fix(android): sync notification timer when time spent is manually changed

Add updateTrackingService method to reset the notification timer when
the user manually edits time spent on a task. Previously, the Android
foreground service maintained its own timer that was only set at
tracking start, causing desync when users manually reset time.

Fixes #5772
This commit is contained in:
Johannes Millan 2025-12-22 14:40:40 +01:00
parent 660adf76bc
commit 2c910f6753
5 changed files with 240 additions and 0 deletions

View file

@ -15,6 +15,7 @@ class TrackingForegroundService : Service() {
const val ACTION_START = "com.superproductivity.ACTION_START_TRACKING"
const val ACTION_STOP = "com.superproductivity.ACTION_STOP_TRACKING"
const val ACTION_UPDATE = "com.superproductivity.ACTION_UPDATE_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"
@ -79,6 +80,11 @@ class TrackingForegroundService : Service() {
startTracking(taskId, title, timeSpentMs)
}
ACTION_UPDATE -> {
val timeSpentMs = intent.getLongExtra(EXTRA_TIME_SPENT, accumulatedMs)
updateTimeSpent(timeSpentMs)
}
ACTION_STOP -> {
stopTracking()
}
@ -115,6 +121,21 @@ class TrackingForegroundService : Service() {
handler.post(updateRunnable)
}
private fun updateTimeSpent(timeSpentMs: Long) {
if (!isTracking) {
Log.d(TAG, "Ignoring updateTimeSpent: not tracking")
return
}
Log.d(TAG, "Updating time spent: timeSpentMs=$timeSpentMs (was accumulated=$accumulatedMs)")
// Reset the timer with the new accumulated value
accumulatedMs = timeSpentMs
startTimestamp = System.currentTimeMillis()
// Update notification immediately
updateNotification()
}
private fun stopTracking() {
Log.d(TAG, "Stopping tracking, elapsed=${getElapsedMs()}ms")

View file

@ -97,6 +97,16 @@ class JavaScriptInterface(
activity.startService(intent)
}
@Suppress("unused")
@JavascriptInterface
fun updateTrackingService(timeSpentMs: Long) {
val intent = Intent(activity, TrackingForegroundService::class.java).apply {
action = TrackingForegroundService.ACTION_UPDATE
putExtra(TrackingForegroundService.EXTRA_TIME_SPENT, timeSpentMs)
}
activity.startService(intent)
}
@Suppress("unused")
@JavascriptInterface
fun getTrackingElapsed(): String {

View file

@ -41,6 +41,7 @@ export interface AndroidInterface {
// Foreground service methods for background time tracking
startTrackingService?(taskId: string, taskTitle: string, timeSpentMs: number): void;
stopTrackingService?(): void;
updateTrackingService?(timeSpentMs: number): void;
getTrackingElapsed?(): string;
// Foreground service methods for focus mode timer

View file

@ -0,0 +1,159 @@
import { TestBed, fakeAsync } from '@angular/core/testing';
import { provideMockStore, MockStore } from '@ngrx/store/testing';
import { BehaviorSubject } from 'rxjs';
import { TaskService } from '../../tasks/task.service';
import { DateService } from '../../../core/date/date.service';
import { Task } from '../../tasks/task.model';
// We need to test the effect logic by reimplementing it in tests since
// the actual effects are conditionally created based on IS_ANDROID_WEB_VIEW
describe('AndroidForegroundTrackingEffects - syncTimeSpentChanges logic', () => {
let store: MockStore;
let currentTask$: BehaviorSubject<Task | null>;
let updateTrackingServiceSpy: jasmine.Spy;
beforeEach(() => {
currentTask$ = new BehaviorSubject<Task | null>(null);
updateTrackingServiceSpy = jasmine.createSpy('updateTrackingService');
TestBed.configureTestingModule({
providers: [
provideMockStore(),
{ provide: TaskService, useValue: { getByIdOnce$: () => currentTask$ } },
{ provide: DateService, useValue: { todayStr: () => '2024-01-01' } },
],
});
store = TestBed.inject(MockStore);
});
afterEach(() => {
store.resetSelectors();
});
/**
* Test the core logic: when timeSpent changes for the same task while tracking,
* the updateTrackingService should be called with the new value.
*/
describe('timeSpent change detection logic', () => {
it('should call updateTrackingService when timeSpent changes for the same task', fakeAsync(() => {
// Simulate the effect logic
const prevState = { taskId: 'task-1', timeSpent: 60000, isFocusModeActive: false };
const currState = { taskId: 'task-1', timeSpent: 0, isFocusModeActive: false };
const shouldUpdate =
prevState.taskId === currState.taskId &&
currState.taskId !== null &&
!currState.isFocusModeActive &&
prevState.timeSpent !== currState.timeSpent;
expect(shouldUpdate).toBeTrue();
// In real code, this triggers: androidInterface.updateTrackingService?.(curr.timeSpent);
if (shouldUpdate) {
updateTrackingServiceSpy(currState.timeSpent);
}
expect(updateTrackingServiceSpy).toHaveBeenCalledWith(0);
}));
it('should NOT call updateTrackingService when switching to a different task', fakeAsync(() => {
const prevState = { taskId: 'task-1', timeSpent: 60000, isFocusModeActive: false };
const currState = { taskId: 'task-2', timeSpent: 30000, isFocusModeActive: false };
const shouldUpdate =
prevState.taskId === currState.taskId &&
currState.taskId !== null &&
!currState.isFocusModeActive &&
prevState.timeSpent !== currState.timeSpent;
expect(shouldUpdate).toBeFalse();
}));
it('should NOT call updateTrackingService when focus mode is active', fakeAsync(() => {
const prevState = { taskId: 'task-1', timeSpent: 60000, isFocusModeActive: false };
const currState = { taskId: 'task-1', timeSpent: 0, isFocusModeActive: true };
const shouldUpdate =
prevState.taskId === currState.taskId &&
currState.taskId !== null &&
!currState.isFocusModeActive &&
prevState.timeSpent !== currState.timeSpent;
expect(shouldUpdate).toBeFalse();
}));
it('should NOT call updateTrackingService when no task is being tracked', fakeAsync(() => {
const prevState = { taskId: null, timeSpent: 0, isFocusModeActive: false };
const currState = { taskId: null, timeSpent: 0, isFocusModeActive: false };
const shouldUpdate =
prevState.taskId === currState.taskId &&
currState.taskId !== null &&
!currState.isFocusModeActive &&
prevState.timeSpent !== currState.timeSpent;
expect(shouldUpdate).toBeFalse();
}));
it('should NOT call updateTrackingService when timeSpent did not change', fakeAsync(() => {
const prevState = { taskId: 'task-1', timeSpent: 60000, isFocusModeActive: false };
const currState = { taskId: 'task-1', timeSpent: 60000, isFocusModeActive: false };
const shouldUpdate =
prevState.taskId === currState.taskId &&
currState.taskId !== null &&
!currState.isFocusModeActive &&
prevState.timeSpent !== currState.timeSpent;
expect(shouldUpdate).toBeFalse();
}));
it('should call updateTrackingService when timeSpent is increased', fakeAsync(() => {
const prevState = { taskId: 'task-1', timeSpent: 60000, isFocusModeActive: false };
const currState = { taskId: 'task-1', timeSpent: 120000, isFocusModeActive: false };
const shouldUpdate =
prevState.taskId === currState.taskId &&
currState.taskId !== null &&
!currState.isFocusModeActive &&
prevState.timeSpent !== currState.timeSpent;
expect(shouldUpdate).toBeTrue();
if (shouldUpdate) {
updateTrackingServiceSpy(currState.timeSpent);
}
expect(updateTrackingServiceSpy).toHaveBeenCalledWith(120000);
}));
});
describe('distinctUntilChanged behavior', () => {
it('should detect changes when only timeSpent differs', () => {
const stateA = { taskId: 'task-1', timeSpent: 60000, isFocusModeActive: false };
const stateB = { taskId: 'task-1', timeSpent: 0, isFocusModeActive: false };
// The distinctUntilChanged comparator
const isEqual =
stateA.taskId === stateB.taskId &&
stateA.timeSpent === stateB.timeSpent &&
stateA.isFocusModeActive === stateB.isFocusModeActive;
expect(isEqual).toBeFalse(); // Should NOT be equal, so effect should fire
});
it('should NOT detect changes when state is identical', () => {
const stateA = { taskId: 'task-1', timeSpent: 60000, isFocusModeActive: false };
const stateB = { taskId: 'task-1', timeSpent: 60000, isFocusModeActive: false };
const isEqual =
stateA.taskId === stateB.taskId &&
stateA.timeSpent === stateB.timeSpent &&
stateA.isFocusModeActive === stateB.isFocusModeActive;
expect(isEqual).toBeTrue(); // Should be equal, so effect should NOT fire
});
});
});

View file

@ -110,6 +110,55 @@ export class AndroidForegroundTrackingEffects {
{ dispatch: false },
);
/**
* Update the native service when timeSpent changes for the current task.
* This handles the case where the user manually edits the time spent.
*/
syncTimeSpentChanges$ =
IS_ANDROID_WEB_VIEW &&
createEffect(
() =>
combineLatest([
this._store.select(selectCurrentTask),
this._store.select(selectTimer),
]).pipe(
map(([currentTask, timer]) => ({
taskId: currentTask?.id || null,
timeSpent: currentTask?.timeSpent || 0,
isFocusModeActive: timer.purpose !== null,
})),
// Only react when timeSpent changes for the same task
distinctUntilChanged(
(a, b) =>
a.taskId === b.taskId &&
a.timeSpent === b.timeSpent &&
a.isFocusModeActive === b.isFocusModeActive,
),
pairwise(),
filter(([prev, curr]) => {
// Only update if:
// 1. Same task (not switching tasks - that's handled by syncTrackingToService$)
// 2. Task exists
// 3. Focus mode is not active (notification is hidden during focus mode)
// 4. timeSpent actually changed
return (
prev.taskId === curr.taskId &&
curr.taskId !== null &&
!curr.isFocusModeActive &&
prev.timeSpent !== curr.timeSpent
);
}),
tap(([, curr]) => {
DroidLog.log('Time spent changed for current task, updating service', {
taskId: curr.taskId,
timeSpent: curr.timeSpent,
});
androidInterface.updateTrackingService?.(curr.timeSpent);
}),
),
{ dispatch: false },
);
/**
* Handle pause action from the notification.
*/