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); }); });