diff --git a/modules/native-background-tasks/android/src/main/java/expo/modules/nativebackgroundtasks/TaskNotificationFactory.kt b/modules/native-background-tasks/android/src/main/java/expo/modules/nativebackgroundtasks/TaskNotificationFactory.kt index 8fdfb869..08882331 100644 --- a/modules/native-background-tasks/android/src/main/java/expo/modules/nativebackgroundtasks/TaskNotificationFactory.kt +++ b/modules/native-background-tasks/android/src/main/java/expo/modules/nativebackgroundtasks/TaskNotificationFactory.kt @@ -53,10 +53,17 @@ object TaskNotificationFactory { ) val icon = if (notificationIconId != 0) notificationIconId else android.R.drawable.ic_dialog_info + val progressLines = task.progressText + ?.lineSequence() + ?.filter { it.isNotBlank() } + ?.toList() + .orEmpty() + val contentText = progressLines.firstOrNull() ?: task.description + val builder = NotificationCompat.Builder(context, CHANNEL_ID) .setSmallIcon(icon) .setContentTitle(task.title) - .setContentText(task.progressText ?: task.description) + .setContentText(contentText) .setContentIntent(contentIntent) .setPriority(NotificationCompat.PRIORITY_LOW) .setCategory(NotificationCompat.CATEGORY_PROGRESS) @@ -77,6 +84,19 @@ object TaskNotificationFactory { } else { builder.setProgress(100, (progress.coerceIn(0.0, 1.0) * 100).toInt(), false) } + + if ( + task.type == "UPDATE_LIBRARY" && + task.state in listOf(BackgroundTaskState.QUEUED, BackgroundTaskState.RUNNING, BackgroundTaskState.PAUSED) && + progressLines.size > 1 + ) { + builder.setStyle( + NotificationCompat.InboxStyle().also { style -> + progressLines.forEach(style::addLine) + }, + ) + } + return builder.build() } @@ -104,4 +124,4 @@ object TaskNotificationFactory { }, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, ) -} \ No newline at end of file +} diff --git a/src/services/updates/__tests__/index.test.ts b/src/services/updates/__tests__/index.test.ts new file mode 100644 index 00000000..0e9bf7ab --- /dev/null +++ b/src/services/updates/__tests__/index.test.ts @@ -0,0 +1,136 @@ +import { + getLibraryNovelsForGlobalUpdate, + getLibraryWithCategory, +} from '@database/queries/LibraryQueries'; +import type { BackgroundTaskMetadata } from '@services/backgroundTasks/contracts'; +import { updateNovel } from '../LibraryUpdateQueries'; +import { updateLibrary } from '../index'; + +jest.mock('@database/queries/LibraryQueries', () => ({ + getLibraryNovelsForGlobalUpdate: jest.fn(), + getLibraryWithCategory: jest.fn(), +})); +jest.mock('../LibraryUpdateQueries', () => ({ + updateNovel: jest.fn(), +})); +jest.mock('@utils/sleep', () => ({ + sleep: jest.fn(() => Promise.resolve()), +})); +jest.mock('@utils/showToast', () => ({ + showToast: jest.fn(), +})); +jest.mock('@utils/mmkv/mmkv', () => ({ + MMKVStorage: { set: jest.fn() }, + getMMKVObject: jest.fn(() => ({})), +})); +jest.mock('@hooks/persisted/useUpdates', () => ({ + LAST_UPDATE_TIME: 'LAST_UPDATE_TIME', +})); +jest.mock('@hooks/persisted/useSettings', () => ({ + APP_SETTINGS: 'APP_SETTINGS', + getGlobalUpdateCategoryFilters: jest.fn(() => ({ + excludedCategoryIds: [], + includedCategoryIds: [], + })), +})); + +const mockedGetLibraryNovels = jest.mocked(getLibraryNovelsForGlobalUpdate); +const mockedGetLibraryWithCategory = jest.mocked(getLibraryWithCategory); +const mockedUpdateNovel = jest.mocked(updateNovel); + +type LibraryNovel = Awaited< + ReturnType +>[number]; + +const novel = (id: number, pluginId: string, name: string): LibraryNovel => + ({ + id, + pluginId, + name, + path: `/${name}`, + } as LibraryNovel); + +const flushPromises = () => new Promise(resolve => setImmediate(resolve)); + +describe('updateLibrary', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('updates at most three different sources while keeping each source sequential', async () => { + const novels = [ + novel(1, 'source-a', 'A One'), + novel(2, 'source-a', 'A Two'), + novel(3, 'source-b', 'B One'), + novel(4, 'source-c', 'C One'), + novel(5, 'source-d', 'D One'), + ]; + mockedGetLibraryNovels.mockResolvedValue(novels); + + const completions = new Map void>(); + mockedUpdateNovel.mockImplementation( + (_pluginId, _path, novelId) => + new Promise(resolve => completions.set(novelId, resolve)), + ); + + let metadata: BackgroundTaskMetadata = { + name: 'Update library', + isRunning: false, + progress: undefined, + progressText: undefined, + }; + const progressSnapshots: BackgroundTaskMetadata[] = []; + const setMeta = ( + transform: (meta: BackgroundTaskMetadata) => BackgroundTaskMetadata, + ) => { + metadata = transform(metadata); + progressSnapshots.push(metadata); + }; + + const updatePromise = updateLibrary({}, setMeta, jest.fn()); + await flushPromises(); + + expect(mockedUpdateNovel.mock.calls.map(([pluginId]) => pluginId)).toEqual([ + 'source-a', + 'source-b', + 'source-c', + ]); + expect(progressSnapshots.at(-1)?.progressText).toBe( + ['A One', 'B One', 'C One'].join('\n'), + ); + + completions.get(3)?.(); + await flushPromises(); + expect(mockedUpdateNovel).toHaveBeenCalledTimes(4); + expect(mockedUpdateNovel.mock.calls[3][0]).toBe('source-d'); + + completions.get(1)?.(); + await flushPromises(); + expect(mockedUpdateNovel).toHaveBeenCalledTimes(5); + expect(mockedUpdateNovel.mock.calls[4][0]).toBe('source-a'); + + completions.get(2)?.(); + completions.get(4)?.(); + completions.get(5)?.(); + await updatePromise; + + expect(metadata).toMatchObject({ + isRunning: false, + progress: 1, + progressText: undefined, + }); + }); + + it('uses the category query when an update is category-scoped', async () => { + mockedGetLibraryWithCategory.mockResolvedValue([]); + + await updateLibrary({ categoryId: 4 }, jest.fn(), jest.fn()); + + expect(mockedGetLibraryWithCategory).toHaveBeenCalledWith( + 4, + undefined, + true, + ); + expect(mockedGetLibraryNovels).not.toHaveBeenCalled(); + }); +}); diff --git a/src/services/updates/index.ts b/src/services/updates/index.ts index 88a71430..f5af65da 100644 --- a/src/services/updates/index.ts +++ b/src/services/updates/index.ts @@ -20,6 +20,23 @@ import type { TaskProgressUpdater, } from '@services/backgroundTasks/contracts'; +const UPDATE_SOURCE_CONCURRENCY = 3; + +const groupNovelsByPlugin = (novels: DBNovelInfo[]) => { + const groupedNovels = new Map(); + + for (const novel of novels) { + const pluginNovels = groupedNovels.get(novel.pluginId); + if (pluginNovels) { + pluginNovels.push(novel); + } else { + groupedNovels.set(novel.pluginId, [novel]); + } + } + + return [...groupedNovels.values()]; +}; + const updateLibrary = async ( { categoryId, @@ -59,26 +76,54 @@ const updateLibrary = async ( if (libraryNovels.length > 0) { MMKVStorage.set(LAST_UPDATE_TIME, dayjs().format('YYYY-MM-DD HH:mm:ss')); - for (let i = 0; i < libraryNovels.length; i++) { + + const sourceQueues = groupNovelsByPlugin(libraryNovels); + const activeNovels = new Map(); + let completedNovels = 0; + let nextSourceQueue = 0; + + const publishProgress = () => { setMeta(meta => ({ ...meta, - progressText: libraryNovels[i].name, - progress: i / libraryNovels.length, + progressText: [...activeNovels.values()].join('\n') || undefined, + progress: completedNovels / libraryNovels.length, })); + }; - try { - await updateNovel( - libraryNovels[i].pluginId, - libraryNovels[i].path, - libraryNovels[i].id, - options, - ); - await sleep(1000); - } catch (error: any) { - showToast(libraryNovels[i].name + ': ' + error.message); - continue; + const updateSourceQueue = async (sourceQueue: DBNovelInfo[]) => { + for (const novel of sourceQueue) { + activeNovels.set(novel.pluginId, novel.name); + publishProgress(); + + try { + await updateNovel(novel.pluginId, novel.path, novel.id, options); + await sleep(1000); + } catch (error: any) { + showToast(novel.name + ': ' + error.message); + } finally { + completedNovels += 1; + activeNovels.delete(novel.pluginId); + publishProgress(); + } } - } + }; + + const updateNextSource = async () => { + while (nextSourceQueue < sourceQueues.length) { + const sourceQueue = sourceQueues[nextSourceQueue]; + nextSourceQueue += 1; + await updateSourceQueue(sourceQueue); + } + }; + + await Promise.all( + Array.from( + { + length: Math.min(UPDATE_SOURCE_CONCURRENCY, sourceQueues.length), + }, + updateNextSource, + ), + ); } else { showToast("There's no novel to be updated"); } @@ -86,6 +131,7 @@ const updateLibrary = async ( setMeta(meta => ({ ...meta, progress: 1, + progressText: undefined, isRunning: false, })); };