diff --git a/app/src/main/java/io/heckel/ntfy/up/Distributor.kt b/app/src/main/java/io/heckel/ntfy/up/Distributor.kt index 81f6ec36..7ea70681 100644 --- a/app/src/main/java/io/heckel/ntfy/up/Distributor.kt +++ b/app/src/main/java/io/heckel/ntfy/up/Distributor.kt @@ -11,6 +11,7 @@ import io.heckel.ntfy.util.Log class Distributor(val context: Context) { fun sendMessage(app: String, connectorToken: String, message: ByteArray) { Log.d(TAG, "Sending MESSAGE to $app (token=$connectorToken): ${message.size} bytes") + RaiseAppToForegroundFactory.getInstance(context, app).raise() val broadcastIntent = Intent() broadcastIntent.`package` = app broadcastIntent.action = ACTION_MESSAGE diff --git a/app/src/main/java/io/heckel/ntfy/up/RaiseAppToForeground.kt b/app/src/main/java/io/heckel/ntfy/up/RaiseAppToForeground.kt new file mode 100644 index 00000000..f86809f2 --- /dev/null +++ b/app/src/main/java/io/heckel/ntfy/up/RaiseAppToForeground.kt @@ -0,0 +1,134 @@ +package io.heckel.ntfy.up + +import android.app.ActivityManager +import android.app.ActivityManager.RunningAppProcessInfo +import android.content.ComponentName +import android.content.Context +import android.content.Intent +import android.content.ServiceConnection +import android.os.Build +import android.os.IBinder +import android.util.Log +import kotlinx.coroutines.Runnable +import java.util.concurrent.Executors +import java.util.concurrent.ScheduledFuture +import java.util.concurrent.TimeUnit + +class RaiseAppToForeground(private val context: Context, private val app: String, private val onUnbound: () -> Unit): ServiceConnection, Runnable { + + /** + * Is the service bound ? This is a per service connection + */ + private var bound = false + private var scheduledFuture: ScheduledFuture<*>? = null + + private val foregroundImportance = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + listOf( + RunningAppProcessInfo.IMPORTANCE_FOREGROUND, + RunningAppProcessInfo.IMPORTANCE_FOREGROUND_SERVICE, + ) + } else { + listOf( + RunningAppProcessInfo.IMPORTANCE_FOREGROUND, + ) + } + + /** + * @return `true` if the app is in Foreground importance + */ + private fun checkForeground(): Boolean { + val appProcesses = (context.getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager).runningAppProcesses + for (appProcess in appProcesses) { + if (appProcess.importance in foregroundImportance) { + Log.i(TAG, "Found foreground process: ${appProcess.processName}") + return true + } + } + return false + } + + /** + * Raise [app] to the foreground, to follow AND_3 specifications + * + * @return `true` if have successfully raised app to foreground + */ + fun raise(): Boolean { + // Per instance lock + synchronized(this) { + if (bound) { + Log.w(TAG, "This service connection is already bound to $app. Aborting.") + /** + * Close current scheduledFuture. We interrupt if it is running, so [run] won't + * unbind this new connection after we release the lock. + */ + scheduledFuture?.cancel(/* mayInterruptIfRunning = */ true) + /** Call [run] (unbind) in 5 seconds */ + scheduledFuture = unbindExecutor.schedule(this, 5L, TimeUnit.SECONDS) + return true + } else if (checkForeground()) { + Log.d(TAG, "Binding to $app/$TARGET_CLASS") + val intent = Intent().apply { + `package` = app + action = ACTION + } + //val sConnection = RaiseAppToForeground(context, app) + /** Bind to the target raise to the foreground service */ + context.bindService(intent, this, Context.BIND_AUTO_CREATE) + /** Call [run] (unbind) in 5 seconds */ + scheduledFuture = unbindExecutor.schedule(this, 5L, TimeUnit.SECONDS) + Log.d(TAG, "Bound to $app") + bound = true + return true + } else { + Log.d(TAG, "We are not in foreground, can't raise $app to foreground") + return false + } + } + } + + private fun unbind() { + // Per instance lock + synchronized(this) { + if (bound) { + context.unbindService(this) + bound = false + onUnbound() + Log.d(TAG, "Unbound") + } + } + } + + override fun onServiceConnected(name: ComponentName?, service: IBinder?) { + Log.d(TAG, "onServiceConnected $name") + } + + override fun onServiceDisconnected(name: ComponentName?) { + Log.d(TAG, "onServiceDisconnected $name") + } + + override fun onBindingDied(name: ComponentName?) { + Log.d(TAG, "onBindingDied") + unbind() + } + + override fun onNullBinding(name: ComponentName?) { + Log.d(TAG, "onBindingDied") + unbind() + } + + /** + * Unbinding when the timeout passes. + */ + override fun run() { + Log.d(TAG, "Timeout expired, unbinding") + unbind() + } + + private companion object { + private const val TAG = "RaiseAppToForeground" + private const val TARGET_CLASS = "org.unifiedpush.android.connector.RaiseToForegroundService" + private const val ACTION = "org.unifiedpush.android.distributor.RAISE_TO_FOREGROUND" + /** Executor to unbind 5 seconds later */ + private val unbindExecutor = Executors.newSingleThreadScheduledExecutor() + } +} \ No newline at end of file diff --git a/app/src/main/java/io/heckel/ntfy/up/RaiseAppToForegroundFactory.kt b/app/src/main/java/io/heckel/ntfy/up/RaiseAppToForegroundFactory.kt new file mode 100644 index 00000000..de2b71a9 --- /dev/null +++ b/app/src/main/java/io/heckel/ntfy/up/RaiseAppToForegroundFactory.kt @@ -0,0 +1,39 @@ +package io.heckel.ntfy.up + +import android.content.Context +import android.util.Log + +/** + * [RaiseAppToForeground] Factory, to avoid having one service connection per push message + * + * There is a very small chance that 2 connections exist at the same time\*, but that's not important. + * We just want to avoid tens of it. + * + * \* When [getInstance] returns an existing instance, that runs [remove] before + * [RaiseAppToForeground.raise] is called. + */ +object RaiseAppToForegroundFactory { + fun getInstance(context: Context, app: String): RaiseAppToForeground { + synchronized(this) { + return instances[app] ?: new(context, app) + } + } + + private fun new(context: Context, app: String): RaiseAppToForeground { + return RaiseAppToForeground(context, app, onUnbound = { + remove(app) + }).also { + Log.d(TAG, "New instance for $app") + instances[app] = it + } + } + + private fun remove(app: String) { + Log.d(TAG, "Removing instance for $app") + synchronized(this) { + instances.remove(app) + } + } + private val instances: MutableMap = mutableMapOf() + private const val TAG = "RaiseAppToF.Factory" +} \ No newline at end of file