fix(android): handle foreground service failures

This commit is contained in:
Johannes Millan 2026-05-11 14:10:51 +02:00
parent bca1dba174
commit 348dc47e3e
14 changed files with 343 additions and 86 deletions

View file

@ -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')

View file

@ -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 {

View file

@ -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()
}

View file

@ -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
}
}

View file

@ -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
}
}

View file

@ -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)
}
}

View file

@ -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
}
}

View file

@ -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) { } }
}

View file

@ -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')

View file

@ -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)

View file

@ -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>; // boolean indicates isBreak
onForegroundServiceStartFailed$: ReplaySubject<ForegroundServiceStartFailure>;
// Reminder notification action callbacks
onReminderTap$: ReplaySubject<string>; // 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);

View file

@ -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 &&

View file

@ -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: {

View file

@ -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": {