diff --git a/locales/rn/de.json b/locales/rn/de.json index 7c8e1686..0632ec64 100644 --- a/locales/rn/de.json +++ b/locales/rn/de.json @@ -76,8 +76,10 @@ "clear_confirm": "Alle entfernen", "clear_description": "Alle Termine in diesem Kalender werden gelöscht. Termine, die auch zu einem anderen Kalender gehören, werden nur aus diesem entfernt.", "clear_title": "Alle Termine entfernen?", + "color_invalid": "Geben Sie eine Farbe als sechs Hex-Ziffern ein, mit oder ohne #, etwa #3b82f6.", "delete_description": "Der Kalender und alle seine Termine werden gelöscht. Dies kann nicht rückgängig gemacht werden.", "delete_title": "Kalender löschen?", + "description_field": "Beschreibung", "description_placeholder": "Optional", "edit_calendar": "Kalender bearbeiten", "new_calendar": "Neuer Kalender" @@ -91,7 +93,8 @@ }, "occurrence_not_found_again": "Die Änderung wurde nicht gespeichert: Der Termin konnte auf dem Server nicht wiedergefunden werden. Versuchen Sie es erneut.", "participants": { - "limit_warning": "Mehr als {max, plural, one {# Teilnehmer} other {# Teilnehmer}}, den Organisator mitgezählt: Der Server lehnt das Speichern dieses Termins möglicherweise ab. Ihr Administrator kann das Limit erhöhen." + "limit_warning": "Mehr als {max, plural, one {# Teilnehmer} other {# Teilnehmer}}, den Organisator mitgezählt: Der Server lehnt das Speichern dieses Termins möglicherweise ab. Ihr Administrator kann das Limit erhöhen.", + "remove_named": "{name} entfernen" }, "recurrence": { "editor_occurrences_count": "{count, plural, one {Termin} other {Terminen}}", @@ -146,7 +149,8 @@ }, "detail": { "delete_confirm": "Dieses Ereignis löschen?" - } + }, + "change_month_named": "{month}, Monat wechseln" }, "common": { "add": "Hinzufügen", diff --git a/locales/rn/en.json b/locales/rn/en.json index fb90bce8..176c2bf2 100644 --- a/locales/rn/en.json +++ b/locales/rn/en.json @@ -13,6 +13,7 @@ "alerts": { "minutes_after": "{count, plural, one {# minute after} other {# minutes after}}" }, + "change_month_named": "{month}, change month", "colors": { "indigo": "Indigo", "teal": "Teal" @@ -95,10 +96,11 @@ "clear_confirm": "Remove all", "clear_description": "Every event in this calendar will be deleted. Events that also belong to another calendar are only unlinked.", "clear_title": "Remove all events?", - "color_invalid": "Enter a color as # and six hex digits, such as #3b82f6.", + "color_invalid": "Enter a color as six hex digits, with or without #, such as #3b82f6.", "custom_color": "Custom color (hex)", "delete_description": "The calendar and all of its events will be deleted. This cannot be undone.", "delete_title": "Delete calendar?", + "description_field": "Description", "description_placeholder": "Optional", "edit_calendar": "Edit calendar", "fewer_colors": "Fewer colors", @@ -115,7 +117,8 @@ "occurrence_not_found_again": "The change was not saved: the occurrence could not be found again on the server. Please try again.", "participants": { "group_members": "{count, plural, one {# member} other {# members}}", - "limit_warning": "More than {max, plural, one {# participant} other {# participants}}, including the organizer: the server may refuse to save this event. An administrator can raise the limit." + "limit_warning": "More than {max, plural, one {# participant} other {# participants}}, including the organizer: the server may refuse to save this event. An administrator can raise the limit.", + "remove_named": "Remove {name}" }, "recurrence": { "editor_occurrences_count": "{count, plural, one {occurrence} other {occurrences}}", diff --git a/patches/react-native-android-widget+0.22.1.patch b/patches/react-native-android-widget+0.22.1.patch index 592b58c8..d6a3c93e 100644 --- a/patches/react-native-android-widget+0.22.1.patch +++ b/patches/react-native-android-widget+0.22.1.patch @@ -24,6 +24,20 @@ index ed1af56..12c3630 100644 intent, PendingIntent.FLAG_CANCEL_CURRENT | PendingIntent.FLAG_MUTABLE +diff --git a/node_modules/react-native-android-widget/android/src/main/java/com/reactnativeandroidwidget/RNWidgetProvider.java b/node_modules/react-native-android-widget/android/src/main/java/com/reactnativeandroidwidget/RNWidgetProvider.java +index c138d81..bcd490d 100644 +--- a/node_modules/react-native-android-widget/android/src/main/java/com/reactnativeandroidwidget/RNWidgetProvider.java ++++ b/node_modules/react-native-android-widget/android/src/main/java/com/reactnativeandroidwidget/RNWidgetProvider.java +@@ -133,6 +133,9 @@ public class RNWidgetProvider extends AppWidgetProvider { + try { + Intent intent = new Intent(Intent.ACTION_VIEW, Uri.parse(uri)); + intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK); ++ // This app's own activity, never a chooser: another build of the app ++ // (another flavor) handles the same scheme and would be offered too. ++ intent.setPackage(context.getPackageName()); + context.startActivity(intent); + } catch (Exception e) { + e.printStackTrace(); diff --git a/node_modules/react-native-android-widget/android/src/main/java/com/reactnativeandroidwidget/RNWidgetUtil.java b/node_modules/react-native-android-widget/android/src/main/java/com/reactnativeandroidwidget/RNWidgetUtil.java index 2a34305..711503b 100644 --- a/node_modules/react-native-android-widget/android/src/main/java/com/reactnativeandroidwidget/RNWidgetUtil.java diff --git a/src/api/__tests__/calendar.test.ts b/src/api/__tests__/calendar.test.ts index 9f376758..833d784c 100644 --- a/src/api/__tests__/calendar.test.ts +++ b/src/api/__tests__/calendar.test.ts @@ -251,7 +251,8 @@ describe('calendar operations', () => { id: 's1', baseEventId: 'base', recurrenceId: '2026-03-02', start: '2026-03-02T00:00:00', timeZone: 'Europe/Berlin', title: 'Daily', } as any; - const single = { id: 's9', baseEventId: 'other', start: '2026-03-02T09:00:00', title: 'Once' } as any; + // Stalwart 1.0 returns a one-off event under its own id. + const single = { id: 's9', baseEventId: null, start: '2026-03-02T09:00:00', title: 'Once' } as any; const [hydrated, untouched] = await hydrateExpandedOccurrences([occurrence, single]); @@ -267,10 +268,40 @@ describe('calendar operations', () => { }); it('skips the round trip when nothing recurs', async () => { - const single = { id: 's9', baseEventId: 'other', start: '2026-03-02T09:00:00' } as any; - expect(await hydrateExpandedOccurrences([single])).toEqual([single]); + const single = { id: 's9', baseEventId: null, start: '2026-03-02T09:00:00' } as any; + const own = { id: 'd', baseEventId: 'd', start: '2026-03-02T09:00:00' } as any; + expect(await hydrateExpandedOccurrences([single, own])).toEqual([single, own]); expect(mockRequest).not.toHaveBeenCalled(); }); + + // Stalwart 0.16 hands a one-off event out under a synthetic id, with no + // recurrenceId and no showWithoutTime, in the query's time zone: the + // all-day "Company holiday" read as 00:00 to 00:00. + it('gives a one-off all-day event under a synthetic id its all-day flag back', async () => { + mockRequest.mockResolvedValue({ + methodResponses: [['CalendarEvent/get', { + list: [ + { id: 'c', showWithoutTime: true, duration: 'P1D' }, + { id: 'd', timeZone: 'Europe/Berlin', duration: 'PT1H' }, + ], + }, '0']], + }); + const holiday = { + id: 'h1pceeaaaaac', baseEventId: 'c', '@type': 'Event', title: 'Company holiday', + start: '2026-11-01T00:00:00', duration: 'P1D', timeZone: 'Europe/Berlin', + utcStart: '2026-10-31T23:00:00Z', utcEnd: '2026-11-01T23:00:00Z', + } as any; + const review = { + id: 'h1pceeaaaaad', baseEventId: 'd', start: '2026-11-01T14:00:00', duration: 'PT1H', timeZone: 'Europe/Berlin', + } as any; + + const [allDay, timed] = await hydrateExpandedOccurrences([holiday, review]); + + expect(mockRequest.mock.calls[0][0][0][1]).toMatchObject({ ids: ['c', 'd'] }); + expect(allDay).toMatchObject({ id: 'h1pceeaaaaac', showWithoutTime: true, timeZone: null, start: '2026-11-01T00:00:00' }); + expect(allDay.recurrenceRules).toBeUndefined(); + expect(timed).toBe(review); + }); }); describe('getEvents for expanded occurrences', () => { diff --git a/src/api/__tests__/live-stalwart-calendar.test.ts b/src/api/__tests__/live-stalwart-calendar.test.ts index 82edff70..744f40d3 100644 --- a/src/api/__tests__/live-stalwart-calendar.test.ts +++ b/src/api/__tests__/live-stalwart-calendar.test.ts @@ -937,6 +937,28 @@ live('live Stalwart calendar', () => { }, 60_000); }); + describe('an all-day event', () => { + // 0.16 expands a one-off event to a synthetic id without showWithoutTime; + // the detail sheet then said 00:00 - 00:00 and the editor's switch was off. + it('stays all-day on the calendar and in the editor', async () => { + await connectSingleton(); + const created = await createEvent({ + '@type': 'Event', + title: `${RUN} all day`, + start: '2027-11-10T00:00:00', + duration: 'P1D', + showWithoutTime: true, + } as Partial, calendarId, false); + const uid = created.uid ?? (await getEvents([created.id]))[0].uid!; + const [event] = await loadOccurrences(uid, '2027-11-08T00:00:00Z', '2027-11-15T00:00:00Z'); + expect(event, 'the event is on the calendar').toBeTruthy(); + expect(event.showWithoutTime).toBe(true); + expect(event.timeZone ?? null).toBeNull(); + expect(editorTimesFromEvent(event).allDay).toBe(true); + expect(getEventStartDate(event).getDate()).toBe(10); + }, 60_000); + }); + describe('a subscribed feed', () => { const FEED_URL = 'https://feeds.invalid/live.ics'; let feed = ''; diff --git a/src/api/__tests__/live-stalwart-delegation.test.ts b/src/api/__tests__/live-stalwart-delegation.test.ts index f2a3dcfd..f2ab9665 100644 --- a/src/api/__tests__/live-stalwart-delegation.test.ts +++ b/src/api/__tests__/live-stalwart-delegation.test.ts @@ -22,6 +22,12 @@ import { JMAPClient } from '../jmap-client'; import { CAPABILITIES } from '../types'; import { applyShareChanges, getShareTargets, supportsDelegation, supportsMailboxSharing } from '../delegation'; import { buildGrantChanges, buildRevokeChanges, summarizeDelegates } from '../../lib/delegation/delegation'; +import { lookupMessage, lookupResult, principalRowDisplay, resolvePrincipal } from '../../lib/delegation/display'; +import { listPrincipals } from '../files'; +import { findPrincipalByEmail } from '../delegation'; +import { getAddressBooks, setAddressBookShare } from '../contacts'; + +const t = (_key: string, fallback?: string) => fallback ?? ''; const SERVER = process.env.BULWARK_LIVE_JMAP; const USER = process.env.BULWARK_LIVE_USER ?? 'usera@example.org'; @@ -120,4 +126,57 @@ live('live Stalwart: delegates', () => { expect(gone.calendars.filter((id) => changes.some((c) => c.id === id))).toEqual([]); expect(gone.addressBooks.filter((id) => changes.some((c) => c.id === id))).toEqual([]); }, 120_000); + + // Stalwart 1.0 with directory queries off (VM 106's 1.0.1) names nobody + // the owner merely shares with: Principal/get answers notFound for the + // delegate's id, a letter or two. The row reads "Unknown person" then, not + // "Unknown person / e"; 0.16 names them. + it('names who has access where the server lets it, and shows no id where it does not', async () => { + const { targets } = await getShareTargets(); + const principalId = delegate.accountId; + const results = await applyShareChanges(buildGrantChanges(targets, principalId, { mail: 'none', calendars: 'none', addressBooks: 'default' }, 'read')); + expect(results.filter((r) => !r.ok)).toEqual([]); + try { + const listing = await listPrincipals(); + const sessionAccounts = new Map(Object.entries(owner.currentSession?.accounts ?? {}) + .filter(([id]) => id !== owner.accountId) + .map(([id, info]) => [id, info.name])); + const principal = resolvePrincipal(principalId, { directory: listing.principals, remembered: new Map(), sessionAccounts }); + const row = principalRowDisplay(principal, t); + if (listing.directoryQueriesOff) { + expect(principal).toBeNull(); + expect(row).toEqual({ name: 'Unknown person', detail: null }); + } else { + expect(principal?.email?.toLowerCase()).toBe(USER2!.toLowerCase()); + expect(row.name).not.toBe('Unknown person'); + } + } finally { + expect(await revokeAll()).toBe(true); + } + }, 120_000); + + // The calendar and address book share sheet looks a typed address up on + // its own. 0.16 finds it and the book is shared; 1.0 with directory + // queries off refuses every lookup, and the sheet says an administrator + // has to turn them on instead of "No matches". + it('shares an address book with a typed address the server finds, and says why when it will not look', async () => { + await useOwner(); + const listing = await listPrincipals(); + const found = await findPrincipalByEmail(USER2!); + const result = lookupResult(USER2!, found, [owner.accountId]); + if (listing.directoryQueriesOff) { + expect(found).toMatchObject({ principal: null, status: 'forbidden', directoryQueriesOff: true }); + expect(lookupMessage(result.lookup, t)).toMatch(/administrator has to turn on directory lookups/); + return; + } + expect(result.picked?.id).toBe(delegate.accountId); + const book = (await getAddressBooks()).find((b) => b.isDefault) ?? (await getAddressBooks())[0]; + try { + await setAddressBookShare(book.id, result.picked!.id, { mayRead: true, mayWrite: false, mayDelete: false, mayShare: false } as never); + expect((await delegateView()).addressBooks).toContain(book.id); + } finally { + await useOwner(); + expect(await revokeAll()).toBe(true); + } + }, 120_000); }); diff --git a/src/api/calendar.ts b/src/api/calendar.ts index a21b3ea5..32f6db37 100644 --- a/src/api/calendar.ts +++ b/src/api/calendar.ts @@ -865,7 +865,10 @@ async function queryExpandedRange( * rule / overrides (and all-day ones without showWithoutTime). Fetch those * from the base events in one batched get and merge them in, so an * occurrence carries the same series context the device-side expansion - * copies from its master. + * copies from its master. Stalwart 0.16 also hands a one-off event out + * under a synthetic id, without a recurrenceId and without + * showWithoutTime: an all-day one would read as 00:00 to 00:00, so its base + * is fetched as well. */ export async function hydrateExpandedOccurrences( events: CalendarEvent[], @@ -873,7 +876,7 @@ export async function hydrateExpandedOccurrences( ): Promise { const baseIds = Array.from(new Set( events - .filter((event) => isServerRecurrenceInstance(event) && event.recurrenceId) + .filter((event) => isServerRecurrenceInstance(event)) .map((event) => event.baseEventId as string), )); if (baseIds.length === 0) return events; diff --git a/src/components/calendar/CalendarEditSheet.tsx b/src/components/calendar/CalendarEditSheet.tsx index 67d9f5c3..739aebc0 100644 --- a/src/components/calendar/CalendarEditSheet.tsx +++ b/src/components/calendar/CalendarEditSheet.tsx @@ -27,7 +27,7 @@ import { radius, spacing, typography, type ThemePalette } from '../../theme/toke import { useColors } from '../../theme/colors'; import { useLocaleStore, type TranslateFn } from '../../stores/locale-store'; import { CALENDAR_COLOR_PALETTE, calendarColorName, getCalendarColor } from '../../lib/calendar-utils'; -import { customColorGrid, normalizeHexColor } from '../../lib/calendar-custom-color'; +import { customColorGrid, HEX_FIELD_MAX_LENGTH, hexFieldText, normalizeHexColor } from '../../lib/calendar-custom-color'; const CUSTOM_COLOR_GRID = customColorGrid(); import Button from '../Button'; @@ -260,11 +260,14 @@ export function CalendarEditSheet({ visible, calendar, onSave, onClose }: Calend const hex = normalizeHexColor(v); if (hex) setColor(hex); }} + // "3b82f6" works as well as "#3b82f6"; leaving the field + // shows it with its "#". + onBlur={() => setHexInput((v) => hexFieldText(v))} placeholder="#3b82f6" placeholderTextColor={c.textMuted} autoCapitalize="none" autoCorrect={false} - maxLength={7} + maxLength={HEX_FIELD_MAX_LENGTH} accessibilityLabel={t('calendar.management.custom_color', 'Custom color (hex)')} style={[styles.input, styles.hexInput, hexInvalid && styles.inputInvalid]} /> @@ -283,7 +286,7 @@ export function CalendarEditSheet({ visible, calendar, onSave, onClose }: Calend {hexInvalid && ( - {t('calendar.management.color_invalid', 'Enter a color as # and six hex digits, such as #3b82f6.')} + {t('calendar.management.color_invalid', 'Enter a color as six hex digits, with or without #, such as #3b82f6.')} )} {moreColors && ( @@ -307,9 +310,11 @@ export function CalendarEditSheet({ visible, calendar, onSave, onClose }: Calend ))} )} - {t('calendar.management.description', 'Description')} + {/* Not calendar.management.description: in the webmail's + catalog that is the management page's desktop help text. */} + {t('calendar.management.description_field', 'Description')} setAttendees((prev) => [...prev, a])} + onAdd={(a) => setAttendees((prev) => withAttendee(prev, a))} onRemove={(email) => setAttendees((prev) => prev.filter((a) => a.email.toLowerCase() !== email.toLowerCase())) } diff --git a/src/components/calendar/ParticipantInput.tsx b/src/components/calendar/ParticipantInput.tsx index 80e43ff6..55d56359 100644 --- a/src/components/calendar/ParticipantInput.tsx +++ b/src/components/calendar/ParticipantInput.tsx @@ -14,6 +14,7 @@ import { useContactsStore, type RecipientSuggestion } from '../../stores/contact import { useLocaleStore } from '../../stores/locale-store'; import type { Attendee } from '../../lib/calendar-participants'; import { participantSuggestions, participantsFromSuggestion } from '../../lib/calendar-participant-suggestions'; +import { participantRemoveAccessibilityLabel } from '../../lib/calendar-a11y'; interface ParticipantInputProps { attendees: Attendee[]; @@ -31,7 +32,14 @@ export function ParticipantInput({ attendees, onAdd, onRemove }: ParticipantInpu const c = useColors(); const styles = React.useMemo(() => makeStyles(c), [c]); const t = useLocaleStore((s) => s.t); - const [draft, setDraft] = React.useState(''); + const [draft, setDraftState] = React.useState(''); + // Done submits and then blurs the field; both handlers run before a + // re-render, so they read the text from here, not from `draft`. + const draftRef = React.useRef(''); + const setDraft = React.useCallback((value: string) => { + draftRef.current = value; + setDraftState(value); + }, []); // Contacts, groups and recent Sent recipients, as the composer offers them. const getAutocomplete = useContactsStore((s) => s.getAutocomplete); const getGroupRecipients = useContactsStore((s) => s.getGroupRecipients); @@ -68,7 +76,8 @@ export function ParticipantInput({ attendees, onAdd, onRemove }: ParticipantInpu }; const handleSubmit = () => { - if (draft.trim()) addParticipant(draft); + const typed = draftRef.current; + if (typed.trim()) addParticipant(typed); }; return ( @@ -84,7 +93,7 @@ export function ParticipantInput({ attendees, onAdd, onRemove }: ParticipantInpu onPress={() => onRemove(a.email)} hitSlop={6} accessibilityRole="button" - accessibilityLabel={t('calendar.participants.remove', 'Remove')} + accessibilityLabel={participantRemoveAccessibilityLabel(a, t)} > @@ -93,28 +102,8 @@ export function ParticipantInput({ attendees, onAdd, onRemove }: ParticipantInpu )} - - + {/* Above the field, not below it: below, the keyboard covered the + list (the editor keeps only the focused field in view). */} {filteredSuggestions.length > 0 && ( )} + + ); } diff --git a/src/components/calendar/TimeGridScrollView.tsx b/src/components/calendar/TimeGridScrollView.tsx index 65f7503e..be6da07a 100644 --- a/src/components/calendar/TimeGridScrollView.tsx +++ b/src/components/calendar/TimeGridScrollView.tsx @@ -89,6 +89,8 @@ interface TimeGridScrollViewProps { onSelectDate?: (date: Date) => void; onSelectEvent?: (event: CalendarEvent) => void; onCreateAtTime?: (date: Date) => void; + /** A change takes the "New event" block away (the editor closed, the tab was left). */ + draftResetKey?: number; } /** @@ -119,6 +121,7 @@ function TimeGridScrollViewInner({ onSelectDate, onSelectEvent, onCreateAtTime, + draftResetKey = 0, }: TimeGridScrollViewProps) { const c = useColors(); // "Light event colors" (webmail calendarTintedEvents). @@ -314,6 +317,9 @@ function TimeGridScrollViewInner({ // A tap on an empty slot marks it with a "New event" block; tapping that // block creates the event. Two taps, so a stray tap never opens the editor. const [draft, setDraft] = React.useState<{ key: string; hour: number } | null>(null); + React.useEffect(() => { + setDraft(null); + }, [draftResetKey]); const handleTapAt = React.useCallback((day: Date, hour: number) => { const key = dayKey(day); setDraft((cur) => (cur && cur.key === key && cur.hour === hour ? null : { key, hour })); diff --git a/src/components/calendar/__tests__/calendar-edit-description-label.test.ts b/src/components/calendar/__tests__/calendar-edit-description-label.test.ts new file mode 100644 index 00000000..996edc86 --- /dev/null +++ b/src/components/calendar/__tests__/calendar-edit-description-label.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from 'vitest'; +import fs from 'node:fs'; +import path from 'node:path'; + +const root = path.resolve(__dirname, '../../../..'); +const source = fs.readFileSync(path.resolve(__dirname, '../CalendarEditSheet.tsx'), 'utf8'); +const catalog = (file: string) => JSON.parse(fs.readFileSync(path.join(root, file), 'utf8')); +const common = catalog('locales/en/common.json'); +const overlay = catalog('locales/rn/en.json'); + +function lookup(tree: Record, key: string): unknown { + return key.split('.').reduce((node, part) => (node as Record | undefined)?.[part], tree); +} + +// The app reads the overlay over the webmail's catalog. +const english = (key: string) => (lookup(overlay, key) ?? lookup(common, key)) as string | undefined; + +describe('the calendar sheet’s description field', () => { + it('is named "Description", not by the webmail’s desktop help text', () => { + const before = source.slice(0, source.indexOf('value={description}')); + const input = before.slice(before.lastIndexOf('\{t\('([^']+)'/g)]; + expect(english(labels[labels.length - 1][1])).toBe('Description'); + }); +}); diff --git a/src/components/calendar/__tests__/participant-double-add.test.ts b/src/components/calendar/__tests__/participant-double-add.test.ts new file mode 100644 index 00000000..7bc970ee --- /dev/null +++ b/src/components/calendar/__tests__/participant-double-add.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from 'vitest'; +import fs from 'node:fs'; +import path from 'node:path'; +import { withAttendee, type Attendee } from '../../../lib/calendar-participants'; + +const input = fs.readFileSync(path.resolve(__dirname, '../ParticipantInput.tsx'), 'utf8'); +const editor = fs.readFileSync(path.resolve(__dirname, '../EventModal.tsx'), 'utf8'); + +describe('adding a participant with Done', () => { + it('invites a typed address once although submit and blur both add it', () => { + // Done fires onSubmitEditing, then onBlur, before the editor re-renders: + // two updaters run on the same queue. + const typed: Attendee = { name: '', email: 'userc@example.org' }; + let attendees: Attendee[] = []; + for (const updater of [(prev: Attendee[]) => withAttendee(prev, typed), (prev: Attendee[]) => withAttendee(prev, typed)]) { + attendees = updater(attendees); + } + expect(attendees).toEqual([typed]); + }); + + it('treats an address in other case or with spaces as the same person', () => { + const list = [{ name: 'C', email: 'userc@example.org' }]; + expect(withAttendee(list, { name: '', email: ' UserC@Example.org ' })).toBe(list); + expect(withAttendee(list, { name: '', email: 'userb@example.org' })).toHaveLength(2); + }); + + it('adds through the dedupe in the editor and reads the typed text from a ref', () => { + expect(editor).toMatch(/onAdd=\{\(a\) => setAttendees\(\(prev\) => withAttendee\(prev, a\)\)\}/); + const submit = input.slice(input.indexOf('const handleSubmit'), input.indexOf('};', input.indexOf('const handleSubmit'))); + expect(submit).toMatch(/draftRef\.current/); + }); + + it('shows the suggestions above the field, where the keyboard does not cover them', () => { + const jsx = input.slice(input.indexOf('return (')); + expect(jsx.indexOf('filteredSuggestions.length > 0')).toBeGreaterThan(-1); + expect(jsx.indexOf('filteredSuggestions.length > 0')).toBeLessThan(jsx.indexOf(' { + it('goes when the grid is told to drop it', () => { + expect(grid).toMatch(/React\.useEffect\(\(\) => \{\s*setDraft\(null\);\s*\}, \[draftResetKey\]\);/); + }); + + it('is dropped when the editor opens or closes and when the user leaves the tab', () => { + const grids = screen.slice(screen.indexOf('', screen.indexOf(' \{\s*setSlotDraftReset\(\(n\) => n \+ 1\);\s*\}, \[modalVisible\]\);/); + // The focus effect's cleanup runs on blur. + expect(screen).toMatch(/useFocusEffect\(\s*React\.useCallback\(\(\) => \(\) => setSlotDraftReset\(\(n\) => n \+ 1\), \[\]\),?\s*\)/); + }); +}); diff --git a/src/components/sharing/ShareCollectionSheet.tsx b/src/components/sharing/ShareCollectionSheet.tsx index 59e82ff9..470f9523 100644 --- a/src/components/sharing/ShareCollectionSheet.tsx +++ b/src/components/sharing/ShareCollectionSheet.tsx @@ -13,8 +13,10 @@ import { } from 'react-native'; import { Trash2, UserPlus, Users } from '../icons'; import { listPrincipals, ownPrincipalId, type PrincipalListing } from '../../api/files'; +import { findPrincipalByEmail } from '../../api/delegation'; import type { Principal } from '../../api/types'; import { directoryNotice } from '../../lib/principal-directory'; +import { canLookUpAddress, lookupMessage, lookupResult, type Lookup } from '../../lib/delegation/display'; import { spacing, radius, typography, type ThemePalette } from '../../theme/tokens'; import { useColors } from '../../theme/colors'; import { useLocaleStore } from '../../stores/locale-store'; @@ -72,12 +74,17 @@ export function ShareCollectionSheet({ const [shares, setShares] = React.useState>({}); const [search, setSearch] = React.useState(''); const [savingId, setSavingId] = React.useState(null); + // A typed address nobody listed has: looked up on its own, as the + // Delegates dialog does (webmail delegate-dialog.tsx). The listing can + // miss people the server still finds by address. + const [lookup, setLookup] = React.useState({ state: 'idle' }); React.useEffect(() => { if (!collection) return undefined; let cancelled = false; setShares(collection.shareWith ?? {}); setSearch(''); + setLookup({ state: 'idle' }); setLoading(true); void listPrincipals().then(({ principals: list, ...listing }) => { if (cancelled) return; @@ -138,6 +145,29 @@ export function ShareCollectionSheet({ } }; + const typedAddress = search.trim(); + // Anyone listed with that address is a candidate, has access or is the user. + const offerLookup = !loading && candidates.length === 0 && canLookUpAddress(search, principals); + + const lookUp = async () => { + if (!offerLookup || lookup.state === 'busy' || savingId) return; + setLookup({ state: 'busy' }); + const found = await findPrincipalByEmail(typedAddress); + let ownIds: string[] = []; + try { + ownIds = [ownPrincipalId()]; + } catch { + // No session yet: the server's answer still says who it is. + } + const result = lookupResult(typedAddress, found, ownIds); + setLookup(result.lookup); + const picked = result.picked; + if (!picked) return; + setPrincipals((prev) => (prev.some((p) => p.id === picked.id) ? prev : [...prev, picked])); + if (shares[picked.id] == null) await applyShare(picked.id, addRights); + setSearch(''); + }; + if (!collection) return null; // The list below is partial or empty for a reason the user can't see @@ -164,8 +194,10 @@ export function ShareCollectionSheet({ {p?.description || p?.name || (unresolved ? t('sharing.unknown_user', 'Unknown user') : principalId)} - {p?.email || unresolved ? ( - {p?.email || principalId} + {/* Never the id in its place: Stalwart 1.0's are a letter or two + ("e") and read as a broken address. */} + {p?.email ? ( + {p.email} ) : null} ); @@ -238,7 +270,10 @@ export function ShareCollectionSheet({ { + setSearch(text); + setLookup({ state: 'idle' }); + }} placeholder={t('calendar.share.search_placeholder', 'Search by name or email')} placeholderTextColor={c.textMuted} autoCapitalize="none" @@ -250,6 +285,30 @@ export function ShareCollectionSheet({ ) : null} {loading ? ( + ) : offerLookup ? ( + + void lookUp()} + disabled={lookup.state === 'busy' || !!savingId} + style={({ pressed }) => [styles.candidateRow, pressed && styles.rowPressed]} + accessibilityRole="button" + accessibilityState={{ busy: lookup.state === 'busy' }} + > + + {lookup.state === 'busy' + ? t('delegates.looking_up', 'Looking up…') + : t('delegates.use_address', 'Use {address}', { address: typedAddress })} + + {lookup.state === 'busy' || savingId ? ( + + ) : ( + + )} + + {lookupMessage(lookup, t) ? ( + {lookupMessage(lookup, t)} + ) : null} + ) : candidates.length === 0 ? ( notice && !search.trim() ? null : ( diff --git a/src/components/sharing/__tests__/share-sheet-lookup.test.ts b/src/components/sharing/__tests__/share-sheet-lookup.test.ts new file mode 100644 index 00000000..3f89098d --- /dev/null +++ b/src/components/sharing/__tests__/share-sheet-lookup.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from 'vitest'; +import fs from 'node:fs'; +import path from 'node:path'; +import { translate } from '../../../i18n'; +import { canLookUpAddress, lookupMessage, lookupResult } from '../../../lib/delegation/display'; +import type { Principal } from '../../../api/types'; + +const tEn = (key: string, fallback?: string, params?: Record) => translate('en', key, fallback, params); +const sheet = fs.readFileSync(path.resolve(__dirname, '../ShareCollectionSheet.tsx'), 'utf8'); + +describe('sharing a calendar or address book with a typed address', () => { + it('offers to look the address up when nobody listed has it', () => { + // Stalwart 1.0 with directory queries off lists only the user and their groups. + const listed: Principal[] = [{ id: 'd', type: 'individual', name: 'usera@example.org', email: 'usera@example.org' }]; + expect(canLookUpAddress('userc@example.org', listed)).toBe(true); + expect(canLookUpAddress('usera@example.org', listed)).toBe(false); + expect(canLookUpAddress('userc', listed)).toBe(false); + }); + + it('says why the server could not find the address instead of "No matches"', () => { + const off = lookupResult('userc@example.org', { principal: null, status: 'forbidden' }, ['d']); + expect(off.picked).toBeNull(); + expect(lookupMessage(off.lookup, tEn)).toMatch(/administrator has to turn on directory lookups/); + const missing = lookupResult('nobody@example.org', { principal: null, status: 'ok' }, ['d']); + expect(lookupMessage(missing.lookup, tEn)).toBe('Nobody on this server has the address nobody@example.org.'); + expect(lookupMessage({ state: 'idle' }, tEn)).toBeNull(); + }); + + it('is wired into the share sheet: looked up on its own and shared once found', () => { + expect(sheet).toMatch(/canLookUpAddress\(search, principals\)/); + expect(sheet).toMatch(/await findPrincipalByEmail\(typedAddress\)/); + expect(sheet).toMatch(/if \(shares\[picked\.id\] == null\) await applyShare\(picked\.id, addRights\);/); + expect(sheet).toMatch(/lookupMessage\(lookup, t\)/); + }); +}); diff --git a/src/lib/__tests__/address-book-label.test.ts b/src/lib/__tests__/address-book-label.test.ts new file mode 100644 index 00000000..6cce3440 --- /dev/null +++ b/src/lib/__tests__/address-book-label.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from 'vitest'; +import fs from 'node:fs'; +import path from 'node:path'; +import { addressBookLabel } from '../address-book-label'; + +describe('addressBookLabel', () => { + it('names a shared book once when Stalwart already put the account in its name', () => { + // As Stalwart 0.16 and 1.0 name a group's default book. + expect(addressBookLabel({ name: 'Stalwart Address Book (team@example.org)', isShared: true, accountName: 'team@example.org' })) + .toBe('Stalwart Address Book (team@example.org)'); + }); + + it('adds the account to a shared book whose name does not say where it is from', () => { + expect(addressBookLabel({ name: 'Clients', isShared: true, accountName: 'team@example.org' })).toBe('Clients (team@example.org)'); + expect(addressBookLabel({ name: 'Clients', isShared: false, accountName: 'usera@example.org' })).toBe('Clients'); + }); + + it('is what the contact form shows', () => { + const form = fs.readFileSync(path.resolve(__dirname, '../../screens/ContactFormScreen.tsx'), 'utf8'); + expect(form).toMatch(/\{addressBookLabel\(book\)\}/); + expect(form).not.toMatch(/\$\{book\.name\} \(\$\{book\.accountName\}\)/); + }); +}); diff --git a/src/lib/__tests__/calendar-a11y.test.ts b/src/lib/__tests__/calendar-a11y.test.ts index 413a9156..23f25d68 100644 --- a/src/lib/__tests__/calendar-a11y.test.ts +++ b/src/lib/__tests__/calendar-a11y.test.ts @@ -7,7 +7,12 @@ vi.hoisted(() => { import { enUS } from 'date-fns/locale'; import type { CalendarEvent } from '../../api/types'; import { formatMessage } from '../../i18n/format'; -import { dayCellAccessibilityLabel, eventAccessibilityLabel } from '../calendar-a11y'; +import { + dayCellAccessibilityLabel, + eventAccessibilityLabel, + monthButtonAccessibilityLabel, + participantRemoveAccessibilityLabel, +} from '../calendar-a11y'; import { useSettingsStore } from '../../stores/settings-store'; const t = (_key: string, fallback?: string, params?: Record) => @@ -46,3 +51,24 @@ describe('eventAccessibilityLabel', () => { .toMatch(/^\(No title\), Monday, Nov(ember)? 2, 2026, 9:00 AM – 9:15 AM, Cancelled$/); }); }); + +describe('header and chip buttons', () => { + it('names the month button by the month it shows, not "Click to change month"', () => { + expect(monthButtonAccessibilityLabel('October 2026', t)).toBe('October 2026, change month'); + }); + + it('names the cross on a participant chip by the person it removes', () => { + expect(participantRemoveAccessibilityLabel({ name: 'Ana Lima', email: 'ana@example.com' }, t)).toBe('Remove Ana Lima'); + expect(participantRemoveAccessibilityLabel({ name: '', email: 'ana@example.com' }, t)).toBe('Remove ana@example.com'); + }); + + it('is what the calendar header and the participant chips use', async () => { + const fs = await import('node:fs'); + const path = await import('node:path'); + const screen = fs.readFileSync(path.resolve(__dirname, '../../screens/CalendarScreen.tsx'), 'utf8'); + const chips = fs.readFileSync(path.resolve(__dirname, '../../components/calendar/ParticipantInput.tsx'), 'utf8'); + expect(screen).toMatch(/accessibilityLabel=\{monthButtonAccessibilityLabel\(/); + expect(screen).not.toMatch(/mini_calendar_change/); + expect(chips).toMatch(/accessibilityLabel=\{participantRemoveAccessibilityLabel\(a, t\)\}/); + }); +}); diff --git a/src/lib/__tests__/calendar-custom-color.test.ts b/src/lib/__tests__/calendar-custom-color.test.ts index e8f534bb..0a41bbba 100644 --- a/src/lib/__tests__/calendar-custom-color.test.ts +++ b/src/lib/__tests__/calendar-custom-color.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { customColorGrid, normalizeHexColor } from '../calendar-custom-color'; +import { customColorGrid, HEX_FIELD_MAX_LENGTH, hexFieldText, normalizeHexColor } from '../calendar-custom-color'; describe('normalizeHexColor', () => { it('takes a typed hex colour in its usual spellings', () => { @@ -23,3 +23,31 @@ describe('customColorGrid', () => { expect(grid[1][0]).toBe('#c32222'); }); }); + +describe('the hex field', () => { + it('shows a colour typed without "#" with it once the user leaves the field', () => { + expect(hexFieldText('3b82f6')).toBe('#3b82f6'); + expect(hexFieldText('3B82F6')).toBe('#3b82f6'); + expect(hexFieldText('#3b82f6')).toBe('#3b82f6'); + expect(hexFieldText('3b8')).toBe('#33bb88'); + // Not a colour: left as typed for the hint under the field. + expect(hexFieldText('3b82f')).toBe('3b82f'); + }); + + it('takes a pasted colour with spaces around it', () => { + const pasted = ' #3b82f6 '; + expect(pasted.length).toBeLessThanOrEqual(HEX_FIELD_MAX_LENGTH); + expect(normalizeHexColor(pasted.slice(0, HEX_FIELD_MAX_LENGTH))).toBe('#3b82f6'); + }); +}); + +describe('the calendar sheet’s hex field', () => { + it('spells the colour out on blur and takes the longer pasted text', async () => { + const fs = await import('node:fs'); + const path = await import('node:path'); + const sheet = fs.readFileSync(path.resolve(__dirname, '../../components/calendar/CalendarEditSheet.tsx'), 'utf8'); + const field = sheet.slice(sheet.indexOf('value={hexInput}'), sheet.indexOf('/>', sheet.indexOf('value={hexInput}'))); + expect(field).toMatch(/onBlur=\{\(\) => setHexInput\(\(v\) => hexFieldText\(v\)\)\}/); + expect(field).toMatch(/maxLength=\{HEX_FIELD_MAX_LENGTH\}/); + }); +}); diff --git a/src/lib/address-book-label.ts b/src/lib/address-book-label.ts new file mode 100644 index 00000000..ace8430b --- /dev/null +++ b/src/lib/address-book-label.ts @@ -0,0 +1,13 @@ +import type { AddressBook } from '../api/types'; + +/** + * How an address book is named in a picker: a shared one with the account + * it comes from, unless its name already says so. Stalwart names a default + * book "Stalwart Address Book (team@example.org)", which came out as + * "... (team@example.org) (team@example.org)". Webmail contact-form.tsx. + */ +export function addressBookLabel(book: Pick): string { + const name = book.name ?? ''; + if (!book.isShared || !book.accountName || name.includes(book.accountName)) return name; + return `${name} (${book.accountName})`; +} diff --git a/src/lib/calendar-a11y.ts b/src/lib/calendar-a11y.ts index 0a55b64a..98cb478f 100644 --- a/src/lib/calendar-a11y.ts +++ b/src/lib/calendar-a11y.ts @@ -11,6 +11,20 @@ type Translate = (key: string, fallback?: string, params?: Record { diff --git a/src/lib/calendar-participants.ts b/src/lib/calendar-participants.ts index 063a2e2d..a6a48f31 100644 --- a/src/lib/calendar-participants.ts +++ b/src/lib/calendar-participants.ts @@ -269,6 +269,18 @@ export interface Attendee { email: string; } +/** + * `attendees` with `added` at the end, unless that address is invited + * already. Done/Enter on Android submits and then blurs the field, and both + * add what was typed: checking against the latest list (a state updater's + * `prev`) keeps the second add from inviting the same person twice. + */ +export function withAttendee(attendees: Attendee[], added: Attendee): Attendee[] { + const key = added.email.trim().toLowerCase(); + if (!key || attendees.some((a) => a.email.trim().toLowerCase() === key)) return attendees; + return [...attendees, added]; +} + type ParticipationStatus = Participant['participationStatus']; /** diff --git a/src/lib/delegation/__tests__/unknown-principal.test.ts b/src/lib/delegation/__tests__/unknown-principal.test.ts new file mode 100644 index 00000000..4159ce37 --- /dev/null +++ b/src/lib/delegation/__tests__/unknown-principal.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from 'vitest'; +import fs from 'node:fs'; +import path from 'node:path'; +import { translate } from '../../../i18n'; +import { principalRowDisplay, resolvePrincipal } from '../display'; + +const tEn = (key: string, fallback?: string, params?: Record) => translate('en', key, fallback, params); + +describe('someone with access the server does not name', () => { + // Stalwart 1.0 with directory queries off: Principal/get answers notFound + // for the sharee's id ("e") and the session does not list them. + it('reads as "Unknown person" without the id as an address', () => { + const principal = resolvePrincipal('e', { directory: [], remembered: new Map(), sessionAccounts: new Map() }); + expect(principal).toBeNull(); + expect(principalRowDisplay(principal, tEn)).toEqual({ name: 'Unknown person', detail: null }); + }); + + it('reads as the person when the directory names them (0.16)', () => { + const userB = { id: 'e', type: 'individual' as const, name: 'userb@example.org', email: 'userb@example.org', description: 'User B' }; + const principal = resolvePrincipal('e', { directory: [userB], remembered: new Map(), sessionAccounts: new Map() }); + expect(principalRowDisplay(principal, tEn)).toEqual({ name: 'userb@example.org', detail: 'User B' }); + }); + + it('gets no id for an address line in the calendar and address book share sheet', () => { + const sheet = fs.readFileSync(path.resolve(__dirname, '../../../components/sharing/ShareCollectionSheet.tsx'), 'utf8'); + const label = sheet.slice(sheet.indexOf('const renderPrincipalLabel'), sheet.indexOf('return (', sheet.indexOf('const renderPrincipalLabel')) + 900); + expect(label).not.toMatch(/p\?\.email \|\| principalId/); + expect(label).toMatch(/\{p\?\.email \? \(/); + }); +}); diff --git a/src/lib/delegation/display.ts b/src/lib/delegation/display.ts index 540063d3..b6f0fb46 100644 --- a/src/lib/delegation/display.ts +++ b/src/lib/delegation/display.ts @@ -17,6 +17,17 @@ export function principalDisplay(principal: Pick>, ): CalendarEvent[] { return collapseDuplicateInstances(instances, bases).map((instance) => { - if (!isServerRecurrenceInstance(instance) || !instance.recurrenceId) return instance; + if (!isServerRecurrenceInstance(instance)) return instance; const base = instance.baseEventId ? bases.get(instance.baseEventId) : undefined; if (!base) return instance; + // Stalwart 0.16 expands a one-off event to a synthetic id too, without + // a recurrenceId and without showWithoutTime, in the query's time zone. + if (!instance.recurrenceId) { + return base.showWithoutTime + ? { ...instance, showWithoutTime: true, timeZone: base.timeZone ?? null } + : instance; + } const hydrated: CalendarEvent = { ...instance, diff --git a/src/screens/CalendarScreen.tsx b/src/screens/CalendarScreen.tsx index 38eb0432..7b6a5177 100644 --- a/src/screens/CalendarScreen.tsx +++ b/src/screens/CalendarScreen.tsx @@ -11,6 +11,8 @@ import { useWindowDimensions, } from 'react-native'; import { SafeAreaView } from 'react-native-safe-area-context'; +import { useFocusEffect } from '@react-navigation/native'; +import { monthButtonAccessibilityLabel } from '../lib/calendar-a11y'; import { TASK_CHIP_ID_PREFIX, multiDayCount } from '../lib/calendar-time-grid'; import { Calendar as CalendarIcon, @@ -239,6 +241,15 @@ export default function CalendarScreen() { const [modalEvent, setModalEvent] = React.useState(null); const [modalDate, setModalDate] = React.useState(undefined); const [modalVisible, setModalVisible] = React.useState(false); + // The "New event" block a tap on an empty slot leaves: gone once the + // editor opens or closes, or the user leaves the tab. + const [slotDraftReset, setSlotDraftReset] = React.useState(0); + React.useEffect(() => { + setSlotDraftReset((n) => n + 1); + }, [modalVisible]); + useFocusEffect( + React.useCallback(() => () => setSlotDraftReset((n) => n + 1), []), + ); const [pendingAction, setPendingAction] = React.useState(null); const [sidebarVisible, setSidebarVisible] = React.useState(false); const [tasksVisible, setTasksVisible] = React.useState(false); @@ -1027,7 +1038,7 @@ export default function CalendarScreen() { style={styles.titleBtn} hitSlop={6} accessibilityRole="button" - accessibilityLabel={t('calendar.mini_calendar_change', 'Change month')} + accessibilityLabel={monthButtonAccessibilityLabel(headerTitle(titleDate, locale), t)} accessibilityState={{ expanded: monthPanelOpen }} > @@ -1177,6 +1188,7 @@ export default function CalendarScreen() { onSelectDate={viewMode === 'day' ? handleSelectDate : openDay} onSelectEvent={handleSelectEvent} onCreateAtTime={openCreate} + draftResetKey={slotDraftReset} /> )} {viewMode === 'week' && !freeScroll && ( diff --git a/src/screens/ContactFormScreen.tsx b/src/screens/ContactFormScreen.tsx index f8c151c8..50662f0a 100644 --- a/src/screens/ContactFormScreen.tsx +++ b/src/screens/ContactFormScreen.tsx @@ -30,6 +30,7 @@ import { spacing, radius, typography, componentSizes, type ThemePalette } from ' import { useColors } from '../theme/colors'; import { useLocaleStore, type TranslateFn } from '../stores/locale-store'; import { MODAL_ORIENTATIONS } from '../lib/modal-orientations'; +import { addressBookLabel } from '../lib/address-book-label'; type Nav = NativeStackNavigationProp; type Route = RouteProp; @@ -517,7 +518,7 @@ export default function ContactFormScreen() { style={[styles.pill, form.addressBookId === book.id && styles.pillActive]} > - {book.isShared && book.accountName ? `${book.name} (${book.accountName})` : book.name} + {addressBookLabel(book)} ))} diff --git a/src/widgets/__tests__/widget-open-explicit.test.ts b/src/widgets/__tests__/widget-open-explicit.test.ts new file mode 100644 index 00000000..3c99c863 --- /dev/null +++ b/src/widgets/__tests__/widget-open-explicit.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from 'vitest'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { open, links } from '../clicks'; + +const root = path.resolve(__dirname, '../../..'); +const patchFile = path.join(root, 'patches/react-native-android-widget+0.22.1.patch'); +const provider = 'node_modules/react-native-android-widget/android/src/main/java/com/reactnativeandroidwidget/RNWidgetProvider.java'; + +// The library's own openUri, as published: what the patch is applied to. +const UPSTREAM_OPEN_URI = [ + ' private void openUri(Context context, String uri) {', + ' try {', + ' Intent intent = new Intent(Intent.ACTION_VIEW, Uri.parse(uri));', + ' intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK);', + ' context.startActivity(intent);', + ' } catch (Exception e) {', + ' e.printStackTrace();', + ' }', + ' }', +]; + +describe('a tap that opens the app from a widget', () => { + it('goes through the library’s OPEN_URI, which the patch makes explicit', () => { + expect(open(links.inbox())).toEqual({ clickAction: 'OPEN_URI', clickActionData: { uri: 'bulwarkmobile://mail/folder/inbox' } }); + }); + + it('is sent to this app’s own package, so no chooser offers another build', async () => { + const { parsePatchFile } = await import(path.join(root, 'node_modules/patch-package/dist/patch/parse.js')); + const { executeEffects } = await import(path.join(root, 'node_modules/patch-package/dist/patch/apply.js')); + const effects = parsePatchFile(fs.readFileSync(patchFile, 'utf8')) + .filter((e: { path?: string }) => e.path?.endsWith('RNWidgetProvider.java')); + expect(effects).toHaveLength(1); + + // The published file around openUri, padded to its line numbers. + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'widget-patch-')); + const target = path.join(dir, provider); + fs.mkdirSync(path.dirname(target), { recursive: true }); + const lines = Array.from({ length: 131 }, () => '').concat(UPSTREAM_OPEN_URI, ['', '}']); + fs.writeFileSync(target, lines.join('\n')); + const cwd = process.cwd(); + try { + process.chdir(dir); + executeEffects(effects, { dryRun: false }); + } finally { + process.chdir(cwd); + } + const patched = fs.readFileSync(target, 'utf8'); + const openUri = patched.slice(patched.indexOf('private void openUri'), patched.indexOf('startActivity')); + expect(openUri).toMatch(/intent\.setPackage\(context\.getPackageName\(\)\);/); + fs.rmSync(dir, { recursive: true, force: true }); + }); +});