Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 20 additions & 4 deletions .github/workflows/nodejs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
16 changes: 12 additions & 4 deletions src/queries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ interface DesktopQueryParams extends BaseQueryParams {

interface AndroidQueryParams extends BaseQueryParams {
bid_android: string;
isIos?: boolean;
}

interface MultiQueryParams extends BaseQueryParams {
Expand Down Expand Up @@ -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)});
Expand Down Expand Up @@ -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"]));

Expand Down
14 changes: 8 additions & 6 deletions src/stores/activity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand All @@ -571,6 +572,7 @@ export const useActivityStore = defineStore('activity', {
...(isAndroid
? {
bid_android: iosOrAndroidBucket,
isIos: !!iosBucketForCategory,
}
: {
bid_afk: this.buckets.afk[0],
Expand Down
5 changes: 4 additions & 1 deletion src/views/settings/CategoryBuilder.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
33 changes: 32 additions & 1 deletion test/unit/queries.test.node.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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<typeof canonicalEvents>[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"])');
});
});
Loading