diff --git a/android/app/capacitor.build.gradle b/android/app/capacitor.build.gradle index f4a19deee0..433e7dfd0a 100644 --- a/android/app/capacitor.build.gradle +++ b/android/app/capacitor.build.gradle @@ -10,7 +10,6 @@ android { apply from: "../capacitor-cordova-android-plugins/cordova.variables.gradle" dependencies { implementation project(':capacitor-browser') - implementation project(':capacitor-keyboard') implementation project(':capacitor-status-bar') implementation project(':capacitor-plugin-safe-area') implementation project(':capacitor-app') diff --git a/android/app/src/main/java/com/superproductivity/superproductivity/CapacitorMainActivity.kt b/android/app/src/main/java/com/superproductivity/superproductivity/CapacitorMainActivity.kt index 79831cb0c1..fadac28c15 100644 --- a/android/app/src/main/java/com/superproductivity/superproductivity/CapacitorMainActivity.kt +++ b/android/app/src/main/java/com/superproductivity/superproductivity/CapacitorMainActivity.kt @@ -20,6 +20,7 @@ import com.superproductivity.superproductivity.plugins.NavigationBarPlugin import com.superproductivity.superproductivity.plugins.SafBridgePlugin import com.superproductivity.superproductivity.service.BackgroundSyncCredentialStore import com.superproductivity.superproductivity.service.FocusModeForegroundService +import com.superproductivity.superproductivity.service.ForegroundServiceFailure import com.superproductivity.superproductivity.service.SyncReminderScheduler import com.superproductivity.superproductivity.service.TrackingForegroundService import com.superproductivity.superproductivity.util.printWebViewVersion @@ -42,6 +43,8 @@ class CapacitorMainActivity : BridgeActivity() { private var pendingShareIntent: JSONObject? = null private var isFrontendReady = false private var startupOverlayManager: StartupOverlayManager? = null + private var isTimerCompleteReceiverRegistered = false + private var isForegroundServiceFailureReceiverRegistered = false private val storageHelper = SimpleStorageHelper(this) // for scoped storage permission management on Android 10+ @@ -56,6 +59,21 @@ class CapacitorMainActivity : BridgeActivity() { } } + private val foregroundServiceFailureReceiver = object : BroadcastReceiver() { + override fun onReceive(context: Context?, intent: Intent?) { + if (intent?.action != ForegroundServiceFailure.ACTION) { + return + } + val service = intent.getStringExtra(ForegroundServiceFailure.EXTRA_SERVICE) ?: return + val reason = intent.getStringExtra(ForegroundServiceFailure.EXTRA_REASON) ?: return + callJSInterfaceFunctionIfExists( + "next", + "onForegroundServiceStartFailed$", + "{service:${JSONObject.quote(service)},reason:${JSONObject.quote(reason)}}" + ) + } + } + override fun load() { val result = WebViewCompatibilityChecker.evaluate(this) webViewCompatibility = result @@ -181,6 +199,12 @@ class CapacitorMainActivity : BridgeActivity() { timerCompleteReceiver, IntentFilter(FocusModeForegroundService.ACTION_TIMER_COMPLETE) ) + isTimerCompleteReceiverRegistered = true + LocalBroadcastManager.getInstance(this).registerReceiver( + foregroundServiceFailureReceiver, + IntentFilter(ForegroundServiceFailure.ACTION) + ) + isForegroundServiceFailureReceiverRegistered = true // Show startup overlay for quick task entry while Angular loads. // Only on fresh cold start — not on config-change recreation. @@ -363,8 +387,17 @@ class CapacitorMainActivity : BridgeActivity() { override fun onDestroy() { startupOverlayManager?.dismiss() startupOverlayManager = null + if (isTimerCompleteReceiverRegistered) { + LocalBroadcastManager.getInstance(this).unregisterReceiver(timerCompleteReceiver) + isTimerCompleteReceiverRegistered = false + } + if (isForegroundServiceFailureReceiverRegistered) { + LocalBroadcastManager.getInstance(this).unregisterReceiver( + foregroundServiceFailureReceiver + ) + isForegroundServiceFailureReceiverRegistered = false + } super.onDestroy() - LocalBroadcastManager.getInstance(this).unregisterReceiver(timerCompleteReceiver) } companion object { diff --git a/android/app/src/main/java/com/superproductivity/superproductivity/FullscreenActivity.kt b/android/app/src/main/java/com/superproductivity/superproductivity/FullscreenActivity.kt index dc1eb70db2..c3f07ef1f4 100644 --- a/android/app/src/main/java/com/superproductivity/superproductivity/FullscreenActivity.kt +++ b/android/app/src/main/java/com/superproductivity/superproductivity/FullscreenActivity.kt @@ -1,8 +1,11 @@ package com.superproductivity.superproductivity import android.app.AlertDialog +import android.content.BroadcastReceiver import android.content.ComponentName +import android.content.Context import android.content.Intent +import android.content.IntentFilter import android.graphics.Rect import android.os.Build import android.os.Bundle @@ -22,14 +25,17 @@ import android.widget.FrameLayout import android.widget.Toast import androidx.annotation.RequiresApi import androidx.appcompat.app.AppCompatActivity +import androidx.localbroadcastmanager.content.LocalBroadcastManager import com.anggrayudi.storage.SimpleStorageHelper import com.superproductivity.superproductivity.app.LaunchDecider +import com.superproductivity.superproductivity.service.ForegroundServiceFailure import com.superproductivity.superproductivity.util.printWebViewVersion import com.superproductivity.superproductivity.webview.JavaScriptInterface import com.superproductivity.superproductivity.webview.WebHelper import com.superproductivity.superproductivity.webview.WebViewBlockActivity import com.superproductivity.superproductivity.webview.WebViewCompatibilityChecker import com.superproductivity.superproductivity.webview.WebViewRequestHandler +import org.json.JSONObject /** @@ -40,6 +46,7 @@ class FullscreenActivity : AppCompatActivity() { private lateinit var javaScriptInterface: JavaScriptInterface private lateinit var webView: WebView private lateinit var wvContainer: FrameLayout + private var isForegroundServiceFailureReceiverRegistered = false private var webViewRequestHandler = WebViewRequestHandler(this, BuildConfig.ONLINE_SERVICE_HOST) val storageHelper = SimpleStorageHelper(this) // for scoped storage permission management on Android 10+ @@ -47,6 +54,21 @@ class FullscreenActivity : AppCompatActivity() { // if (BuildConfig.DEBUG) "https://test-app.super-productivity.com" else "https://app.super-productivity.com" "${BuildConfig.ONLINE_SERVICE_PROTOCOL}://${BuildConfig.ONLINE_SERVICE_HOST}" + private val foregroundServiceFailureReceiver = object : BroadcastReceiver() { + override fun onReceive(context: Context?, intent: Intent?) { + if (intent?.action != ForegroundServiceFailure.ACTION) { + return + } + val service = intent.getStringExtra(ForegroundServiceFailure.EXTRA_SERVICE) ?: return + val reason = intent.getStringExtra(ForegroundServiceFailure.EXTRA_REASON) ?: return + callJSInterfaceFunctionIfExists( + "next", + "onForegroundServiceStartFailed$", + "{service:${JSONObject.quote(service)},reason:${JSONObject.quote(reason)}}" + ) + } + } + @Suppress("ReplaceCallWithBinaryOperator") override fun onCreate(savedInstanceState: Bundle?) { Log.v("TW", "FullScreenActivity: onCreate") @@ -109,6 +131,11 @@ class FullscreenActivity : AppCompatActivity() { setContentView(R.layout.activity_fullscreen) wvContainer = findViewById(R.id.webview_container) wvContainer.addView(webView) + LocalBroadcastManager.getInstance(this).registerReceiver( + foregroundServiceFailureReceiver, + IntentFilter(ForegroundServiceFailure.ACTION) + ) + isForegroundServiceFailureReceiverRegistered = true if (savedInstanceState != null) { webView.restoreState(savedInstanceState) } else { @@ -331,6 +358,12 @@ class FullscreenActivity : AppCompatActivity() { if (::wvContainer.isInitialized) { wvContainer.removeView(webView) } + if (isForegroundServiceFailureReceiverRegistered) { + LocalBroadcastManager.getInstance(this).unregisterReceiver( + foregroundServiceFailureReceiver + ) + isForegroundServiceFailureReceiverRegistered = false + } super.onDestroy() } diff --git a/android/app/src/main/java/com/superproductivity/superproductivity/service/FocusModeForegroundService.kt b/android/app/src/main/java/com/superproductivity/superproductivity/service/FocusModeForegroundService.kt index c72f213e2e..740ae35636 100644 --- a/android/app/src/main/java/com/superproductivity/superproductivity/service/FocusModeForegroundService.kt +++ b/android/app/src/main/java/com/superproductivity/superproductivity/service/FocusModeForegroundService.kt @@ -83,11 +83,14 @@ class FocusModeForegroundService : Service() { override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { Log.d(TAG, "onStartCommand: action=${intent?.action}") - // Every Context.startForegroundService() creates a per-call FGS token - // that must be answered with startForeground() within the system - // timeout, regardless of action or existing state. Satisfy it here so - // every branch below already has the contract honored. - ensureForegroundNotification() + // Android documents successful startForeground() as the safe path + // after startForegroundService(). Promote before handling actions so + // newly started services satisfy that contract. + if (!ensureForegroundNotification()) { + reportForegroundFailure() + stopAfterForegroundFailure(startId) + return START_NOT_STICKY + } when (intent?.action) { ACTION_START -> { @@ -98,7 +101,11 @@ class FocusModeForegroundService : Service() { isBreak = intent.getBooleanExtra(EXTRA_IS_BREAK, false) isPaused = intent.getBooleanExtra(EXTRA_IS_PAUSED, false) - startFocusMode() + if (!startFocusMode()) { + reportForegroundFailure() + stopAfterForegroundFailure(startId) + return START_NOT_STICKY + } } ACTION_UPDATE -> { @@ -144,9 +151,9 @@ class FocusModeForegroundService : Service() { return START_NOT_STICKY } - private fun ensureForegroundNotification() { - try { - val notification = if (isRunning && title.isNotEmpty()) { + private fun ensureForegroundNotification(): Boolean { + val notification = try { + if (isRunning && title.isNotEmpty()) { FocusModeNotificationHelper.buildNotification( this, title, @@ -157,7 +164,7 @@ class FocusModeForegroundService : Service() { ) } else { // A content title is required on some OEM skins (notably Samsung - // One UI) — a title-less notification can render blank or cause + // One UI) - a title-less notification can render blank or cause // startForeground() to throw IllegalArgumentException on a few // Android 14 builds, which would re-trigger the FGS timeout. androidx.core.app.NotificationCompat.Builder( @@ -171,41 +178,33 @@ class FocusModeForegroundService : Service() { .setSilent(true) .build() } - startForegroundSpecialUse(FocusModeNotificationHelper.NOTIFICATION_ID, notification) - } catch (e: IllegalArgumentException) { - // Some Android 14 builds throw this from startForeground() when the - // notification lacks a content title. We set one above, but keep - // the guard so the FGS timeout path still short-circuits. - Log.e(TAG, "ensureForegroundNotification: invalid notification", e) - } catch (e: SecurityException) { - // Missing POST_NOTIFICATIONS or FGS type permission. - Log.e(TAG, "ensureForegroundNotification: missing permission", e) + } catch (e: RuntimeException) { + Log.e(TAG, "ensureForegroundNotification: failed to build notification", e) + return false } + return startForegroundSpecialUse(FocusModeNotificationHelper.NOTIFICATION_ID, notification) } - private fun startFocusMode() { + private fun startFocusMode(): Boolean { Log.d(TAG, "Starting focus mode: title=$title, durationMs=$durationMs, remainingMs=$remainingMs, isBreak=$isBreak, isPaused=$isPaused") isRunning = true hasNotifiedCompletion = false lastUpdateTimestamp = System.currentTimeMillis() - // Start foreground immediately to avoid ANR - val notification = FocusModeNotificationHelper.buildNotification( - this, - title, - taskTitle, - remainingMs, - isPaused, - isBreak - ) - startForegroundSpecialUse(FocusModeNotificationHelper.NOTIFICATION_ID, notification) + // The foreground-service start token was already satisfied at the top + // of onStartCommand(). Replace the placeholder notification without + // risking a second startForeground() failure resetting focus state. + if (!updateNotification()) { + return false + } // Start update loop if not paused handler.removeCallbacks(updateRunnable) if (!isPaused) { handler.post(updateRunnable) } + return true } private fun stopForegroundAndSelf() { @@ -213,6 +212,28 @@ class FocusModeForegroundService : Service() { stopSelf() } + private fun stopAfterForegroundFailure(startId: Int) { + isRunning = false + handler.removeCallbacks(updateRunnable) + title = "" + taskTitle = null + durationMs = 0 + remainingMs = 0 + isBreak = false + isPaused = false + lastUpdateTimestamp = 0 + hasNotifiedCompletion = false + stopSelf(startId) + } + + private fun reportForegroundFailure() { + ForegroundServiceFailure.send( + this, + ForegroundServiceFailure.SERVICE_FOCUS_MODE, + ForegroundServiceFailure.REASON_PROMOTION_FAILED + ) + } + private fun stopFocusMode() { Log.d(TAG, "Stopping focus mode") @@ -223,10 +244,10 @@ class FocusModeForegroundService : Service() { stopSelf() } - private fun updateNotification() { - if (!isRunning) return + private fun updateNotification(): Boolean { + if (!isRunning) return true - try { + return try { val notification = FocusModeNotificationHelper.buildNotification( this, title, @@ -239,8 +260,10 @@ class FocusModeForegroundService : Service() { FocusModeNotificationHelper.NOTIFICATION_ID, notification ) - } catch (e: SecurityException) { - Log.w(TAG, "No permission to post notification", e) + true + } catch (e: RuntimeException) { + Log.w(TAG, "Unable to update focus mode notification", e) + false } } diff --git a/android/app/src/main/java/com/superproductivity/superproductivity/service/ForegroundServiceExt.kt b/android/app/src/main/java/com/superproductivity/superproductivity/service/ForegroundServiceExt.kt index d784a8909b..cb738b9a89 100644 --- a/android/app/src/main/java/com/superproductivity/superproductivity/service/ForegroundServiceExt.kt +++ b/android/app/src/main/java/com/superproductivity/superproductivity/service/ForegroundServiceExt.kt @@ -4,6 +4,7 @@ import android.app.Notification import android.app.Service import android.content.pm.ServiceInfo import android.os.Build +import android.util.Log import androidx.core.app.ServiceCompat // Why: on Android 14+ the FGS type must match the manifest's `specialUse` @@ -11,13 +12,25 @@ import androidx.core.app.ServiceCompat // best practice and removes OEM-dependent fallback ambiguity. Both // FocusModeForegroundService and TrackingForegroundService declare // `specialUse` and share this helper. -fun Service.startForegroundSpecialUse(id: Int, notification: Notification) { - ServiceCompat.startForeground( - this, - id, - notification, - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) - ServiceInfo.FOREGROUND_SERVICE_TYPE_SPECIAL_USE - else 0, - ) +fun Service.startForegroundSpecialUse(id: Int, notification: Notification): Boolean { + return try { + ServiceCompat.startForeground( + this, + id, + notification, + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) + ServiceInfo.FOREGROUND_SERVICE_TYPE_SPECIAL_USE + else 0, + ) + true + } catch (e: IllegalArgumentException) { + Log.e(javaClass.simpleName, "Unable to start foreground service: invalid notification", e) + false + } catch (e: IllegalStateException) { + Log.e(javaClass.simpleName, "Unable to start foreground service: start not allowed", e) + false + } catch (e: SecurityException) { + Log.e(javaClass.simpleName, "Unable to start foreground service: missing permission", e) + false + } } diff --git a/android/app/src/main/java/com/superproductivity/superproductivity/service/ForegroundServiceFailure.kt b/android/app/src/main/java/com/superproductivity/superproductivity/service/ForegroundServiceFailure.kt new file mode 100644 index 0000000000..4995652449 --- /dev/null +++ b/android/app/src/main/java/com/superproductivity/superproductivity/service/ForegroundServiceFailure.kt @@ -0,0 +1,25 @@ +package com.superproductivity.superproductivity.service + +import android.content.Context +import android.content.Intent +import androidx.localbroadcastmanager.content.LocalBroadcastManager + +object ForegroundServiceFailure { + const val ACTION = "com.superproductivity.ACTION_FOREGROUND_SERVICE_FAILED" + const val EXTRA_SERVICE = "service" + const val EXTRA_REASON = "reason" + + const val SERVICE_TRACKING = "tracking" + const val SERVICE_FOCUS_MODE = "focusMode" + + const val REASON_START_NOT_ALLOWED = "startNotAllowed" + const val REASON_PROMOTION_FAILED = "promotionFailed" + + fun send(context: Context, service: String, reason: String) { + val intent = Intent(ACTION).apply { + putExtra(EXTRA_SERVICE, service) + putExtra(EXTRA_REASON, reason) + } + LocalBroadcastManager.getInstance(context).sendBroadcast(intent) + } +} diff --git a/android/app/src/main/java/com/superproductivity/superproductivity/service/TrackingForegroundService.kt b/android/app/src/main/java/com/superproductivity/superproductivity/service/TrackingForegroundService.kt index b158e73311..65e52c224e 100644 --- a/android/app/src/main/java/com/superproductivity/superproductivity/service/TrackingForegroundService.kt +++ b/android/app/src/main/java/com/superproductivity/superproductivity/service/TrackingForegroundService.kt @@ -71,11 +71,14 @@ class TrackingForegroundService : Service() { override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { Log.d(TAG, "onStartCommand: action=${intent?.action}") - // Every Context.startForegroundService() creates a per-call FGS token - // that must be answered with startForeground() within the system - // timeout, regardless of action or existing state. Satisfy it here so - // every branch below already has the contract honored. - ensureForegroundNotification() + // Android documents successful startForeground() as the safe path + // after startForegroundService(). Promote before handling actions so + // newly started services satisfy that contract. + if (!ensureForegroundNotification()) { + reportForegroundFailure() + stopAfterForegroundFailure(startId) + return START_NOT_STICKY + } when (intent?.action) { ACTION_START -> { @@ -90,7 +93,11 @@ class TrackingForegroundService : Service() { val title = intent.getStringExtra(EXTRA_TASK_TITLE) ?: "Task" val timeSpentMs = intent.getLongExtra(EXTRA_TIME_SPENT, 0L) - startTracking(taskId, title, timeSpentMs) + if (!startTracking(taskId, title, timeSpentMs)) { + reportForegroundFailure() + stopAfterForegroundFailure(startId) + return START_NOT_STICKY + } } ACTION_UPDATE -> { @@ -121,9 +128,9 @@ class TrackingForegroundService : Service() { return START_NOT_STICKY } - private fun ensureForegroundNotification() { - try { - val notification = if (isTracking && taskTitle.isNotEmpty()) { + private fun ensureForegroundNotification(): Boolean { + val notification = try { + if (isTracking && taskTitle.isNotEmpty()) { TrackingNotificationHelper.buildNotification( this, taskTitle, @@ -131,7 +138,7 @@ class TrackingForegroundService : Service() { ) } else { // A content title is required on some OEM skins (notably Samsung - // One UI) — a title-less notification can render blank or cause + // One UI) - a title-less notification can render blank or cause // startForeground() to throw IllegalArgumentException on a few // Android 14 builds, which would re-trigger the FGS timeout. androidx.core.app.NotificationCompat.Builder( @@ -145,16 +152,11 @@ class TrackingForegroundService : Service() { .setSilent(true) .build() } - startForegroundSpecialUse(TrackingNotificationHelper.NOTIFICATION_ID, notification) - } catch (e: IllegalArgumentException) { - // Some Android 14 builds throw this from startForeground() when the - // notification lacks a content title. We set one above, but keep - // the guard so the FGS timeout path still short-circuits. - Log.e(TAG, "ensureForegroundNotification: invalid notification", e) - } catch (e: SecurityException) { - // Missing POST_NOTIFICATIONS or FGS type permission. - Log.e(TAG, "ensureForegroundNotification: missing permission", e) + } catch (e: RuntimeException) { + Log.e(TAG, "ensureForegroundNotification: failed to build notification", e) + return false } + return startForegroundSpecialUse(TrackingNotificationHelper.NOTIFICATION_ID, notification) } private fun stopForegroundAndSelf() { @@ -162,7 +164,25 @@ class TrackingForegroundService : Service() { stopSelf() } - private fun startTracking(taskId: String, title: String, timeSpentMs: Long) { + private fun stopAfterForegroundFailure(startId: Int) { + isTracking = false + handler.removeCallbacks(updateRunnable) + currentTaskId = null + startTimestamp = 0 + accumulatedMs = 0 + taskTitle = "" + stopSelf(startId) + } + + private fun reportForegroundFailure() { + ForegroundServiceFailure.send( + this, + ForegroundServiceFailure.SERVICE_TRACKING, + ForegroundServiceFailure.REASON_PROMOTION_FAILED + ) + } + + private fun startTracking(taskId: String, title: String, timeSpentMs: Long): Boolean { Log.d(TAG, "Starting tracking: taskId=$taskId, title=$title, timeSpentMs=$timeSpentMs") currentTaskId = taskId @@ -171,17 +191,17 @@ class TrackingForegroundService : Service() { startTimestamp = System.currentTimeMillis() isTracking = true - // Start foreground immediately to avoid ANR - val notification = TrackingNotificationHelper.buildNotification( - this, - taskTitle, - getElapsedMs() - ) - startForegroundSpecialUse(TrackingNotificationHelper.NOTIFICATION_ID, notification) + // The foreground-service start token was already satisfied at the top + // of onStartCommand(). Replace the placeholder notification without + // risking a second startForeground() failure resetting tracking state. + if (!updateNotification()) { + return false + } // Start update loop handler.removeCallbacks(updateRunnable) handler.post(updateRunnable) + return true } private fun updateTimeSpent(timeSpentMs: Long) { @@ -214,10 +234,10 @@ class TrackingForegroundService : Service() { stopSelf() } - private fun updateNotification() { - if (!isTracking) return + private fun updateNotification(): Boolean { + if (!isTracking) return true - try { + return try { val notification = TrackingNotificationHelper.buildNotification( this, taskTitle, @@ -227,8 +247,10 @@ class TrackingForegroundService : Service() { TrackingNotificationHelper.NOTIFICATION_ID, notification ) - } catch (e: SecurityException) { - Log.w(TAG, "No permission to post notification", e) + true + } catch (e: RuntimeException) { + Log.w(TAG, "Unable to update tracking notification", e) + false } } diff --git a/android/app/src/main/java/com/superproductivity/superproductivity/webview/JavaScriptInterface.kt b/android/app/src/main/java/com/superproductivity/superproductivity/webview/JavaScriptInterface.kt index c1134cb048..82a556782a 100644 --- a/android/app/src/main/java/com/superproductivity/superproductivity/webview/JavaScriptInterface.kt +++ b/android/app/src/main/java/com/superproductivity/superproductivity/webview/JavaScriptInterface.kt @@ -3,7 +3,9 @@ package com.superproductivity.superproductivity.webview import android.app.Activity import android.app.ForegroundServiceStartNotAllowedException import android.content.Intent +import android.net.Uri import android.os.Build +import android.provider.Settings import android.util.Log import android.webkit.JavascriptInterface import android.webkit.WebView @@ -15,6 +17,7 @@ import com.superproductivity.superproductivity.FullscreenActivity.Companion.WIND import com.superproductivity.superproductivity.app.LaunchDecider import com.superproductivity.superproductivity.service.BackgroundSyncCredentialStore import com.superproductivity.superproductivity.service.FocusModeForegroundService +import com.superproductivity.superproductivity.service.ForegroundServiceFailure import com.superproductivity.superproductivity.service.ReminderNotificationHelper import com.superproductivity.superproductivity.service.SyncReminderScheduler import com.superproductivity.superproductivity.service.TrackingForegroundService @@ -23,6 +26,7 @@ import com.superproductivity.superproductivity.widget.ReminderSnoozeQueue import com.superproductivity.superproductivity.widget.ReminderTapQueue import com.superproductivity.superproductivity.widget.ShareIntentQueue import com.superproductivity.superproductivity.widget.WidgetTaskQueue +import org.json.JSONObject class JavaScriptInterface( @@ -30,18 +34,45 @@ class JavaScriptInterface( private val webView: WebView, ) { - private inline fun safeCall(errorMsg: String, block: () -> Unit) { + private inline fun safeCall( + errorMsg: String, + foregroundService: String? = null, + block: () -> Unit + ) { try { block() } catch (e: Exception) { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && e is ForegroundServiceStartNotAllowedException) { + val isStartNotAllowed = + Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && + e is ForegroundServiceStartNotAllowedException + if (isStartNotAllowed) { Log.e(TAG, "$errorMsg - ForegroundService restrictions violated (Android 12+). App may be in background.", e) } else { Log.e(TAG, errorMsg, e) } + foregroundService?.let { + emitForegroundServiceStartFailed( + it, + if (isStartNotAllowed) { + ForegroundServiceFailure.REASON_START_NOT_ALLOWED + } else { + ForegroundServiceFailure.REASON_PROMOTION_FAILED + } + ) + } } } + private fun emitForegroundServiceStartFailed(service: String, reason: String) { + val payload = "{service:${JSONObject.quote(service)},reason:${JSONObject.quote(reason)}}" + val subjectPath = "${FN_PREFIX}onForegroundServiceStartFailed${'$'}" + callJavaScriptFunction( + "if(window.$WINDOW_INTERFACE_PROPERTY && " + + "$subjectPath) " + + "$subjectPath.next($payload)" + ) + } + @Suppress("unused") @JavascriptInterface fun getVersion(): String { @@ -100,7 +131,10 @@ class JavaScriptInterface( @Suppress("unused") @JavascriptInterface fun startTrackingService(taskId: String, taskTitle: String, timeSpentMs: Long) { - safeCall("Failed to start tracking service") { + safeCall( + "Failed to start tracking service", + ForegroundServiceFailure.SERVICE_TRACKING + ) { val intent = Intent(activity, TrackingForegroundService::class.java).apply { action = TrackingForegroundService.ACTION_START putExtra(TrackingForegroundService.EXTRA_TASK_ID, taskId) @@ -155,7 +189,10 @@ class JavaScriptInterface( isPaused: Boolean, taskTitle: String? ) { - safeCall("Failed to start focus mode service") { + safeCall( + "Failed to start focus mode service", + ForegroundServiceFailure.SERVICE_FOCUS_MODE + ) { val intent = Intent(activity, FocusModeForegroundService::class.java).apply { action = FocusModeForegroundService.ACTION_START putExtra(FocusModeForegroundService.EXTRA_TITLE, title) @@ -337,6 +374,23 @@ class JavaScriptInterface( } } + @Suppress("unused") + @JavascriptInterface + fun openAppNotificationSettings() { + safeCall("Failed to open notification settings") { + val intent = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + Intent(Settings.ACTION_APP_NOTIFICATION_SETTINGS).apply { + putExtra(Settings.EXTRA_APP_PACKAGE, activity.packageName) + } + } else { + Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply { + data = Uri.parse("package:${activity.packageName}") + } + } + activity.startActivity(intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)) + } + } + fun callJavaScriptFunction(script: String) { webView.post { webView.evaluateJavascript(script) { } } } diff --git a/android/capacitor.settings.gradle b/android/capacitor.settings.gradle index b4186cdae4..c601fe7995 100644 --- a/android/capacitor.settings.gradle +++ b/android/capacitor.settings.gradle @@ -5,9 +5,6 @@ project(':capacitor-android').projectDir = new File('../node_modules/@capacitor/ include ':capacitor-browser' project(':capacitor-browser').projectDir = new File('../node_modules/@capacitor/browser/android') -include ':capacitor-keyboard' -project(':capacitor-keyboard').projectDir = new File('../node_modules/@capacitor/keyboard/android') - include ':capacitor-status-bar' project(':capacitor-status-bar').projectDir = new File('../node_modules/@capacitor/status-bar/android') diff --git a/capacitor.config.ts b/capacitor.config.ts index 7fe7498c48..a0380eac5e 100644 --- a/capacitor.config.ts +++ b/capacitor.config.ts @@ -13,13 +13,28 @@ const config: CapacitorConfig = { smallIcon: 'ic_stat_sp', }, Keyboard: { - // Default: resize body (Android) + // Used by iOS native keyboard handling. Android uses JavaScriptInterface + // for keyboard visibility and excludes the native Keyboard plugin below. resize: 'body', resizeOnFullScreen: true, }, }, android: { adjustMarginsForEdgeToEdge: 'auto', + // Android keyboard visibility is handled by JavaScriptInterface. Keeping + // @capacitor/keyboard Android-side registers an unused insets callback + // that can crash in Keyboard$1.onEnd on some devices. + includePlugins: [ + '@capacitor/browser', + '@capacitor/status-bar', + 'capacitor-plugin-safe-area', + '@capacitor/app', + '@capacitor/filesystem', + '@capacitor/local-notifications', + '@capacitor/share', + '@capawesome/capacitor-android-dark-mode-support', + '@capawesome/capacitor-background-task', + ], }, ios: { // Content inset for safe areas (notch, home indicator) diff --git a/src/app/features/android/android-interface.ts b/src/app/features/android/android-interface.ts index b301ebe3b2..e81cd903da 100644 --- a/src/app/features/android/android-interface.ts +++ b/src/app/features/android/android-interface.ts @@ -44,6 +44,7 @@ export interface AndroidInterface { stopTrackingService?(): void; updateTrackingService?(timeSpentMs: number): void; getTrackingElapsed?(): string; + openAppNotificationSettings?(): void; // Foreground service methods for focus mode timer startFocusModeService?( @@ -118,6 +119,7 @@ export interface AndroidInterface { // Focus mode timer completion (native service detected timer reached 0) onFocusModeTimerComplete$: Subject; // boolean indicates isBreak + onForegroundServiceStartFailed$: ReplaySubject; // Reminder notification action callbacks onReminderTap$: ReplaySubject; // emits taskId @@ -130,6 +132,11 @@ export interface AndroidInterface { clearSuperSyncCredentials?(): void; } +export type ForegroundServiceStartFailure = { + service: 'tracking' | 'focusMode'; + reason: 'startNotAllowed' | 'promotionFailed'; +}; + // setInterval(() => { // androidInterface.updatePermanentNotification?.(new Date().toString(), '', -1); // }, 7000); @@ -150,6 +157,7 @@ if (IS_ANDROID_WEB_VIEW) { androidInterface.onFocusSkip$ = new Subject(); androidInterface.onFocusComplete$ = new Subject(); androidInterface.onFocusModeTimerComplete$ = new Subject(); + androidInterface.onForegroundServiceStartFailed$ = new ReplaySubject(3); androidInterface.onReminderTap$ = new ReplaySubject(5); androidInterface.onReminderDone$ = new ReplaySubject(20); androidInterface.onReminderSnooze$ = new ReplaySubject(20); diff --git a/src/app/features/android/store/android.effects.ts b/src/app/features/android/store/android.effects.ts index b1b1f9bb93..d9091dc943 100644 --- a/src/app/features/android/store/android.effects.ts +++ b/src/app/features/android/store/android.effects.ts @@ -7,6 +7,7 @@ import { DroidLog } from '../../../core/log'; import { androidInterface } from '../android-interface'; import { TaskService } from '../../tasks/task.service'; import { TaskAttachmentService } from '../../tasks/task-attachment/task-attachment.service'; +import { T } from '../../../t.const'; // TODO send message to electron when current task changes here @@ -42,6 +43,28 @@ export class AndroidEffects { { dispatch: false }, ); + showForegroundServiceFailure$ = + IS_ANDROID_WEB_VIEW && + createEffect( + () => + androidInterface.onForegroundServiceStartFailed$.pipe( + tap((failure) => { + DroidLog.warn('Foreground service notification failed', failure); + this._snackService.open({ + type: 'WARNING', + msg: + failure.service === 'focusMode' + ? T.F.ANDROID.FOREGROUND_SERVICE_START_FAILED_FOCUS + : T.F.ANDROID.FOREGROUND_SERVICE_START_FAILED_TRACKING, + actionStr: T.F.ANDROID.OPEN_NOTIFICATION_SETTINGS, + actionFn: () => androidInterface.openAppNotificationSettings?.(), + config: { duration: 10000 }, + }); + }), + ), + { dispatch: false }, + ); + // Process tasks queued from the home screen widget processWidgetTasks$ = IS_ANDROID_WEB_VIEW && diff --git a/src/app/t.const.ts b/src/app/t.const.ts index 7c42484488..b1db2f27ba 100644 --- a/src/app/t.const.ts +++ b/src/app/t.const.ts @@ -70,6 +70,13 @@ const T = { }, }, F: { + ANDROID: { + FOREGROUND_SERVICE_START_FAILED_FOCUS: + 'F.ANDROID.FOREGROUND_SERVICE_START_FAILED_FOCUS', + FOREGROUND_SERVICE_START_FAILED_TRACKING: + 'F.ANDROID.FOREGROUND_SERVICE_START_FAILED_TRACKING', + OPEN_NOTIFICATION_SETTINGS: 'F.ANDROID.OPEN_NOTIFICATION_SETTINGS', + }, ATTACHMENT: { LOCAL_FILE_UNAVAILABLE: 'F.ATTACHMENT.LOCAL_FILE_UNAVAILABLE', DIALOG_EDIT: { diff --git a/src/assets/i18n/en.json b/src/assets/i18n/en.json index b544c256e4..bd3da14bb6 100644 --- a/src/assets/i18n/en.json +++ b/src/assets/i18n/en.json @@ -70,6 +70,11 @@ } }, "F": { + "ANDROID": { + "FOREGROUND_SERVICE_START_FAILED_FOCUS": "Android blocked the persistent focus mode notification. The timer continues in the app, but background alerts may be unavailable. Check notification permission and battery/background restrictions.", + "FOREGROUND_SERVICE_START_FAILED_TRACKING": "Android blocked the persistent time tracking notification. Time is still tracked in the app, but background recovery may be unavailable. Check notification permission and battery/background restrictions.", + "OPEN_NOTIFICATION_SETTINGS": "Open settings" + }, "ATTACHMENT": { "LOCAL_FILE_UNAVAILABLE": "This attachment is only available on the device where it was added.", "DIALOG_EDIT": {