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]),