From 049ce761eacde645f75934ec00a320ed698544c6 Mon Sep 17 00:00:00 2001 From: Nick O'Neill Date: Sun, 6 Sep 2026 15:36:46 -0700 Subject: [PATCH 1/3] Register push tokens with the 5calls API instead of OneSignal OneSignal is capping the free plan at 1,000 mobile MAU on Oct 1 and we have far more than that, so the API stores push tokens itself now and will send notifications directly. The FCM tokens were always issued under our own Firebase project rather than OneSignal's, so they keep working once we're the ones sending to them. FiveCallsMessagingService takes over the two jobs the OneSignal SDK was doing: onNewToken hands new tokens to the API, and onMessageReceived builds and shows the notification, which OneSignal used to render for us. It uses its own channel so vote alerts can be silenced separately from call reminders. PushRegistration posts to /v1/push/register with the caller's district, which AccountManager already stores, and re-sends when the district changes the same way the OneSignal tag was updated. Turning notifications off deletes the token rather than just opting out locally. Only firebase-messaging is added; the Firebase BOM, the google-services plugin and google-services.json were already here for auth and analytics. New files are Kotlin, existing Java left as Java. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01UxrjMYu6big5x2XrR7HyTy --- 5calls/app/build.gradle | 4 +- 5calls/app/src/main/AndroidManifest.xml | 8 ++ .../android/a5calls/FiveCallsApplication.java | 6 - .../NotificationSettingsDialog.java | 21 ++- .../a5calls/controller/SettingsActivity.java | 8 +- .../android/a5calls/net/FiveCallsApi.java | 8 +- .../a5calls/net/FiveCallsMessagingService.kt | 113 +++++++++++++++ .../android/a5calls/net/PushRegistration.kt | 129 ++++++++++++++++++ 5calls/app/src/main/res/values/strings.xml | 1 + 9 files changed, 276 insertions(+), 22 deletions(-) create mode 100644 5calls/app/src/main/java/org/a5calls/android/a5calls/net/FiveCallsMessagingService.kt create mode 100644 5calls/app/src/main/java/org/a5calls/android/a5calls/net/PushRegistration.kt 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..b3f70392 --- /dev/null +++ b/5calls/app/src/main/java/org/a5calls/android/a5calls/net/PushRegistration.kt @@ -0,0 +1,129 @@ +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.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().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..5472a6c4 100644 --- a/5calls/app/src/main/res/values/strings.xml +++ b/5calls/app/src/main/res/values/strings.xml @@ -833,4 +833,5 @@ Show an example issue in the list with hints about how to use 5 Calls. + Vote alerts From 403f814982a9e876f56690d58274501aed722c30 Mon Sep 17 00:00:00 2001 From: Nick O'Neill Date: Sun, 6 Sep 2026 16:23:28 -0700 Subject: [PATCH 2/3] Re-send the push token on launch onNewToken only fires when FCM rotates a token, so a registration that failed once, or a district that changed while the device had no network, would stay wrong until the token happened to change. iOS already re-registers on launch via registerIfAuthorized; this brings Android in line. Skipped when notifications are turned off, otherwise the next launch would undo the unregister we send on opt-out. Also skipped when the OS permission isn't granted, which is the existing branch that resets the preference. refreshToken now returns early when Firebase isn't initialized. Calling it from Application.onCreate meant FirebaseMessaging.getInstance() ran in unit tests, where FirebaseApp doesn't exist, and threw through 140 of them. The api upserts on the token, so repeating this costs one request. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01UxrjMYu6big5x2XrR7HyTy --- .../a5calls/android/a5calls/FiveCallsApplication.java | 11 +++++++++++ .../a5calls/android/a5calls/net/PushRegistration.kt | 9 +++++++++ 2 files changed, 20 insertions(+) diff --git a/5calls/app/src/main/java/org/a5calls/android/a5calls/FiveCallsApplication.java b/5calls/app/src/main/java/org/a5calls/android/a5calls/FiveCallsApplication.java index 16bfb8ba..f48c21cd 100644 --- a/5calls/app/src/main/java/org/a5calls/android/a5calls/FiveCallsApplication.java +++ b/5calls/app/src/main/java/org/a5calls/android/a5calls/FiveCallsApplication.java @@ -19,12 +19,14 @@ import android.app.Activity; import android.app.Application; import android.os.Bundle; +import android.text.TextUtils; import androidx.core.app.NotificationManagerCompat; import org.a5calls.android.a5calls.controller.SettingsActivity; import org.a5calls.android.a5calls.model.AccountManager; +import org.a5calls.android.a5calls.net.PushRegistration; import org.a5calls.android.a5calls.model.NotificationUtils; import org.a5calls.android.a5calls.util.AnalyticsManager; @@ -102,6 +104,15 @@ public void onCreate() { AccountManager.Instance, AccountManager.DEFAULT_NOTIFICATION_SELECTION ); + } else if (!TextUtils.equals("1", + AccountManager.Instance.getNotificationPreference(this))) { + // Re-send the token on launch, unless they've turned notifications + // off. onNewToken only fires when FCM rotates a token, so without + // this a registration that failed once, or a district that changed + // while we had no network, would stay wrong until the token + // happened to change. The api upserts on the token so repeating + // this is cheap. + PushRegistration.INSTANCE.refreshToken(this); } } 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 index b3f70392..d93dd321 100644 --- 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 @@ -6,6 +6,7 @@ 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 @@ -43,6 +44,14 @@ object PushRegistration { * 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") } From 76e291cd8e370332c0ac64db96c736b7720c0ba9 Mon Sep 17 00:00:00 2001 From: Nick O'Neill Date: Sun, 6 Sep 2026 16:30:54 -0700 Subject: [PATCH 3/3] Name the push channel after what Android actually sends "Vote alerts" described the iOS inbox, which shows how a rep voted. Android has no inbox; its notifications are alerts about new issues worth calling about, which is how the app already describes them in the opt-in dialog and in disable_new_issues_notification. The channel name is only the label in system notification settings, and it updates on the next createNotificationChannel call, so this reaches existing installs without a new channel. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01UxrjMYu6big5x2XrR7HyTy --- 5calls/app/src/main/res/values/strings.xml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/5calls/app/src/main/res/values/strings.xml b/5calls/app/src/main/res/values/strings.xml index 5472a6c4..769a4f9e 100644 --- a/5calls/app/src/main/res/values/strings.xml +++ b/5calls/app/src/main/res/values/strings.xml @@ -833,5 +833,6 @@ Show an example issue in the list with hints about how to use 5 Calls. - Vote alerts + + Important issues