From 5e62cf71f4c8f96c8b6f84b50de5b981ee67b0b5 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sun, 27 Sep 2026 10:04:18 +0200 Subject: [PATCH] fix: add no extra account named after its id when Android refuses one #34 Turning device sync on retried with the app account's registry id as the Android account name whenever adding the account failed. That fallback is for one case only: the login name turned out to belong to another app account's Android account, which the native module reports with the `conflict` code. A passing AccountManager failure or a permission error instead created a second account named after the opaque id, which then showed in the Contacts and Calendar apps. Only a `conflict` rejection takes the fallback now; any other failure fails the turn-on as before. --- src/device-sync/__tests__/app/lifecycle.test.ts | 15 ++++++++++++++- src/device-sync/app/lifecycle.ts | 5 +++-- 2 files changed, 17 insertions(+), 3 deletions(-) diff --git a/src/device-sync/__tests__/app/lifecycle.test.ts b/src/device-sync/__tests__/app/lifecycle.test.ts index b95b0bb1..18729359 100644 --- a/src/device-sync/__tests__/app/lifecycle.test.ts +++ b/src/device-sync/__tests__/app/lifecycle.test.ts @@ -43,7 +43,8 @@ vi.mock('../../native', () => ({ ensureAndroidAccount: vi.fn(async (name: string, registryId: string) => { h.state.log.push(`ensure ${name}`); const existing = h.find(name); - if (existing && existing.registryId !== registryId) throw new Error('conflict'); + // As the native module rejects (DeviceSyncAccounts.ensure → AccountConflictException). + if (existing && existing.registryId !== registryId) throw Object.assign(new Error(`${name} belongs to another app account`), { code: 'conflict' }); if (existing) return false; h.state.accounts.push({ name, registryId, sync: {} }); return true; @@ -212,6 +213,18 @@ describe('enable', () => { expect(outcome).toEqual({ kind: 'enabled', accountName: ALICE }); }); + it('adds no second account named after the registry id when adding the account fails for another reason', async () => { + for (const code of ['failed', 'permission']) { + vi.mocked(native.ensureAndroidAccount).mockClear(); + vi.mocked(native.ensureAndroidAccount).mockRejectedValueOnce(Object.assign(new Error(`AccountManager said no (${code})`), { code })); + const outcome = await enableDeviceSync(ALICE, CONTACTS_AUTHORITY); + expect(outcome).toEqual({ kind: 'failed', message: `AccountManager said no (${code})` }); + expect(native.ensureAndroidAccount).toHaveBeenCalledTimes(1); + expect(h.state.accounts).toEqual([]); + expect(accountDeviceSync(ALICE).androidAccountName).toBeUndefined(); + } + }); + it('leaves no account behind when turning sync on fails', async () => { h.state.failSetSyncEnabled = true; const outcome = await enableDeviceSync(ALICE, CONTACTS_AUTHORITY); diff --git a/src/device-sync/app/lifecycle.ts b/src/device-sync/app/lifecycle.ts index 4daba392..6f058c71 100644 --- a/src/device-sync/app/lifecycle.ts +++ b/src/device-sync/app/lifecycle.ts @@ -122,8 +122,9 @@ async function ensureAccountFor(registryId: string): Promise { await ensureAndroidAccount(name, registryId); return name; } catch (err) { - // The name belongs to another app account after all (the list was stale). - if (name === registryId) throw err; + // The name belongs to another app account after all (the list was stale). Any + // other failure fails the turn-on: a second account would show in the apps. + if (name === registryId || (err as { code?: unknown } | null)?.code !== 'conflict') throw err; await ensureAndroidAccount(registryId, registryId); return registryId; }