feat(android): add background time tracking via foreground service

When a task is being tracked and the app goes to the background on Android,
JavaScript execution is paused/throttled, causing time tracking to stop.

This adds a native Foreground Service that:
- Shows a persistent notification with task title and elapsed time
- Tracks time independently using timestamps (not JS ticks)
- Provides Pause and Done action buttons on the notification
- Syncs elapsed time back to Angular when the app resumes or task changes

Architecture:
- Native service stores startTimestamp + accumulatedMs
- Calculates elapsed = (now - startTimestamp) + accumulated
- Angular effects sync time on resume, task switch, and notification actions
This commit is contained in:
Johannes Millan 2025-12-19 13:38:52 +01:00
parent afa6bb85ea
commit ffa7122aea
8 changed files with 518 additions and 7 deletions

View file

@ -8,6 +8,7 @@
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" />
<uses-permission android:name="android.permission.SCHEDULE_EXACT_ALARM" />
<application
@ -76,5 +77,15 @@
android:name=".webview.WebViewBlockActivity"
android:exported="false"
android:theme="@style/AppTheme" />
<service
android:name=".service.TrackingForegroundService"
android:enabled="true"
android:exported="false"
android:foregroundServiceType="specialUse">
<property
android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE"
android:value="time_tracking" />
</service>
</application>
</manifest>

View file

@ -13,6 +13,7 @@ import androidx.activity.addCallback
import com.anggrayudi.storage.SimpleStorageHelper
import com.getcapacitor.BridgeActivity
import com.superproductivity.superproductivity.plugins.SafBridgePlugin
import com.superproductivity.superproductivity.service.TrackingForegroundService
import com.superproductivity.superproductivity.util.printWebViewVersion
import com.superproductivity.superproductivity.webview.JavaScriptInterface
import com.superproductivity.superproductivity.webview.WebHelper
@ -144,6 +145,22 @@ class CapacitorMainActivity : BridgeActivity() {
private fun handleIntent(intent: Intent) {
Log.d("SP_SHARE", "handleIntent action: ${intent.action} type: ${intent.type}")
// Handle tracking notification actions
when (intent.action) {
TrackingForegroundService.ACTION_PAUSE -> {
Log.d("SP_TRACKING", "Pause action received from notification")
callJSInterfaceFunctionIfExists("next", "onPauseTracking$")
return
}
TrackingForegroundService.ACTION_DONE -> {
Log.d("SP_TRACKING", "Done action received from notification")
callJSInterfaceFunctionIfExists("next", "onMarkTaskDone$")
return
}
}
// Handle share intent
if (Intent.ACTION_SEND == intent.action && intent.type != null) {
if (intent.type?.startsWith("text/") == true) {
val sharedText = intent.getStringExtra(Intent.EXTRA_TEXT)

View file

@ -0,0 +1,165 @@
package com.superproductivity.superproductivity.service
import android.app.Service
import android.content.Intent
import android.os.Handler
import android.os.IBinder
import android.os.Looper
import android.util.Log
import androidx.core.app.NotificationManagerCompat
class TrackingForegroundService : Service() {
companion object {
const val TAG = "TrackingService"
const val ACTION_START = "com.superproductivity.ACTION_START_TRACKING"
const val ACTION_STOP = "com.superproductivity.ACTION_STOP_TRACKING"
const val ACTION_PAUSE = "com.superproductivity.ACTION_PAUSE_TRACKING"
const val ACTION_DONE = "com.superproductivity.ACTION_MARK_DONE"
const val ACTION_GET_ELAPSED = "com.superproductivity.ACTION_GET_ELAPSED"
const val EXTRA_TASK_ID = "task_id"
const val EXTRA_TASK_TITLE = "task_title"
const val EXTRA_TIME_SPENT = "time_spent_ms"
// Static state accessible from JavaScriptInterface
@Volatile
var currentTaskId: String? = null
private set
@Volatile
var startTimestamp: Long = 0
private set
@Volatile
var accumulatedMs: Long = 0
private set
@Volatile
var isTracking: Boolean = false
private set
fun getElapsedMs(): Long {
return if (isTracking && startTimestamp > 0) {
(System.currentTimeMillis() - startTimestamp) + accumulatedMs
} else {
accumulatedMs
}
}
}
private var taskTitle: String = ""
private val handler = Handler(Looper.getMainLooper())
private val updateRunnable = object : Runnable {
override fun run() {
if (isTracking) {
updateNotification()
handler.postDelayed(this, 1000)
}
}
}
override fun onCreate() {
super.onCreate()
Log.d(TAG, "Service created")
TrackingNotificationHelper.createChannel(this)
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
Log.d(TAG, "onStartCommand: action=${intent?.action}")
when (intent?.action) {
ACTION_START -> {
val taskId = intent.getStringExtra(EXTRA_TASK_ID) ?: return START_NOT_STICKY
val title = intent.getStringExtra(EXTRA_TASK_TITLE) ?: "Task"
val timeSpentMs = intent.getLongExtra(EXTRA_TIME_SPENT, 0L)
startTracking(taskId, title, timeSpentMs)
}
ACTION_STOP -> {
stopTracking()
}
else -> {
// Service restarted by system - we have no state to restore
Log.d(TAG, "Service started without action, stopping")
stopSelf()
}
}
return START_NOT_STICKY
}
private fun startTracking(taskId: String, title: String, timeSpentMs: Long) {
Log.d(TAG, "Starting tracking: taskId=$taskId, title=$title, timeSpentMs=$timeSpentMs")
currentTaskId = taskId
taskTitle = title
accumulatedMs = timeSpentMs
startTimestamp = System.currentTimeMillis()
isTracking = true
// Start foreground immediately to avoid ANR
val notification = TrackingNotificationHelper.buildNotification(
this,
taskTitle,
getElapsedMs()
)
startForeground(TrackingNotificationHelper.NOTIFICATION_ID, notification)
// Start update loop
handler.removeCallbacks(updateRunnable)
handler.post(updateRunnable)
}
private fun stopTracking() {
Log.d(TAG, "Stopping tracking, elapsed=${getElapsedMs()}ms")
isTracking = false
handler.removeCallbacks(updateRunnable)
// Reset state
currentTaskId = null
startTimestamp = 0
accumulatedMs = 0
stopForeground(STOP_FOREGROUND_REMOVE)
stopSelf()
}
private fun updateNotification() {
if (!isTracking) return
try {
val notification = TrackingNotificationHelper.buildNotification(
this,
taskTitle,
getElapsedMs()
)
NotificationManagerCompat.from(this).notify(
TrackingNotificationHelper.NOTIFICATION_ID,
notification
)
} catch (e: SecurityException) {
Log.w(TAG, "No permission to post notification", e)
}
}
override fun onBind(intent: Intent?): IBinder? = null
override fun onDestroy() {
super.onDestroy()
Log.d(TAG, "Service destroyed")
isTracking = false
handler.removeCallbacks(updateRunnable)
}
override fun onTaskRemoved(rootIntent: Intent?) {
super.onTaskRemoved(rootIntent)
Log.d(TAG, "Task removed, stopping service")
stopTracking()
}
}

View file

@ -0,0 +1,97 @@
package com.superproductivity.superproductivity.service
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.os.Build
import androidx.core.app.NotificationCompat
import com.superproductivity.superproductivity.CapacitorMainActivity
import com.superproductivity.superproductivity.R
object TrackingNotificationHelper {
const val CHANNEL_ID = "sp_time_tracking_channel"
const val NOTIFICATION_ID = 1001
fun createChannel(context: Context) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val channel = NotificationChannel(
CHANNEL_ID,
"Time Tracking",
NotificationManager.IMPORTANCE_LOW
).apply {
description = "Shows active time tracking status"
setShowBadge(false)
}
val notificationManager = context.getSystemService(NotificationManager::class.java)
notificationManager.createNotificationChannel(channel)
}
}
fun buildNotification(
context: Context,
taskTitle: String,
elapsedMs: Long
): Notification {
val contentIntent = Intent(context, CapacitorMainActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP
}
val contentPendingIntent = PendingIntent.getActivity(
context,
0,
contentIntent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
val pauseIntent = Intent(context, CapacitorMainActivity::class.java).apply {
action = TrackingForegroundService.ACTION_PAUSE
flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP
}
val pausePendingIntent = PendingIntent.getActivity(
context,
1,
pauseIntent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
val doneIntent = Intent(context, CapacitorMainActivity::class.java).apply {
action = TrackingForegroundService.ACTION_DONE
flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP
}
val donePendingIntent = PendingIntent.getActivity(
context,
2,
doneIntent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
return NotificationCompat.Builder(context, CHANNEL_ID)
.setSmallIcon(R.drawable.ic_stat_sp)
.setContentTitle(taskTitle)
.setContentText(formatDuration(elapsedMs))
.setContentIntent(contentPendingIntent)
.setOngoing(true)
.setOnlyAlertOnce(true)
.setSilent(true)
.addAction(0, "Pause", pausePendingIntent)
.addAction(0, "Done", donePendingIntent)
.setCategory(NotificationCompat.CATEGORY_PROGRESS)
.setPriority(NotificationCompat.PRIORITY_LOW)
.build()
}
fun formatDuration(ms: Long): String {
val totalSeconds = ms / 1000
val hours = totalSeconds / 3600
val minutes = (totalSeconds % 3600) / 60
val seconds = totalSeconds % 60
return when {
hours > 0 -> String.format("%dh %dm %ds", hours, minutes, seconds)
minutes > 0 -> String.format("%dm %ds", minutes, seconds)
else -> String.format("%ds", seconds)
}
}
}

View file

@ -1,13 +1,16 @@
package com.superproductivity.superproductivity.webview
import android.app.Activity
import android.content.Intent
import android.webkit.JavascriptInterface
import android.webkit.WebView
import android.widget.Toast
import androidx.core.content.ContextCompat
import com.superproductivity.superproductivity.App
import com.superproductivity.superproductivity.BuildConfig
import com.superproductivity.superproductivity.FullscreenActivity.Companion.WINDOW_INTERFACE_PROPERTY
import com.superproductivity.superproductivity.app.LaunchDecider
import com.superproductivity.superproductivity.service.TrackingForegroundService
class JavaScriptInterface(
@ -71,6 +74,40 @@ class JavaScriptInterface(
}
}
@Suppress("unused")
@JavascriptInterface
fun startTrackingService(taskId: String, taskTitle: String, timeSpentMs: Long) {
val intent = Intent(activity, TrackingForegroundService::class.java).apply {
action = TrackingForegroundService.ACTION_START
putExtra(TrackingForegroundService.EXTRA_TASK_ID, taskId)
putExtra(TrackingForegroundService.EXTRA_TASK_TITLE, taskTitle)
putExtra(TrackingForegroundService.EXTRA_TIME_SPENT, timeSpentMs)
}
ContextCompat.startForegroundService(activity, intent)
}
@Suppress("unused")
@JavascriptInterface
fun stopTrackingService() {
val intent = Intent(activity, TrackingForegroundService::class.java).apply {
action = TrackingForegroundService.ACTION_STOP
}
activity.startService(intent)
}
@Suppress("unused")
@JavascriptInterface
fun getTrackingElapsed(): String {
val taskId = TrackingForegroundService.currentTaskId
val elapsedMs = TrackingForegroundService.getElapsedMs()
val isTracking = TrackingForegroundService.isTracking
return if (isTracking && taskId != null) {
"""{"taskId":"$taskId","elapsedMs":$elapsedMs}"""
} else {
"null"
}
}
fun callJavaScriptFunction(script: String) {
webView.post { webView.evaluateJavascript(script) { } }