diff --git a/.github/workflows/nodejs.yml b/.github/workflows/nodejs.yml index c8294e4a1..b1619a55c 100644 --- a/.github/workflows/nodejs.yml +++ b/.github/workflows/nodejs.yml @@ -177,20 +177,36 @@ jobs: with: name: screenshots-${{ matrix.aw-server }}-${{ matrix.aw-version }} path: screenshots/dist/* + # NOTE: the glob covers both cache roots. Since ActivityWatch/aw-server-rust#652 + # a --testing server logs under ~/.cache/activitywatch-testing/, while released + # builds still use ~/.cache/activitywatch/. These steps must never fail on absence. - name: Print server logs to console if: ${{ always() }} shell: bash - run: - for file in ~/.cache/activitywatch/log/*/*.log; do echo $file; cat $file; echo; done + run: | + shopt -s nullglob + logs=(~/.cache/activitywatch*/log/*/*.log) + if [ ${#logs[@]} -eq 0 ]; then + echo "No server logs found under ~/.cache/activitywatch*/log/" + exit 0 + fi + for file in "${logs[@]}"; do echo "$file"; cat "$file"; echo; done - name: Move logs to subdir # Run this step even if e2e tests flag failure if: ${{ always() }} + shell: bash env: aw_server: ${{ matrix.aw-server }} aw_version: ${{ matrix.aw-version }} run: | - mkdir -p logs/dist/$aw_server/$aw_version - mv ~/.cache/activitywatch/log/*/*.log logs/dist/$aw_server/$aw_version + shopt -s nullglob + logs=(~/.cache/activitywatch*/log/*/*.log) + if [ ${#logs[@]} -eq 0 ]; then + echo "No server logs to move" + exit 0 + fi + mkdir -p "logs/dist/$aw_server/$aw_version" + mv "${logs[@]}" "logs/dist/$aw_server/$aw_version" - name: Upload logs if: ${{ always() }} uses: actions/upload-artifact@v7 diff --git a/src/queries.ts b/src/queries.ts index 10b51e6d8..1f2f87a40 100644 --- a/src/queries.ts +++ b/src/queries.ts @@ -70,6 +70,7 @@ interface DesktopQueryParams extends BaseQueryParams { interface AndroidQueryParams extends BaseQueryParams { bid_android: string; + isIos?: boolean; } interface MultiQueryParams extends BaseQueryParams { @@ -154,8 +155,12 @@ export function canonicalEvents(params: DesktopQueryParams | AndroidQueryParams) return [ // Fetch window/app events `events = flood(${queryBucket(bid_window)});`, - // On Android, merge events to avoid overload of events - isAndroidParams(params) ? 'events = merge_events_by_keys(events, ["app", "title"]);' : '', + // Android watcher events have classname but no title; ScreenTime events have title. + isAndroidParams(params) + ? params.isIos + ? 'events = merge_events_by_keys(events, ["app", "title"]);' + : 'events = merge_events_by_keys(events, ["app", "classname"]);' + : '', // Fetch not-afk events isDesktopParams(params) ? `not_afk = flood(${queryBucket(params.bid_afk)}); @@ -225,19 +230,22 @@ const default_limit = 100; // Hardcoded limit per group export function appQuery( appbucket: string, categories: Category[], - filter_categories: string[][] + filter_categories: string[][], + isIos = false ): string[] { appbucket = escape_doublequote(appbucket); const params: AndroidQueryParams = { bid_android: appbucket, categories, filter_categories, + isIos, }; + const titleMergeKeys = isIos ? '["app", "title"]' : '["app", "classname"]'; const code = ` ${canonicalEvents(params)} - title_events = sort_by_duration(merge_events_by_keys(events, ["app", "classname", "title"])); + title_events = sort_by_duration(merge_events_by_keys(events, ${titleMergeKeys})); app_events = sort_by_duration(merge_events_by_keys(title_events, ["app"])); cat_events = sort_by_duration(merge_events_by_keys(events, ["$category"])); diff --git a/src/stores/activity.ts b/src/stores/activity.ts index c9816ae17..d9e708401 100644 --- a/src/stores/activity.ts +++ b/src/stores/activity.ts @@ -328,16 +328,16 @@ export const useActivityStore = defineStore('activity', { ); const selectedBucket = iosBucket || this.buckets.android[0]; + const isIos = !!iosBucket; const q = queries.appQuery( selectedBucket, categoryStore.classes_for_query, - filter_categories + filter_categories, + isIos ); const data = await getClient().query(periods, q).catch(this.errorHandler); // Post-process for iOS compatibility (swap app <-> title) - const isIos = !!iosBucket; - if (isIos && data && data[0] && data[0].title_events) { // Build bundle ID → human name lookup from title_events before modifying them. // title_events has 'app' = bundle ID and 'title' = human-readable name. @@ -551,9 +551,10 @@ export const useActivityStore = defineStore('activity', { } // Prefer ScreenTime bucket over Android watcher for consistency with query_android - const iosOrAndroidBucket = - this.buckets.android.find((id: string) => id.startsWith('aw-import-screentime')) || - this.buckets.android[0]; + const iosBucketForCategory = this.buckets.android.find((id: string) => + id.startsWith('aw-import-screentime') + ); + const iosOrAndroidBucket = iosBucketForCategory || this.buckets.android[0]; const isAndroid = iosOrAndroidBucket !== undefined; const categories = useCategoryStore().classes_for_query; // TODO: Clean up call, pass QueryParams in fullDesktopQuery as well @@ -571,6 +572,7 @@ export const useActivityStore = defineStore('activity', { ...(isAndroid ? { bid_android: iosOrAndroidBucket, + isIos: !!iosBucketForCategory, } : { bid_afk: this.buckets.afk[0], diff --git a/src/views/settings/CategoryBuilder.vue b/src/views/settings/CategoryBuilder.vue index 77d25a9b4..ac0a562da 100644 --- a/src/views/settings/CategoryBuilder.vue +++ b/src/views/settings/CategoryBuilder.vue @@ -261,7 +261,10 @@ export default { let bucketParams; if (!windowAvail && androidBuckets.length > 0) { const screentimeBucket = androidBuckets.find(id => id.startsWith('aw-import-screentime')); - bucketParams = { bid_android: screentimeBucket || androidBuckets[0] }; + bucketParams = { + bid_android: screentimeBucket || androidBuckets[0], + isIos: !!screentimeBucket, + }; } else { bucketParams = { bid_window: 'aw-watcher-window_' + hostname, diff --git a/test/unit/queries.test.node.ts b/test/unit/queries.test.node.ts index 9b15c23ef..4642c233f 100644 --- a/test/unit/queries.test.node.ts +++ b/test/unit/queries.test.node.ts @@ -55,7 +55,7 @@ * (Flatpak app ID retained: 'one.ablaze.floorp') */ -import { browser_appname_regex, querystr_to_array } from '~/queries'; +import { browser_appname_regex, querystr_to_array, canonicalEvents, appQuery } from '~/queries'; // Convert ActivityWatch (?i) patterns to JS RegExp with i flag for testing. // AW server uses Python-style (?i) inline flag; JS uses RegExp 'i' flag instead. @@ -277,3 +277,34 @@ describe('querystr_to_array', () => { expect(result).toHaveLength(2); }); }); + +// Regression guard: Android merge keys must not include "title" (Android events +// only carry app/package/classname — missing keys cause aw-transform to drop all +// events, producing "Time active: 0s" in the Activity view). See issue #959. +describe('Android merge keys (regression guard for issue #959)', () => { + const androidParams = { + bid_android: 'aw-watcher-android_test', + categories: [] as never[], + filter_categories: [] as never[], + }; + + test('canonicalEvents uses ["app", "classname"] merge keys for Android, not ["app", "title"]', () => { + const query = canonicalEvents(androidParams as Parameters[0]); + expect(query).toContain('merge_events_by_keys(events, ["app", "classname"])'); + expect(query).not.toContain('merge_events_by_keys(events, ["app", "title"])'); + }); + + test('appQuery does not include "title" in Android merge keys', () => { + const queryLines = appQuery('aw-watcher-android_test', [], []); + const fullQuery = queryLines.join('\n'); + expect(fullQuery).toContain('merge_events_by_keys(events, ["app", "classname"])'); + expect(fullQuery).not.toContain('merge_events_by_keys(events, ["app", "title"])'); + }); + + test('appQuery preserves ScreenTime events with iOS title merge keys', () => { + const queryLines = appQuery('aw-import-screentime_test', [], [], true); + const fullQuery = queryLines.join('\n'); + expect(fullQuery).toContain('merge_events_by_keys(events, ["app", "title"])'); + expect(fullQuery).not.toContain('merge_events_by_keys(events, ["app", "classname"])'); + }); +});