diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
index ef427bfc2..57507a69f 100644
--- a/android/app/src/main/AndroidManifest.xml
+++ b/android/app/src/main/AndroidManifest.xml
@@ -8,6 +8,7 @@
+
+
+
+
+
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 e3195bc6d..071edb742 100644
--- a/android/app/src/main/java/com/superproductivity/superproductivity/CapacitorMainActivity.kt
+++ b/android/app/src/main/java/com/superproductivity/superproductivity/CapacitorMainActivity.kt
@@ -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)
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
new file mode 100644
index 000000000..22e52eb0b
--- /dev/null
+++ b/android/app/src/main/java/com/superproductivity/superproductivity/service/TrackingForegroundService.kt
@@ -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()
+ }
+}
diff --git a/android/app/src/main/java/com/superproductivity/superproductivity/service/TrackingNotificationHelper.kt b/android/app/src/main/java/com/superproductivity/superproductivity/service/TrackingNotificationHelper.kt
new file mode 100644
index 000000000..bee0c8979
--- /dev/null
+++ b/android/app/src/main/java/com/superproductivity/superproductivity/service/TrackingNotificationHelper.kt
@@ -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)
+ }
+ }
+}
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 a7facef3b..4a9cf678e 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
@@ -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) { } }
diff --git a/src/app/features/android/android-interface.ts b/src/app/features/android/android-interface.ts
index 33584011b..2324d82ec 100644
--- a/src/app/features/android/android-interface.ts
+++ b/src/app/features/android/android-interface.ts
@@ -38,6 +38,11 @@ export interface AndroidInterface {
triggerGetShareData?(): void;
+ // Foreground service methods for background time tracking
+ startTrackingService?(taskId: string, taskTitle: string, timeSpentMs: number): void;
+ stopTrackingService?(): void;
+ getTrackingElapsed?(): string;
+
// added here only
onResume$: Subject;
onPause$: Subject;
@@ -50,9 +55,9 @@ export interface AndroidInterface {
path: string;
}>;
- // onPauseCurrentTask$: Subject;
- // onMarkCurrentTaskAsDone$: Subject;
- // onAddNewTask$: Subject;
+ // Notification action callbacks
+ onPauseTracking$: Subject;
+ onMarkTaskDone$: Subject;
}
// setInterval(() => {
@@ -68,9 +73,8 @@ if (IS_ANDROID_WEB_VIEW) {
androidInterface.onResume$ = new Subject();
androidInterface.onPause$ = new Subject();
- // androidInterface.onPauseCurrentTask$ = new Subject();
- // androidInterface.onMarkCurrentTaskAsDone$ = new Subject();
- // androidInterface.onAddNewTask$ = new Subject();
+ androidInterface.onPauseTracking$ = new Subject();
+ androidInterface.onMarkTaskDone$ = new Subject();
androidInterface.onShareWithAttachment$ = new ReplaySubject(1);
androidInterface.isKeyboardShown$ = new BehaviorSubject(false);
diff --git a/src/app/features/android/store/android-foreground-tracking.effects.ts b/src/app/features/android/store/android-foreground-tracking.effects.ts
new file mode 100644
index 000000000..2275b8e8a
--- /dev/null
+++ b/src/app/features/android/store/android-foreground-tracking.effects.ts
@@ -0,0 +1,177 @@
+import { inject, Injectable } from '@angular/core';
+import { createEffect } from '@ngrx/effects';
+import { Store } from '@ngrx/store';
+import {
+ distinctUntilChanged,
+ filter,
+ pairwise,
+ startWith,
+ tap,
+ withLatestFrom,
+} from 'rxjs/operators';
+import { IS_ANDROID_WEB_VIEW } from '../../../util/is-android-web-view';
+import { androidInterface } from '../android-interface';
+import { TaskService } from '../../tasks/task.service';
+import { selectCurrentTask } from '../../tasks/store/task.selectors';
+import { DroidLog } from '../../../core/log';
+import { DateService } from '../../../core/date/date.service';
+import { Task } from '../../tasks/task.model';
+
+@Injectable()
+export class AndroidForegroundTrackingEffects {
+ private _store = inject(Store);
+ private _taskService = inject(TaskService);
+ private _dateService = inject(DateService);
+
+ /**
+ * Start/stop the native foreground service when the current task changes.
+ * Also handles syncing time when switching tasks directly.
+ */
+ syncTrackingToService$ =
+ IS_ANDROID_WEB_VIEW &&
+ createEffect(
+ () =>
+ this._store.select(selectCurrentTask).pipe(
+ distinctUntilChanged((a, b) => a?.id === b?.id),
+ startWith(null as Task | null),
+ pairwise(),
+ tap(([prevTask, currentTask]) => {
+ // If switching from one task to another (or stopping), sync the previous task's time first
+ if (prevTask) {
+ this._syncElapsedTimeForTask(prevTask.id);
+ }
+
+ if (currentTask) {
+ DroidLog.log('Starting tracking service', {
+ taskId: currentTask.id,
+ title: currentTask.title,
+ timeSpent: currentTask.timeSpent,
+ });
+ androidInterface.startTrackingService?.(
+ currentTask.id,
+ currentTask.title,
+ currentTask.timeSpent || 0,
+ );
+ } else {
+ DroidLog.log('Stopping tracking service');
+ androidInterface.stopTrackingService?.();
+ }
+ }),
+ ),
+ { dispatch: false },
+ );
+
+ /**
+ * When the app resumes from background, sync the elapsed time from the native service.
+ */
+ syncOnResume$ =
+ IS_ANDROID_WEB_VIEW &&
+ createEffect(
+ () =>
+ androidInterface.onResume$.pipe(
+ withLatestFrom(this._store.select(selectCurrentTask)),
+ filter(([, currentTask]) => !!currentTask),
+ tap(([, currentTask]) => {
+ this._syncElapsedTimeForTask(currentTask!.id);
+ }),
+ ),
+ { dispatch: false },
+ );
+
+ /**
+ * Handle pause action from the notification.
+ */
+ handlePauseAction$ =
+ IS_ANDROID_WEB_VIEW &&
+ createEffect(
+ () =>
+ androidInterface.onPauseTracking$.pipe(
+ withLatestFrom(this._store.select(selectCurrentTask)),
+ filter(([, currentTask]) => !!currentTask),
+ tap(([, currentTask]) => {
+ DroidLog.log('Pause action from notification');
+ // Sync elapsed time first, then pause
+ this._syncElapsedTimeForTask(currentTask!.id);
+ this._taskService.pauseCurrent();
+ }),
+ ),
+ { dispatch: false },
+ );
+
+ /**
+ * Handle done action from the notification.
+ */
+ handleDoneAction$ =
+ IS_ANDROID_WEB_VIEW &&
+ createEffect(
+ () =>
+ androidInterface.onMarkTaskDone$.pipe(
+ withLatestFrom(this._store.select(selectCurrentTask)),
+ filter(([, currentTask]) => !!currentTask),
+ tap(([, currentTask]) => {
+ DroidLog.log('Done action from notification', { taskId: currentTask!.id });
+ // Sync elapsed time, mark as done, then pause
+ this._syncElapsedTimeForTask(currentTask!.id);
+ this._taskService.setDone(currentTask!.id);
+ this._taskService.pauseCurrent();
+ }),
+ ),
+ { dispatch: false },
+ );
+
+ /**
+ * Sync elapsed time from native service to the task.
+ * Only syncs if the native service is tracking the specified task.
+ */
+ private _syncElapsedTimeForTask(taskId: string): void {
+ const elapsedJson = androidInterface.getTrackingElapsed?.();
+ DroidLog.log('Syncing elapsed time for task', { taskId, elapsedJson });
+
+ if (!elapsedJson || elapsedJson === 'null') {
+ return;
+ }
+
+ try {
+ const nativeData = JSON.parse(elapsedJson) as {
+ taskId: string;
+ elapsedMs: number;
+ };
+
+ // Only sync if native is tracking the same task
+ if (nativeData.taskId !== taskId) {
+ DroidLog.log('Native tracking different task, skipping sync', {
+ nativeTaskId: nativeData.taskId,
+ expectedTaskId: taskId,
+ });
+ return;
+ }
+
+ // Get the task to find its current timeSpent
+ this._taskService
+ .getByIdOnce$(taskId)
+ .subscribe((task) => {
+ if (!task) {
+ DroidLog.log('Task not found for sync', { taskId });
+ return;
+ }
+
+ const currentTimeSpent = task.timeSpent || 0;
+ const duration = nativeData.elapsedMs - currentTimeSpent;
+
+ DroidLog.log('Calculated sync duration', {
+ taskId,
+ nativeElapsed: nativeData.elapsedMs,
+ currentTimeSpent,
+ duration,
+ });
+
+ if (duration > 0) {
+ this._taskService.addTimeSpent(task, duration, this._dateService.todayStr());
+ }
+ })
+ .unsubscribe();
+ } catch (e) {
+ DroidLog.err('Failed to parse elapsed time', e);
+ }
+ }
+}
diff --git a/src/app/root-store/feature-stores.module.ts b/src/app/root-store/feature-stores.module.ts
index f8b198712..e34f306b0 100644
--- a/src/app/root-store/feature-stores.module.ts
+++ b/src/app/root-store/feature-stores.module.ts
@@ -63,6 +63,7 @@ import { workContextReducer } from '../features/work-context/store/work-context.
import { WorkContextEffects } from '../features/work-context/store/work-context.effects';
import { IS_ANDROID_WEB_VIEW } from '../util/is-android-web-view';
import { AndroidEffects } from '../features/android/store/android.effects';
+import { AndroidForegroundTrackingEffects } from '../features/android/store/android-foreground-tracking.effects';
import { CaldavIssueEffects } from '../features/issue/providers/caldav/caldav-issue.effects';
import { CalendarIntegrationEffects } from '../features/calendar-integration/store/calendar-integration.effects';
import { ElectronEffects } from '../core/electron/electron.effects';
@@ -151,7 +152,9 @@ import { PluginHooksEffects } from '../plugins/plugin-hooks.effects';
EffectsModule.forFeature([PlannerEffects]),
// EFFECTS ONLY
- EffectsModule.forFeature([...(IS_ANDROID_WEB_VIEW ? [AndroidEffects] : [])]),
+ EffectsModule.forFeature([
+ ...(IS_ANDROID_WEB_VIEW ? [AndroidEffects, AndroidForegroundTrackingEffects] : []),
+ ]),
EffectsModule.forFeature([CaldavIssueEffects]),
EffectsModule.forFeature([CalendarIntegrationEffects]),
EffectsModule.forFeature([ElectronEffects]),