mirror of
https://github.com/johannesjo/super-productivity.git
synced 2026-01-23 02:36:05 +00:00
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:
parent
660adf76bc
commit
2c910f6753
5 changed files with 240 additions and 0 deletions
|
|
@ -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")
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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.
|
||||
*/
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue