mirror of
https://github.com/johannesjo/super-productivity.git
synced 2026-01-23 02:36:05 +00:00
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:
parent
afa6bb85ea
commit
ffa7122aea
8 changed files with 518 additions and 7 deletions
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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) { } }
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue