mirror of
https://github.com/johannesjo/super-productivity.git
synced 2026-07-17 16:37:43 +00:00
fix(android): handle foreground service failures
This commit is contained in:
parent
bca1dba174
commit
348dc47e3e
14 changed files with 343 additions and 86 deletions
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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) { } }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 &&
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue