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