diff --git a/5calls/app/build.gradle b/5calls/app/build.gradle index 16cef03d..5ba135ef 100644 --- a/5calls/app/build.gradle +++ b/5calls/app/build.gradle @@ -89,8 +89,8 @@ dependencies { implementation 'com.github.PhilJay:MPAndroidChart:v3.0.3' implementation 'com.jjoe64:graphview:4.2.2' - // OneSignal - implementation 'com.onesignal:OneSignal:5.1.28' + // Push notifications, sent by our own api + implementation 'com.google.firebase:firebase-messaging' // Plausible implementation 'com.github.OneBusAway:plausible-android-sdk:3.3' diff --git a/5calls/app/src/main/AndroidManifest.xml b/5calls/app/src/main/AndroidManifest.xml index 520f2807..caf14c7f 100644 --- a/5calls/app/src/main/AndroidManifest.xml +++ b/5calls/app/src/main/AndroidManifest.xml @@ -75,6 +75,14 @@ android:value=".controller.MainActivity" /> + + + + + + mPermissionRequest; + + @Override + public void onCreate(@Nullable Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + mPermissionRequest = SettingsActivity.createNotificationPermissionRequest( + this, isGranted -> {}); + } + public NotificationSettingsDialog() { } @@ -49,9 +61,8 @@ public void onClick(DialogInterface dialogInterface, int i) { builder.setPositiveButton(R.string.save, new DialogInterface.OnClickListener() { @Override public void onClick(DialogInterface dialogInterface, int i) { - if (mSelectedOption == 0) { - OneSignal.getUser().getPushSubscription().optIn(); - OneSignal.getNotifications().requestPermission(true, Continue.none()); + if (mSelectedOption == 0 && mPermissionRequest != null) { + mPermissionRequest.launch(Manifest.permission.POST_NOTIFICATIONS); // TODO(#139): Do not turn on notifications preference if they did not enable // permissions. } diff --git a/5calls/app/src/main/java/org/a5calls/android/a5calls/controller/SettingsActivity.java b/5calls/app/src/main/java/org/a5calls/android/a5calls/controller/SettingsActivity.java index 6cc58221..3f559b32 100644 --- a/5calls/app/src/main/java/org/a5calls/android/a5calls/controller/SettingsActivity.java +++ b/5calls/app/src/main/java/org/a5calls/android/a5calls/controller/SettingsActivity.java @@ -32,12 +32,11 @@ import android.text.format.DateFormat; import android.view.MenuItem; -import com.onesignal.Continue; -import com.onesignal.OneSignal; import org.a5calls.android.a5calls.FiveCallsApplication; import org.a5calls.android.a5calls.R; import org.a5calls.android.a5calls.model.AccountManager; +import org.a5calls.android.a5calls.net.PushRegistration; import org.a5calls.android.a5calls.model.NotificationUtils; import java.text.SimpleDateFormat; @@ -142,11 +141,10 @@ public static void updateNotificationsPreference(FiveCallsApplication applicatio String result) { accountManager.setNotificationPreference(application, result); if (TextUtils.equals("0", result)) { - OneSignal.getNotifications().requestPermission(true, Continue.none()); - OneSignal.getUser().getPushSubscription().optIn(); // TODO(#139): Wait for permission request result before opting in + PushRegistration.INSTANCE.refreshToken(application); } else if (TextUtils.equals("1", result)) { - OneSignal.getUser().getPushSubscription().optOut(); + PushRegistration.INSTANCE.unregister(application); } // If the user changes the settings there's no need to show the dialog in the future. accountManager.setNotificationDialogShown(application, true); diff --git a/5calls/app/src/main/java/org/a5calls/android/a5calls/net/FiveCallsApi.java b/5calls/app/src/main/java/org/a5calls/android/a5calls/net/FiveCallsApi.java index beee4f39..38ddffc4 100644 --- a/5calls/app/src/main/java/org/a5calls/android/a5calls/net/FiveCallsApi.java +++ b/5calls/app/src/main/java/org/a5calls/android/a5calls/net/FiveCallsApi.java @@ -16,7 +16,6 @@ import com.google.gson.Gson; import com.google.gson.GsonBuilder; import com.google.gson.reflect.TypeToken; -import com.onesignal.OneSignal; import org.a5calls.android.a5calls.BuildConfig; import org.a5calls.android.a5calls.model.AccountManager; @@ -296,9 +295,10 @@ public void onResponse(JSONObject response) { AccountManager.Instance.setDistrict(mContext, district); districtId = state + "-" + district; - if (OneSignal.isInitialized()) { - OneSignal.getUser().addTag("districtID", districtId); - } + // the api targets notifications by district, so + // it needs to hear about a change the same way + // the onesignal tag used to + PushRegistration.INSTANCE.updateDistrict(mContext); } } catch (JSONException e) { e.printStackTrace(); diff --git a/5calls/app/src/main/java/org/a5calls/android/a5calls/net/FiveCallsMessagingService.kt b/5calls/app/src/main/java/org/a5calls/android/a5calls/net/FiveCallsMessagingService.kt new file mode 100644 index 00000000..120f9d1a --- /dev/null +++ b/5calls/app/src/main/java/org/a5calls/android/a5calls/net/FiveCallsMessagingService.kt @@ -0,0 +1,113 @@ +package org.a5calls.android.a5calls.net + +import android.app.Notification +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import android.os.Build +import android.util.Log +import androidx.core.content.ContextCompat +import com.google.firebase.messaging.FirebaseMessagingService +import com.google.firebase.messaging.RemoteMessage +import org.a5calls.android.a5calls.R +import org.a5calls.android.a5calls.controller.MainActivity + +/** + * Receives push notifications from FCM and shows them. OneSignal's SDK used to + * do both the token handling and the display; this does the same work against + * our own Firebase project and our own API. + */ +class FiveCallsMessagingService : FirebaseMessagingService() { + companion object { + private const val TAG = "FiveCallsMessaging" + + /** + * Separate from the call reminder channel so people can silence vote + * alerts without losing their own reminders. + */ + const val CHANNEL_ID = "5calls_push_channel" + + private const val NOTIFICATION_ID = 8288 + + /** Custom data the API sends so a tap can open the right message. */ + const val MESSAGE_ID_KEY = "messageid" + } + + /** + * FCM hands us a new token on install, reinstall, restore, and whenever it + * decides to rotate one. Passing it straight to the API is what keeps our + * token list from going stale. + */ + override fun onNewToken(token: String) { + super.onNewToken(token) + PushRegistration.register(applicationContext, token) + } + + override fun onMessageReceived(message: RemoteMessage) { + super.onMessageReceived(message) + + // notifications from our API carry a notification block; fall back to + // the data payload so a data-only send still shows something + val title = message.notification?.title ?: message.data["title"] + val body = message.notification?.body ?: message.data["body"] + + if (title.isNullOrEmpty() && body.isNullOrEmpty()) { + Log.w(TAG, "push with no title or body, nothing to show") + return + } + + showNotification(title, body, message.data[MESSAGE_ID_KEY]) + } + + private fun showNotification(title: String?, body: String?, messageId: String?) { + createChannel() + + val intent = Intent(this, MainActivity::class.java).apply { + putExtra(MainActivity.EXTRA_FROM_NOTIFICATION, true) + if (!messageId.isNullOrEmpty()) { + putExtra(MESSAGE_ID_KEY, messageId) + } + } + + val pendingIntent = PendingIntent.getActivity( + this, + MainActivity.NOTIFICATION_REQUEST, + intent, + PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT + ) + + val builder = Notification.Builder(this) + .setContentTitle(title ?: getString(R.string.app_name)) + .setContentText(body) + .setStyle(Notification.BigTextStyle().bigText(body)) + .setContentIntent(pendingIntent) + .setAutoCancel(true) + .setSmallIcon(R.drawable.app_icon_bw) + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + builder.setChannelId(CHANNEL_ID) + } + + builder.setColor(ContextCompat.getColor(this, R.color.colorPrimary)) + + val manager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + manager.notify(NOTIFICATION_ID, builder.build()) + } + + private fun createChannel() { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) { + return + } + + val manager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + val channel = NotificationChannel( + CHANNEL_ID, + getString(R.string.push_notification_channel_name), + NotificationManager.IMPORTANCE_DEFAULT + ) + + manager.createNotificationChannel(channel) + } +} diff --git a/5calls/app/src/main/java/org/a5calls/android/a5calls/net/PushRegistration.kt b/5calls/app/src/main/java/org/a5calls/android/a5calls/net/PushRegistration.kt new file mode 100644 index 00000000..d93dd321 --- /dev/null +++ b/5calls/app/src/main/java/org/a5calls/android/a5calls/net/PushRegistration.kt @@ -0,0 +1,138 @@ +package org.a5calls.android.a5calls.net + +import android.content.Context +import android.text.TextUtils +import android.util.Log +import com.android.volley.AuthFailureError +import com.android.volley.Request +import com.android.volley.toolbox.JsonObjectRequest +import com.google.firebase.FirebaseApp +import com.google.firebase.messaging.FirebaseMessaging +import org.a5calls.android.a5calls.AppSingleton +import org.a5calls.android.a5calls.model.AccountManager +import org.json.JSONObject + +/** + * Tells the 5calls API about this device's FCM token so it can send us + * notifications directly, which OneSignal used to do. + * + * The token is issued by our own Firebase project, so it keeps working no + * matter who sends to it. Registration upserts on the token, so calling this + * more than once is harmless and is how a token stays fresh. + */ +object PushRegistration { + private const val TAG = "PushRegistration" + private const val REGISTER_URL = "https://api.5calls.org/v1/push/register" + + private const val PREFS_NAME = "org.a5calls.android.a5calls.push" + private const val KEY_TOKEN = "pushToken" + + /** The token we last sent, so a district change can re-send it. */ + fun getToken(context: Context): String? = + context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + .getString(KEY_TOKEN, null) + + private fun setToken(context: Context, token: String?) { + context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + .edit() + .putString(KEY_TOKEN, token) + .apply() + } + + /** + * Asks FCM for the current token and registers it. Used when someone turns + * notifications on, since onNewToken only fires when the token changes. + */ + fun refreshToken(context: Context) { + // FirebaseMessaging.getInstance() throws when Firebase never came up, + // which is the case in unit tests and would be the case in a build + // missing google-services.json. There's no token to refresh then. + if (FirebaseApp.getApps(context).isEmpty()) { + Log.w(TAG, "firebase isn't initialized, skipping token refresh") + return + } + + FirebaseMessaging.getInstance().token + .addOnSuccessListener { token -> register(context, token) } + .addOnFailureListener { error -> Log.w(TAG, "couldn't get an fcm token: $error") } + } + + /** Sends a token to the API along with the caller's district, if we know it. */ + fun register(context: Context, token: String) { + if (TextUtils.isEmpty(token)) { + return + } + + setToken(context, token) + + val body = JSONObject().apply { + put("token", token) + put("platform", "android") + put("district", district(context)) + } + + send(context, Request.Method.POST, body) + } + + /** + * Re-sends the token we already have after the district changes, so + * notifications go to the right people. No-op before we have a token. + */ + fun updateDistrict(context: Context) { + val token = getToken(context) ?: return + register(context, token) + } + + /** Drops this device's token when someone turns notifications off. */ + fun unregister(context: Context) { + val token = getToken(context) ?: return + + val body = JSONObject().apply { + put("token", token) + } + + send(context, Request.Method.DELETE, body) + setToken(context, null) + } + + /** + * The district in the form the API wants, e.g. CA-12. Empty when we don't + * know it yet: the API keeps the token and just can't target it by + * district until the next registration. + */ + private fun district(context: Context): String { + val state = AccountManager.Instance.getState(context) + val district = AccountManager.Instance.getDistrict(context) + + if (TextUtils.isEmpty(state) || TextUtils.isEmpty(district)) { + return "" + } + + return "$state-$district" + } + + private fun send(context: Context, method: Int, body: JSONObject) { + val callerId = AccountManager.Instance.getCallerID(context) + if (TextUtils.isEmpty(callerId)) { + Log.w(TAG, "no caller id yet, skipping push registration") + return + } + + val request = object : JsonObjectRequest( + method, + REGISTER_URL, + body, + { Log.d(TAG, "push registration updated") }, + { error -> Log.w(TAG, "push registration failed: $error") } + ) { + @Throws(AuthFailureError::class) + override fun getHeaders(): Map = mapOf( + "Content-Type" to "application/json", + "X-Caller-ID" to callerId + ) + } + + request.tag = TAG + AppSingleton.getInstance(context).requestQueue.add(request) + } +} diff --git a/5calls/app/src/main/res/values/strings.xml b/5calls/app/src/main/res/values/strings.xml index ae22c64a..769a4f9e 100644 --- a/5calls/app/src/main/res/values/strings.xml +++ b/5calls/app/src/main/res/values/strings.xml @@ -833,4 +833,6 @@ Show an example issue in the list with hints about how to use 5 Calls. + + Important issues