From 17e22a8c1146d76ed3710634d863b93356714272 Mon Sep 17 00:00:00 2001 From: Manton Reece Date: Wed, 9 Sep 2026 11:03:00 -0500 Subject: [PATCH 1/2] Better handling of problems when converting old episodes. --- src/App.js | 22 ++- src/__tests__/App.test.js | 23 +++- src/stores/Episodes.js | 108 ++++++++++++--- src/stores/__tests__/Episodes.test.js | 187 +++++++++++++++++++++----- 4 files changed, 285 insertions(+), 55 deletions(-) diff --git a/src/App.js b/src/App.js index 1b50852..29a9ff1 100644 --- a/src/App.js +++ b/src/App.js @@ -3,6 +3,7 @@ import { ActivityIndicator, Linking, Modal, + Pressable, StyleSheet, Text, useColorScheme, @@ -29,7 +30,7 @@ function LegacyUpgradeModal({ theme, visible = false }) { return ( {}} + onRequestClose={() => Episodes.continue_without_legacy_upgrade()} presentationStyle="overFullScreen" statusBarTranslucent transparent @@ -51,6 +52,15 @@ function LegacyUpgradeModal({ theme, visible = false }) { Upgrading previous Wavelength recordings... + Episodes.continue_without_legacy_upgrade()} + style={({ pressed }) => [styles.continueButton, { opacity: pressed ? 0.68 : 1 }]} + > + + Continue + + @@ -120,6 +130,16 @@ function App() { } const styles = StyleSheet.create({ + continueButton: { + alignItems: 'center', + justifyContent: 'center', + minHeight: 44, + paddingHorizontal: 20, + }, + continueButtonText: { + fontSize: 17, + fontWeight: '600', + }, loadingScreen: { flex: 1, alignItems: 'center', diff --git a/src/__tests__/App.test.js b/src/__tests__/App.test.js index eba4b91..185a217 100644 --- a/src/__tests__/App.test.js +++ b/src/__tests__/App.test.js @@ -13,8 +13,10 @@ jest.mock('react-native', () => ({ getInitialURL: jest.fn(async () => null), }, Modal: 'Modal', + Pressable: 'Pressable', StyleSheet: { create: styles => styles, + flatten: style => Object.assign({}, ...[style].flat().filter(Boolean)), }, Text: 'Text', View: 'View', @@ -61,6 +63,7 @@ jest.mock('../theme/wavelengthTheme', () => ({ is_dark: false, colors: { accent: '#ff8800', + accent_strong: '#cc6600', canvas: '#fffaf0', ink: '#24180d', line: '#eee', @@ -70,6 +73,7 @@ jest.mock('../theme/wavelengthTheme', () => ({ })); jest.mock('../stores/Episodes', () => ({ + continue_without_legacy_upgrade: jest.fn(), is_upgrading_legacy: false, })); @@ -80,14 +84,15 @@ jest.mock('../stores/Auth', () => ({ handle_open_url: (...args) => mock_handle_open_url(...args), hydrate: jest.fn(async () => {}), is_hydrating: false, - is_signed_in: () => false, + is_signed_in: jest.fn(() => false), is_signing_in: true, })); const React = require('react'); -const { render } = require('@testing-library/react-native'); +const { fireEvent, render } = require('@testing-library/react-native'); const App = require('../App').default; const Auth = require('../stores/Auth'); +const Episodes = require('../stores/Episodes'); describe('App auth callback URLs', () => { beforeEach(async () => { @@ -96,6 +101,7 @@ describe('App auth callback URLs', () => { Auth.can_handle_open_url.mockReset(); Auth.can_handle_open_url.mockReturnValue(true); Auth.is_signing_in = true; + Auth.is_signed_in.mockReturnValue(false); await render(React.createElement(App)); }); @@ -109,3 +115,16 @@ describe('App auth callback URLs', () => { expect(mock_handle_open_url).toHaveBeenCalledWith(callback_url); }); }); + +describe('App legacy upgrade', () => { + test('Continue delegates to the store to stop waiting for the upgrade', async () => { + Auth.is_signed_in.mockReturnValue(true); + Episodes.is_upgrading_legacy = true; + + const { getByText } = await render(React.createElement(App)); + + await fireEvent.press(getByText('Continue')); + + expect(Episodes.continue_without_legacy_upgrade).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/stores/Episodes.js b/src/stores/Episodes.js index e4f92ab..121722c 100644 --- a/src/stores/Episodes.js +++ b/src/stores/Episodes.js @@ -34,12 +34,18 @@ import Auth from './Auth'; import Posts from './Posts'; import Tokens from './Tokens'; -async function upgrade_legacy_episode(legacy_episode) { +const LEGACY_UPGRADE_TIMEOUT_MILLIS = 30_000; + +async function upgrade_legacy_episode(legacy_episode, upgrade) { const existing = await read_migrated_episode( legacy_episode.id, legacy_episode.clips.length, ); + if (upgrade.is_cancelled) { + return null; + } + if (existing) { await delete_legacy_episode(legacy_episode.id); return existing; @@ -51,9 +57,19 @@ async function upgrade_legacy_episode(legacy_episode) { for (const clip of legacy_episode.clips) { const converted = await normalize_imported_audio(clip.uri); converted_clips.push(converted); + + // Native conversion can finish after we have stopped waiting for it. + if (upgrade.is_cancelled) { + return null; + } } const migrated = await save_migrated_episode(legacy_episode, converted_clips); + + if (upgrade.is_cancelled) { + return null; + } + await delete_legacy_episode(legacy_episode.id); return migrated; @@ -136,6 +152,7 @@ const Episodes = types export_fingerprints: {}, is_loading: false, is_upgrading_legacy: false, + legacy_upgrade: null, })) .actions(self => ({ apply_episode_snapshot(snapshot) { @@ -149,42 +166,91 @@ const Episodes = types }, refresh: flow(function* () { + if (self.is_loading) { + return; + } + self.is_loading = true; try { - if (!self.did_check_for_legacy) { - self.did_check_for_legacy = true; + try { + const loaded_episodes = yield list_episodes(); + applySnapshot(self.episodes, loaded_episodes); + } catch (error) { + console.warn('Could not load recordings:', error); + } - const legacy_episodes = yield list_legacy_episodes(); + self.did_hydrate = true; + yield self.upgrade_legacy_recordings(); + } finally { + self.is_loading = false; + } + }), + + upgrade_legacy_recordings: flow(function* () { + if (self.did_check_for_legacy) { + return; + } - if (legacy_episodes.length > 0) { - self.is_upgrading_legacy = true; + self.did_check_for_legacy = true; + const upgrade = { is_cancelled: false, cancel: null }; + const cancelled = new Promise(resolve => { + upgrade.cancel = () => { + upgrade.is_cancelled = true; + resolve(); + }; + }); + self.legacy_upgrade = upgrade; + + const timeout = setTimeout(() => { + console.warn('Legacy recording upgrade timed out.'); + self.continue_without_legacy_upgrade(); + }, LEGACY_UPGRADE_TIMEOUT_MILLIS); + + try { + const legacy_episodes = yield Promise.race([list_legacy_episodes(), cancelled]); + + if (!upgrade.is_cancelled && legacy_episodes.length > 0) { + self.is_upgrading_legacy = true; + + for (const legacy_episode of legacy_episodes) { try { - for (const legacy_episode of legacy_episodes) { - try { - yield upgrade_legacy_episode(legacy_episode); - } catch (error) { - // Leave the old folder intact so a future launch can retry it. - } + const migrated = yield Promise.race([ + upgrade_legacy_episode(legacy_episode, upgrade), + cancelled, + ]); + + if (!upgrade.is_cancelled) { + self.apply_episode_snapshot(migrated); } - } finally { - self.is_upgrading_legacy = false; + } catch (error) { + // Leave the old folder intact so a future launch can retry it. + console.warn('Could not upgrade legacy recording:', legacy_episode.id, error); + } + + if (upgrade.is_cancelled) { + break; } } } - - const loaded_episodes = yield list_episodes(); - applySnapshot(self.episodes, loaded_episodes); - self.did_hydrate = true; } catch (error) { + console.warn('Could not upgrade legacy recordings:', error); + } finally { + clearTimeout(timeout); self.is_upgrading_legacy = false; - self.did_hydrate = true; + self.legacy_upgrade = null; } - - self.is_loading = false; }), + continue_without_legacy_upgrade() { + if (self.legacy_upgrade) { + self.legacy_upgrade.cancel(); + } + + self.is_upgrading_legacy = false; + }, + refresh_episode: flow(function* (episode_id = '') { const snapshot = yield read_episode(episode_id); diff --git a/src/stores/__tests__/Episodes.test.js b/src/stores/__tests__/Episodes.test.js index 64e868f..70cab15 100644 --- a/src/stores/__tests__/Episodes.test.js +++ b/src/stores/__tests__/Episodes.test.js @@ -73,7 +73,7 @@ jest.mock('../../lib/episode_audio', () => ({ normalize_imported_audio: jest.fn(), })); -const { applySnapshot } = require('mobx-state-tree'); +const { applySnapshot, getType } = require('mobx-state-tree'); const { delete_micropub_post } = require('../../api/Micropub'); const { append_clip_to_episode, @@ -155,28 +155,50 @@ describe('Episodes store', () => { }); describe('legacy migration', () => { + const legacy_episode = { + clips: [{ name: 'recording.caf', uri: 'file:///documents/recording.caf' }], + created_at: '2024-03-13T18:34:56.000Z', + id: '2024-03-13 12:34:56', + title: 'Legacy recording', + }; + const converted_clip = { + duration_seconds: 60, + uri: 'file:///tmp/recording.m4a', + waveform: [0.1, 0.5], + }; + const migrated_episode = { + ...EPISODE_BY_ID, + folder_uri: 'file:///episodes/legacy-2024-03-13-12-34-56/', + id: 'legacy-2024-03-13-12-34-56', + post_id: null, + post_url: null, + published_at: null, + title: 'Legacy recording', + }; + let legacy_store; + + beforeEach(() => { + legacy_store = getType(Episodes).create(); + jest.useFakeTimers({ doNotFake: ['queueMicrotask'] }); + jest.spyOn(console, 'warn').mockImplementation(() => {}); + list_episodes.mockResolvedValue([EPISODE_BY_ID]); + list_legacy_episodes.mockResolvedValue([legacy_episode]); + normalize_imported_audio.mockResolvedValue(converted_clip); + save_migrated_episode.mockResolvedValue(migrated_episode); + }); + + afterEach(() => { + jest.useRealTimers(); + jest.restoreAllMocks(); + }); + test('converts legacy clips before deleting the old episode folder', async () => { let finish_conversion; const conversion = new Promise(resolve => { finish_conversion = resolve; }); - const migrated_episode = { - ...EPISODE_BY_ID, - folder_uri: 'file:///episodes/legacy-2024-03-13-12-34-56/', - id: 'legacy-2024-03-13-12-34-56', - post_id: null, - post_url: null, - published_at: null, - title: 'Legacy recording', - }; - list_legacy_episodes.mockResolvedValueOnce([ - { - clips: [{ name: 'recording.caf', uri: 'file:///documents/recording.caf' }], - created_at: '2024-03-13T18:34:56.000Z', - id: '2024-03-13 12:34:56', - title: 'Legacy recording', - }, + legacy_episode, { clips: [{ name: 'missing.caf', uri: 'file:///documents/missing.caf' }], created_at: '2024-03-14T18:34:56.000Z', @@ -187,21 +209,15 @@ describe('Episodes store', () => { normalize_imported_audio .mockReturnValueOnce(conversion) .mockRejectedValueOnce(new Error('Conversion failed.')); - save_migrated_episode.mockResolvedValueOnce(migrated_episode); - list_episodes.mockResolvedValueOnce([migrated_episode]); - - const refresh = Episodes.refresh(); + const refresh = legacy_store.refresh(); - await Promise.resolve(); - await Promise.resolve(); + await jest.advanceTimersByTimeAsync(0); - expect(Episodes.is_upgrading_legacy).toBe(true); + expect(legacy_store.is_upgrading_legacy).toBe(true); + expect(legacy_store.did_hydrate).toBe(true); + expect(legacy_store.get_episode('episode-1')?.title).toBe('Morning microcast'); - finish_conversion({ - duration_seconds: 60, - uri: 'file:///tmp/recording.m4a', - waveform: [0.1, 0.5], - }); + finish_conversion(converted_clip); await refresh; expect(read_migrated_episode).toHaveBeenCalledWith( @@ -221,8 +237,117 @@ describe('Episodes store', () => { expect(save_migrated_episode.mock.invocationCallOrder[0]) .toBeLessThan(delete_legacy_episode.mock.invocationCallOrder[0]); expect(delete_audio_file).toHaveBeenCalledWith('file:///tmp/recording.m4a'); - expect(Episodes.is_upgrading_legacy).toBe(false); - expect(Episodes.get_episode(migrated_episode.id)?.title).toBe('Legacy recording'); + expect(legacy_store.is_upgrading_legacy).toBe(false); + expect(legacy_store.get_episode(migrated_episode.id)?.title).toBe('Legacy recording'); + expect(legacy_store.get_episode('episode-1')?.title).toBe('Morning microcast'); + expect(console.warn).toHaveBeenCalledWith( + 'Could not upgrade legacy recording:', + '2024-03-14 12:34:56', + expect.any(Error), + ); + expect(jest.getTimerCount()).toBe(0); + }); + + test('loads existing recordings even when the legacy scan fails', async () => { + list_legacy_episodes.mockRejectedValueOnce(new Error('Could not read old folders.')); + + await legacy_store.refresh(); + + expect(legacy_store.get_episode('episode-1')?.title).toBe('Morning microcast'); + expect(legacy_store.did_hydrate).toBe(true); + expect(legacy_store.is_loading).toBe(false); + expect(legacy_store.is_upgrading_legacy).toBe(false); + expect(delete_legacy_episode).not.toHaveBeenCalled(); + expect(console.warn).toHaveBeenCalledWith( + 'Could not upgrade legacy recordings:', + expect.any(Error), + ); + }); + + test.each([ + ['finding old recordings', list_legacy_episodes], + ['checking an existing migration', read_migrated_episode], + ['converting audio', normalize_imported_audio], + ['saving converted audio', save_migrated_episode], + ])('continues after 30 seconds when %s hangs', async (_, stalled_operation) => { + stalled_operation.mockReturnValueOnce(new Promise(() => {})); + + const refresh = legacy_store.refresh(); + + await jest.advanceTimersByTimeAsync(30_000); + await refresh; + + expect(legacy_store.get_episode('episode-1')?.title).toBe('Morning microcast'); + expect(legacy_store.did_hydrate).toBe(true); + expect(legacy_store.is_loading).toBe(false); + expect(legacy_store.is_upgrading_legacy).toBe(false); + expect(delete_legacy_episode).not.toHaveBeenCalled(); + expect(console.warn).toHaveBeenCalledWith('Legacy recording upgrade timed out.'); + expect(jest.getTimerCount()).toBe(0); + + await legacy_store.refresh(); + + expect(list_episodes).toHaveBeenCalledTimes(2); + expect(list_legacy_episodes).toHaveBeenCalledTimes(1); + }); + + test('Continue stops waiting and discards a late conversion without deleting originals', async () => { + let finish_conversion; + normalize_imported_audio.mockReturnValueOnce(new Promise(resolve => { + finish_conversion = resolve; + })); + list_legacy_episodes.mockResolvedValueOnce([ + { + ...legacy_episode, + clips: [ + ...legacy_episode.clips, + { name: 'second.caf', uri: 'file:///documents/second.caf' }, + ], + }, + { ...legacy_episode, id: '2024-03-14 12:34:56' }, + ]); + + const refresh = legacy_store.refresh(); + + await jest.advanceTimersByTimeAsync(0); + legacy_store.continue_without_legacy_upgrade(); + + expect(legacy_store.is_upgrading_legacy).toBe(false); + await refresh; + expect(legacy_store.is_loading).toBe(false); + + legacy_store.apply_episode_snapshot(EPISODE_BY_URL); + finish_conversion(converted_clip); + await jest.advanceTimersByTimeAsync(0); + + expect(normalize_imported_audio).toHaveBeenCalledTimes(1); + expect(read_migrated_episode).toHaveBeenCalledTimes(1); + expect(save_migrated_episode).not.toHaveBeenCalled(); + expect(delete_legacy_episode).not.toHaveBeenCalled(); + expect(delete_audio_file).toHaveBeenCalledWith(converted_clip.uri); + expect(legacy_store.get_episode('episode-2')?.title).toBe('Afternoon microcast'); + expect(legacy_store.get_episode(migrated_episode.id)).toBeNull(); + expect(jest.getTimerCount()).toBe(0); + }); + + test('a save finishing after timeout keeps the original and does not change the loaded list', async () => { + let finish_save; + save_migrated_episode.mockReturnValueOnce(new Promise(resolve => { + finish_save = resolve; + })); + + const refresh = legacy_store.refresh(); + + await jest.advanceTimersByTimeAsync(30_000); + await refresh; + + finish_save(migrated_episode); + await jest.advanceTimersByTimeAsync(0); + + expect(delete_legacy_episode).not.toHaveBeenCalled(); + expect(legacy_store.get_episode(migrated_episode.id)).toBeNull(); + expect(legacy_store.get_episode('episode-1')?.title).toBe('Morning microcast'); + expect(legacy_store.is_upgrading_legacy).toBe(false); }); }); From b594a6388f6e02576dc4f9fde6fd2aecb76d1949 Mon Sep 17 00:00:00 2001 From: Manton Reece Date: Mon, 28 Sep 2026 12:10:00 -0500 Subject: [PATCH 2/2] Keep legacy upgrades running after the dialog closes. --- src/stores/Episodes.js | 20 +++++++--- src/stores/__tests__/Episodes.test.js | 56 ++++++++++++++++++++++++--- 2 files changed, 65 insertions(+), 11 deletions(-) diff --git a/src/stores/Episodes.js b/src/stores/Episodes.js index 121722c..eb2c175 100644 --- a/src/stores/Episodes.js +++ b/src/stores/Episodes.js @@ -181,7 +181,8 @@ const Episodes = types } self.did_hydrate = true; - yield self.upgrade_legacy_recordings(); + // Native conversion must not keep the recordings screen loading. + self.upgrade_legacy_recordings(); } finally { self.is_loading = false; } @@ -194,7 +195,7 @@ const Episodes = types self.did_check_for_legacy = true; - const upgrade = { is_cancelled: false, cancel: null }; + const upgrade = { is_cancelled: false, is_modal_dismissed: false, cancel: null }; const cancelled = new Promise(resolve => { upgrade.cancel = () => { upgrade.is_cancelled = true; @@ -204,15 +205,14 @@ const Episodes = types self.legacy_upgrade = upgrade; const timeout = setTimeout(() => { - console.warn('Legacy recording upgrade timed out.'); - self.continue_without_legacy_upgrade(); + self.dismiss_legacy_upgrade_modal(); }, LEGACY_UPGRADE_TIMEOUT_MILLIS); try { const legacy_episodes = yield Promise.race([list_legacy_episodes(), cancelled]); if (!upgrade.is_cancelled && legacy_episodes.length > 0) { - self.is_upgrading_legacy = true; + self.is_upgrading_legacy = !upgrade.is_modal_dismissed; for (const legacy_episode of legacy_episodes) { try { @@ -243,12 +243,20 @@ const Episodes = types } }), + dismiss_legacy_upgrade_modal() { + if (self.legacy_upgrade) { + self.legacy_upgrade.is_modal_dismissed = true; + } + + self.is_upgrading_legacy = false; + }, + continue_without_legacy_upgrade() { if (self.legacy_upgrade) { self.legacy_upgrade.cancel(); } - self.is_upgrading_legacy = false; + self.dismiss_legacy_upgrade_modal(); }, refresh_episode: flow(function* (episode_id = '') { diff --git a/src/stores/__tests__/Episodes.test.js b/src/stores/__tests__/Episodes.test.js index 70cab15..e0f32d8 100644 --- a/src/stores/__tests__/Episodes.test.js +++ b/src/stores/__tests__/Episodes.test.js @@ -219,6 +219,7 @@ describe('Episodes store', () => { finish_conversion(converted_clip); await refresh; + await jest.advanceTimersByTimeAsync(0); expect(read_migrated_episode).toHaveBeenCalledWith( '2024-03-13 12:34:56', @@ -252,6 +253,7 @@ describe('Episodes store', () => { list_legacy_episodes.mockRejectedValueOnce(new Error('Could not read old folders.')); await legacy_store.refresh(); + await jest.advanceTimersByTimeAsync(0); expect(legacy_store.get_episode('episode-1')?.title).toBe('Morning microcast'); expect(legacy_store.did_hydrate).toBe(true); @@ -269,7 +271,7 @@ describe('Episodes store', () => { ['checking an existing migration', read_migrated_episode], ['converting audio', normalize_imported_audio], ['saving converted audio', save_migrated_episode], - ])('continues after 30 seconds when %s hangs', async (_, stalled_operation) => { + ])('closes the dialog after 30 seconds when %s hangs', async (_, stalled_operation) => { stalled_operation.mockReturnValueOnce(new Promise(() => {})); const refresh = legacy_store.refresh(); @@ -281,8 +283,8 @@ describe('Episodes store', () => { expect(legacy_store.did_hydrate).toBe(true); expect(legacy_store.is_loading).toBe(false); expect(legacy_store.is_upgrading_legacy).toBe(false); + expect(legacy_store.legacy_upgrade).not.toBeNull(); expect(delete_legacy_episode).not.toHaveBeenCalled(); - expect(console.warn).toHaveBeenCalledWith('Legacy recording upgrade timed out.'); expect(jest.getTimerCount()).toBe(0); await legacy_store.refresh(); @@ -291,6 +293,50 @@ describe('Episodes store', () => { expect(list_legacy_episodes).toHaveBeenCalledTimes(1); }); + test('a slow conversion finishes and appears after the dialog closes', async () => { + let finish_conversion; + normalize_imported_audio.mockReturnValueOnce(new Promise(resolve => { + finish_conversion = resolve; + })); + + await legacy_store.refresh(); + await jest.advanceTimersByTimeAsync(0); + + expect(legacy_store.is_loading).toBe(false); + expect(legacy_store.is_upgrading_legacy).toBe(true); + + await jest.advanceTimersByTimeAsync(30_000); + + expect(legacy_store.is_upgrading_legacy).toBe(false); + expect(save_migrated_episode).not.toHaveBeenCalled(); + + finish_conversion(converted_clip); + await jest.advanceTimersByTimeAsync(0); + + expect(save_migrated_episode).toHaveBeenCalledWith(legacy_episode, [converted_clip]); + expect(delete_legacy_episode).toHaveBeenCalledWith(legacy_episode.id); + expect(legacy_store.get_episode(migrated_episode.id)?.title).toBe('Legacy recording'); + expect(legacy_store.is_upgrading_legacy).toBe(false); + expect(legacy_store.legacy_upgrade).toBeNull(); + }); + + test('a slow scan does not reopen the dialog after it was dismissed', async () => { + let finish_scan; + list_legacy_episodes.mockReturnValueOnce(new Promise(resolve => { + finish_scan = resolve; + })); + + await legacy_store.refresh(); + await jest.advanceTimersByTimeAsync(30_000); + + finish_scan([legacy_episode]); + await jest.advanceTimersByTimeAsync(0); + + expect(legacy_store.is_upgrading_legacy).toBe(false); + expect(legacy_store.get_episode(migrated_episode.id)?.title).toBe('Legacy recording'); + expect(delete_legacy_episode).toHaveBeenCalledWith(legacy_episode.id); + }); + test('Continue stops waiting and discards a late conversion without deleting originals', async () => { let finish_conversion; normalize_imported_audio.mockReturnValueOnce(new Promise(resolve => { @@ -330,7 +376,7 @@ describe('Episodes store', () => { expect(jest.getTimerCount()).toBe(0); }); - test('a save finishing after timeout keeps the original and does not change the loaded list', async () => { + test('a save finishing after the dialog closes completes the migration', async () => { let finish_save; save_migrated_episode.mockReturnValueOnce(new Promise(resolve => { finish_save = resolve; @@ -344,8 +390,8 @@ describe('Episodes store', () => { finish_save(migrated_episode); await jest.advanceTimersByTimeAsync(0); - expect(delete_legacy_episode).not.toHaveBeenCalled(); - expect(legacy_store.get_episode(migrated_episode.id)).toBeNull(); + expect(delete_legacy_episode).toHaveBeenCalledWith(legacy_episode.id); + expect(legacy_store.get_episode(migrated_episode.id)?.title).toBe('Legacy recording'); expect(legacy_store.get_episode('episode-1')?.title).toBe('Morning microcast'); expect(legacy_store.is_upgrading_legacy).toBe(false); });