diff --git a/App/Resources/InfoPlist.xcstrings b/App/Resources/InfoPlist.xcstrings index 05c431e..1dea09a 100644 --- a/App/Resources/InfoPlist.xcstrings +++ b/App/Resources/InfoPlist.xcstrings @@ -32,7 +32,7 @@ "de": { "stringUnit": { "state": "translated", - "value": "Pickup verwendet Bluetooth, um sich mit Ihrem Android-Telefon zu verbinden und dessen Anrufe zu steuern." + "value": "Pickup verwendet Bluetooth, um sich mit deinem Android-Telefon zu verbinden und dessen Anrufe zu steuern." } } } @@ -50,7 +50,7 @@ "de": { "stringUnit": { "state": "translated", - "value": "Pickup sucht die Namen von Anrufern in Ihren Kontakten." + "value": "Pickup sucht die Namen von Anrufern in deinen Kontakten." } } } diff --git a/App/Resources/Localizable.xcstrings b/App/Resources/Localizable.xcstrings index 054d5bd..c3b3403 100644 --- a/App/Resources/Localizable.xcstrings +++ b/App/Resources/Localizable.xcstrings @@ -464,7 +464,7 @@ "de": { "stringUnit": { "state": "translated", - "value": "Pickup benötigt Ihre Bestätigung in den Systemeinstellungen unter „Allgemein“ > „Anmeldeobjekte“." + "value": "Pickup benötigt deine Bestätigung in den Systemeinstellungen unter „Allgemein“ > „Anmeldeobjekte“." } } } @@ -662,7 +662,7 @@ "de": { "stringUnit": { "state": "translated", - "value": "Das Anruf-Audio konnte auf diesem Mac nicht gestartet werden. Es bleibt auf Ihrem Telefon." + "value": "Das Anruf-Audio konnte auf diesem Mac nicht gestartet werden. Es bleibt auf deinem Telefon." } } } @@ -680,7 +680,7 @@ "de": { "stringUnit": { "state": "translated", - "value": "Das Anruf-Audio auf diesem Mac wurde unterbrochen. Es läuft wieder auf Ihrem Telefon." + "value": "Das Anruf-Audio auf diesem Mac wurde unterbrochen. Es läuft wieder auf deinem Telefon." } } } @@ -698,7 +698,7 @@ "de": { "stringUnit": { "state": "translated", - "value": "Das Anruf-Audio wurde auf Ihr Telefon verlegt." + "value": "Das Anruf-Audio wurde auf dein Telefon verlegt." } } } @@ -716,7 +716,7 @@ "de": { "stringUnit": { "state": "translated", - "value": "Ihr Telefon ist nicht verbunden." + "value": "Dein Telefon ist nicht verbunden." } } } @@ -770,7 +770,7 @@ "de": { "stringUnit": { "state": "translated", - "value": "Ihr Telefon unterstützt das nicht." + "value": "Dein Telefon unterstützt das nicht." } } } @@ -788,7 +788,7 @@ "de": { "stringUnit": { "state": "translated", - "value": "Ihr Telefon konnte diese Aktion nicht ausführen." + "value": "Dein Telefon konnte diese Aktion nicht ausführen." } } } @@ -806,7 +806,7 @@ "de": { "stringUnit": { "state": "translated", - "value": "Ihrem Telefon" + "value": "deinem Telefon" } } } @@ -932,7 +932,7 @@ "de": { "stringUnit": { "state": "translated", - "value": "Wählen Sie zuerst in den Einstellungen Ihr Telefon aus." + "value": "Wähle zuerst in den Einstellungen dein Telefon aus." } } } @@ -1148,7 +1148,7 @@ "de": { "stringUnit": { "state": "translated", - "value": "Koppeln Sie Ihr Telefon in den Systemeinstellungen unter „Bluetooth“ und wählen Sie es dann in den Einstellungen von Pickup aus." + "value": "Kopple dein Telefon in den Systemeinstellungen unter „Bluetooth“ und wähle es dann in den Einstellungen von Pickup aus." } } } @@ -1166,7 +1166,7 @@ "de": { "stringUnit": { "state": "translated", - "value": "Erlauben Sie Pickup unter „Datenschutz & Sicherheit“ > „Bluetooth“ den Zugriff, damit es mit Ihrem Telefon kommunizieren kann." + "value": "Erlaube Pickup unter „Datenschutz & Sicherheit“ > „Bluetooth“ den Zugriff, damit es mit deinem Telefon kommunizieren kann." } } } @@ -1184,7 +1184,7 @@ "de": { "stringUnit": { "state": "translated", - "value": "Stellen Sie sicher, dass Bluetooth eingeschaltet und das Telefon in der Nähe ist. Erlauben Sie diesem Mac auf Nachfrage den Zugriff auf Telefonanrufe (Pixel: Bluetooth-Einstellungen, „Telefonanrufe“)." + "value": "Stelle sicher, dass Bluetooth eingeschaltet und das Telefon in der Nähe ist. Erlaube diesem Mac auf Nachfrage den Zugriff auf Telefonanrufe (Pixel: Bluetooth-Einstellungen, „Telefonanrufe“)." } } } @@ -1328,7 +1328,7 @@ "de": { "stringUnit": { "state": "translated", - "value": "Ein Klick auf eine Telefonnummer in Safari, Kontakten oder anderen Apps lässt Pickup die Nummer dann auf Ihrem Telefon anrufen." + "value": "Ein Klick auf eine Telefonnummer in Safari, Kontakten oder anderen Apps lässt Pickup die Nummer dann auf deinem Telefon anrufen." } } } @@ -1346,7 +1346,7 @@ "de": { "stringUnit": { "state": "translated", - "value": "Anrufernamen stammen aus Ihren Kontakten." + "value": "Anrufernamen stammen aus deinen Kontakten." } } } @@ -1364,7 +1364,7 @@ "de": { "stringUnit": { "state": "translated", - "value": "Pickup kann Ihre Kontakte nicht lesen, daher erscheinen Anrufer mit ihrer Nummer." + "value": "Pickup kann deine Kontakte nicht lesen, daher erscheinen Anrufer mit ihrer Nummer." } } } @@ -1418,7 +1418,7 @@ "de": { "stringUnit": { "state": "translated", - "value": "Diese Nummer ist ein Dienstcode und muss von Ihnen bestätigt werden." + "value": "Diese Nummer ist ein Dienstcode und muss von dir bestätigt werden." } } } @@ -1436,7 +1436,7 @@ "de": { "stringUnit": { "state": "translated", - "value": "Dieser Link möchte einen Dienstcode (z. B. eine Rufumleitung) an Ihr Telefon senden." + "value": "Dieser Link möchte einen Dienstcode (z. B. eine Rufumleitung) an dein Telefon senden." } } } @@ -1454,7 +1454,7 @@ "de": { "stringUnit": { "state": "translated", - "value": "Ihr Telefon ist nicht erreichbar" + "value": "Dein Telefon ist nicht erreichbar" } } } @@ -1472,7 +1472,7 @@ "de": { "stringUnit": { "state": "translated", - "value": "Warte auf die Verbindung zu Ihrem Telefon …" + "value": "Warte auf die Verbindung zu deinem Telefon …" } } } @@ -1562,7 +1562,7 @@ "de": { "stringUnit": { "state": "translated", - "value": "Telefonnummern-Links in Safari und anderen Apps öffnen noch eine andere App. Machen Sie Pickup zur Standard-App, damit diese Anrufe über Ihr Telefon geführt werden." + "value": "Telefonnummern-Links in Safari und anderen Apps öffnen noch eine andere App. Mach Pickup zur Standard-App, damit diese Anrufe über dein Telefon geführt werden." } } } @@ -1616,7 +1616,7 @@ "de": { "stringUnit": { "state": "translated", - "value": "Falls macOS die Änderung nicht übernimmt, öffnen Sie FaceTime, wählen Sie „Einstellungen“ und wählen Sie unter „Standard für Anrufe“ die App Pickup." + "value": "Falls macOS die Änderung nicht übernimmt, öffne FaceTime, wähle „Einstellungen“ und wähle unter „Standard für Anrufe“ die App Pickup." } } } @@ -1688,7 +1688,7 @@ "de": { "stringUnit": { "state": "translated", - "value": "Führen und beantworten Sie Anrufe Ihres Android-Telefons an Ihrem Mac." + "value": "Führe und beantworte Anrufe deines Android-Telefons an deinem Mac." } } } @@ -1742,7 +1742,7 @@ "de": { "stringUnit": { "state": "translated", - "value": "Verbinden Sie Ihr Telefon, um Anrufe zu starten." + "value": "Verbinde dein Telefon, um Anrufe zu starten." } } } @@ -1760,7 +1760,7 @@ "de": { "stringUnit": { "state": "translated", - "value": "Beenden Sie zuerst das aktuelle Gespräch." + "value": "Beende zuerst das aktuelle Gespräch." } } } @@ -1837,24 +1837,6 @@ } } }, - "Type a name or a number.": { - "comment": "Calm empty state of the popover when the search field is empty.", - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Type a name or a number." - } - }, - "de": { - "stringUnit": { - "state": "translated", - "value": "Geben Sie einen Namen oder eine Nummer ein." - } - } - } - }, "Allow access to search contacts": { "comment": "Button in the popover that asks for Contacts access so names can be searched.", "extractionState": "manual", @@ -2156,7 +2138,7 @@ "de": { "stringUnit": { "state": "translated", - "value": "Pickup zeigt Anrufernamen an und durchsucht beim Wählen Ihre Kontakte." + "value": "Pickup zeigt Anrufernamen an und durchsucht beim Wählen deine Kontakte." } } } @@ -2210,7 +2192,7 @@ "de": { "stringUnit": { "state": "translated", - "value": "Kein Telefon gefunden. Koppeln Sie Ihr Telefon zuerst in den Systemeinstellungen unter „Bluetooth“." + "value": "Kein Telefon gefunden. Kopple dein Telefon zuerst in den Systemeinstellungen unter „Bluetooth“." } } } @@ -2264,7 +2246,7 @@ "de": { "stringUnit": { "state": "translated", - "value": "Das ist ein Dienstcode (z. B. eine Rufumleitung). Drücken Sie erneut Return, um ihn an Ihr Telefon zu senden." + "value": "Das ist ein Dienstcode (z. B. eine Rufumleitung). Drücke erneut Return, um ihn an dein Telefon zu senden." } } } @@ -2282,7 +2264,7 @@ "de": { "stringUnit": { "state": "translated", - "value": "Noch nicht verfügbar. Vorerst steuert Pickup Ihre Anrufe vom Mac aus, während das Gespräch auf dem Telefon bleibt." + "value": "Noch nicht verfügbar. Vorerst steuert Pickup deine Anrufe vom Mac aus, während das Gespräch auf dem Telefon bleibt." } } } @@ -2466,6 +2448,1068 @@ } } } + }, + "Outgoing": { + "comment": "Tab of the recent calls in the popover: calls placed from the phone.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Outgoing" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Ausgehend" + } + } + } + }, + "Incoming": { + "comment": "Tab of the recent calls in the popover: answered incoming calls.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Incoming" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Eingehend" + } + } + } + }, + "Missed": { + "comment": "Tab of the recent calls in the popover: missed incoming calls.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Missed" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Verpasst" + } + } + } + }, + "Recent Calls": { + "comment": "VoiceOver label of the tabs above the recent calls in the popover.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Recent Calls" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Letzte Anrufe" + } + } + } + }, + "No calls": { + "comment": "Empty state of a recent calls tab in the popover.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "No calls" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Keine Anrufe" + } + } + } + }, + "Calls since Pickup started. To see your phone's call history, allow access to contacts and call history in the phone's Bluetooth settings.": { + "comment": "Footnote below the recent calls when the phone did not share its call history and Pickup shows its own list.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Calls since Pickup started. To see your phone's call history, allow access to contacts and call history in the phone's Bluetooth settings." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Anrufe seit dem Start von Pickup. Um die Anrufliste des Telefons zu sehen, erlaube in den Bluetooth-Einstellungen des Telefons den Zugriff auf Kontakte und Anrufliste." + } + } + } + }, + "%lld calls": { + "comment": "Recent calls row: number of consecutive calls with the same number merged into this row, e.g. \"3 calls\".", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "%lld calls" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "%lld Anrufe" + } + } + } + }, + "%@ tab, no calls": { + "comment": "VoiceOver announcement when the recent calls tab is switched from the keyboard and the tab is empty. %@ is the tab name, e.g. \"Missed\".", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "%@ tab, no calls" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Reiter „%@“, keine Anrufe" + } + } + } + }, + "%@ tab, %@": { + "comment": "VoiceOver announcement when the recent calls tab is switched from the keyboard. First %@ is the tab name, second the selected row, e.g. \"Jane Doe, selected\".", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "%1$@ tab, %2$@" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Reiter „%1$@“, %2$@" + } + } + } + }, + "%@ tab": { + "comment": "VoiceOver announcement when the recent calls tab is switched from the keyboard. %@ is the tab name, e.g. \"Missed\".", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "%@ tab" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Reiter „%@“" + } + } + } + }, + "%@, %@, selected": { + "comment": "VoiceOver announcement when a row of the popover's list is selected with the keyboard. First %@ is the name or number, second the details (number, time).", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "%1$@, %2$@, selected" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "%1$@, %2$@, ausgewählt" + } + } + } + }, + "%@, selected": { + "comment": "VoiceOver announcement when a row of the popover's list is selected with the keyboard. %@ is the name or number.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "%@, selected" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "%@, ausgewählt" + } + } + } + }, + "Redial": { + "comment": "Label of the keypad's call button while the field is empty: it puts the last dialed number in.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Redial" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Wahlwiederholung" + } + } + } + }, + "Use the left and right arrow keys or Command-1 to 3 to switch tabs.": { + "comment": "VoiceOver hint of the recent calls tabs in the popover.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Use the left and right arrow keys or Command-1 to 3 to switch tabs." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Reiter mit den Pfeiltasten links und rechts oder mit Befehlstaste-1 bis 3 wechseln." + } + } + } + }, + "Copy Number": { + "comment": "Context menu item of a row in the popover's list: copies the phone number.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Copy Number" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Nummer kopieren" + } + } + } + }, + "Search Results": { + "comment": "VoiceOver label of the list of contacts and numbers matching the popover's search field.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Search Results" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Suchergebnisse" + } + } + } + }, + "Number copied": { + "comment": "VoiceOver announcement after a phone number from the popover's list was copied with Command-C or the context menu.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Number copied" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Nummer kopiert" + } + } + } + }, + "Window width": { + "comment": "VoiceOver label of the invisible handle at the side of the popover that changes its width.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Window width" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Fensterbreite" + } + } + } + }, + "Call Back": { + "comment": "Button of the missed-call notification: calls the number back on the phone.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Call Back" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Zurückrufen" + } + } + } + }, + "%@, missed calls": { + "comment": "Accessibility label of the menu bar icon while it shows the red dot for missed calls. %@ is the app name.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "%@, missed calls" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "%@, verpasste Anrufe" + } + } + } + }, + "%@, phone battery %@": { + "comment": "Accessibility label of the menu bar icon with the phone's battery next to it. First %@ is the app name (maybe with \"missed calls\"), second the percentage, e.g. \"60 %\".", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "%1$@, phone battery %2$@" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "%1$@, Akku des Telefons %2$@" + } + } + } + }, + "Phone battery": { + "comment": "Settings: where the phone's battery charge is shown. Also the VoiceOver label of the battery gauge in the popover.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Phone battery" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Akku des Telefons" + } + } + } + }, + "In the menu window": { + "comment": "Settings option for the phone's battery: gauge and percentage in the popover's status line.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "In the menu window" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Im Menüfenster" + } + } + } + }, + "In the menu bar": { + "comment": "Settings option for the phone's battery: percentage next to Pickup's menu bar icon.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "In the menu bar" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "In der Menüleiste" + } + } + } + }, + "Don’t show": { + "comment": "Settings option for the phone's battery: hidden.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Don’t show" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Nicht anzeigen" + } + } + } + }, + "Your phone reports its charge in steps of 20 percent.": { + "comment": "Tooltip of the phone battery setting.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Your phone reports its charge in steps of 20 percent." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Das Telefon meldet seinen Ladestand in Schritten von 20 Prozent." + } + } + } + }, + "The contact could not be handed to your contacts app.": { + "comment": "Inline error when Pickup could not open the new contact card in the contacts app.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "The contact could not be handed to your contacts app." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Der Kontakt konnte nicht an deine Kontakte-App übergeben werden." + } + } + } + }, + "Add to %@…": { + "comment": "Context menu item of a recent call or typed number without a contact: opens the contacts app (e.g. Contacts or Cardhop) with a new contact. %@ is the app name.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Add to %@…" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Zu %@ hinzufügen …" + } + } + } + }, + "Add to Contacts…": { + "comment": "Context menu item of a recent call or typed number without a contact, when the name of the contacts app is unknown.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Add to Contacts…" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Zu Kontakten hinzufügen …" + } + } + } + }, + "Hold": { + "comment": "Button in the call window that puts the call on hold.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Hold" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Halten" + } + } + } + }, + "Resume": { + "comment": "Button in the call window that takes the call off hold.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Resume" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Fortsetzen" + } + } + } + }, + "Swap": { + "comment": "Button in the call window that swaps between the active call and the call on hold.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Swap" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Makeln" + } + } + } + }, + "Swap to %@": { + "comment": "VoiceOver label of the button that swaps to the call on hold. %@ is the caller's name or number.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Swap to %@" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Zu %@ wechseln" + } + } + } + }, + "End call with %@": { + "comment": "VoiceOver label of the button that ends the call on hold. %@ is the caller's name or number.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "End call with %@" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Anruf mit %@ beenden" + } + } + } + }, + "Hold & Accept": { + "comment": "Button in the call window when a second call comes in: puts the current call on hold and answers the new one.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Hold & Accept" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Halten und annehmen" + } + } + } + }, + "End & Accept": { + "comment": "Button in the call window when a second call comes in: ends the current call and answers the new one.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "End & Accept" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Beenden und annehmen" + } + } + } + }, + "Call waiting": { + "comment": "Status of a second incoming call in the call window (call waiting).", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Call waiting" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Anklopfen" + } + } + } + }, + "Done": { + "comment": "Button that closes the welcome window.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Done" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Fertig" + } + } + } + }, + "Bluetooth": { + "comment": "Welcome window: row title of the Bluetooth permission.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Bluetooth" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Bluetooth" + } + } + } + }, + "Notifications": { + "comment": "Welcome window: row title of the notifications permission.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Notifications" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Mitteilungen" + } + } + } + }, + "Allow": { + "comment": "Welcome window: button that asks macOS for a permission.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Allow" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Erlauben" + } + } + } + }, + "Open Settings…": { + "comment": "Welcome window: button that opens the matching privacy pane in System Settings.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Open Settings…" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Einstellungen öffnen …" + } + } + } + }, + "Welcome to Pickup": { + "comment": "Title of the welcome window (window title, read by VoiceOver).", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Welcome to Pickup" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Willkommen bei Pickup" + } + } + } + }, + "Pickup was updated": { + "comment": "Title of the welcome window after an update of an unsigned build.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Pickup was updated" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Pickup wurde aktualisiert" + } + } + } + }, + "On this Mac": { + "comment": "Welcome window: section header for the permissions on the Mac.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "On this Mac" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Auf diesem Mac" + } + } + } + }, + "On your phone": { + "comment": "Welcome window: section header for the permissions on the Android phone.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "On your phone" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Auf deinem Telefon" + } + } + } + }, + "Phone calls and call history": { + "comment": "Welcome window: title of the row about the permissions on the Android phone.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Phone calls and call history" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Telefonanrufe und Anrufliste" + } + } + } + }, + "In the phone’s Bluetooth settings, open this Mac and allow “Phone calls” and “Contacts and call history”. Without the call history, Pickup lists only the calls since it started.": { + "comment": "Welcome window: what to allow on the Android phone (Pixel wording: Phone calls, Contacts and call history).", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "In the phone’s Bluetooth settings, open this Mac and allow “Phone calls” and “Contacts and call history”. Without the call history, Pickup lists only the calls since it started." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Öffne in den Bluetooth-Einstellungen des Telefons diesen Mac und erlaube „Telefonanrufe“ und „Kontakte und Anrufliste“. Ohne die Anrufliste zeigt Pickup nur die Anrufe seit dem Start." + } + } + } + }, + "Pickup isn’t signed by Apple yet, so macOS treats every update like a new app and may ask for its permissions again. Here you see them all at a glance.": { + "comment": "Welcome window text after an update of a build without an Apple Developer ID signature.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Pickup isn’t signed by Apple yet, so macOS treats every update like a new app and may ask for its permissions again. Here you see them all at a glance." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Pickup ist noch nicht von Apple signiert. macOS behandelt deshalb jedes Update wie eine neue App und fragt die Freigaben eventuell erneut ab. Hier siehst du alle auf einen Blick." + } + } + } + }, + "Pickup isn’t signed by Apple yet. After an update, macOS may ask again; this window then helps you.": { + "comment": "Small note at the bottom of the welcome window for builds without an Apple Developer ID signature.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Pickup isn’t signed by Apple yet. After an update, macOS may ask again; this window then helps you." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Pickup ist noch nicht von Apple signiert. Nach einem Update fragt macOS eventuell erneut; dann hilft dir dieses Fenster." + } + } + } + }, + "Pair it in System Settings › Bluetooth first, then choose it in Pickup.": { + "comment": "Welcome window: how to set up the phone.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Pair it in System Settings › Bluetooth first, then choose it in Pickup." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Kopple es zuerst in den Systemeinstellungen › Bluetooth und wähle es dann in Pickup aus." + } + } + } + }, + "Needed to connect to your phone.": { + "comment": "Welcome window: why Pickup needs Bluetooth.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Needed to connect to your phone." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Nötig für die Verbindung zu deinem Telefon." + } + } + } + }, + "Asked when connecting": { + "comment": "Welcome window: macOS asks for Bluetooth the first time Pickup connects to the phone.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Asked when connecting" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Wird beim Verbinden abgefragt" + } + } + } + }, + "Shows names and photos and lets you search your contacts. Optional.": { + "comment": "Welcome window: why Pickup asks for Contacts access.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Shows names and photos and lets you search your contacts. Optional." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Zeigt Namen und Fotos und ermöglicht die Suche in deinen Kontakten. Optional." + } + } + } + }, + "Tells you about missed calls, with “Call Back”. Optional.": { + "comment": "Welcome window: why Pickup asks to send notifications.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Tells you about missed calls, with “Call Back”. Optional." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Meldet verpasste Anrufe, mit „Zurückrufen“. Optional." + } + } + } + }, + "Welcome to Pickup!": { + "comment": "Headline of the welcome window at the first launch.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Welcome to Pickup!" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Willkommen bei Pickup!" + } + } + } + }, + "Loading recent calls": { + "comment": "VoiceOver label of the placeholder rows shown while the recent calls are read from the phone.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Loading recent calls" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Letzte Anrufe werden geladen" + } + } + } + }, + "Recent calls aren’t available right now.": { + "comment": "Shown instead of the recent calls when the phone has not sent them within 10 seconds or did not answer.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Recent calls aren’t available right now." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Die letzten Anrufe sind gerade nicht verfügbar." + } + } + } + }, + "Pickup controls the incoming and outgoing calls of your Android phone, much like Continuity does for the iPhone. The conversation stays on your phone. Check the permissions below once, then you’re ready.": { + "comment": "Welcome window text at the first launch. Continuity is Apple’s name for the iPhone feature; keep it.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Pickup controls the incoming and outgoing calls of your Android phone, much like Continuity does for the iPhone. The conversation stays on your phone. Check the permissions below once, then you’re ready." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Mit Pickup steuerst du deine ein- und ausgehenden Anrufe deines Android-Smartphones, ähnlich wie das Apple beim iPhone mit Continuity macht. Das Gespräch selbst bleibt dabei aber auf deinem Telefon. Bitte prüfe einmal die Freigaben unten, dann kann es losgehen." + } + } + } } }, "version": "1.0" diff --git a/App/Sources/Pickup/AppInfo.swift b/App/Sources/Pickup/AppInfo.swift index 9b2bc63..b64de1c 100644 --- a/App/Sources/Pickup/AppInfo.swift +++ b/App/Sources/Pickup/AppInfo.swift @@ -2,9 +2,10 @@ import Foundation /// Static facts about the app shown in the About panel. enum AppInfo { - /// Public source repository. `nil` (or a placeholder) hides the link in the About panel. - /// Set it once the public `pickup` repository exists. - static let repositoryURL: String? = nil + /// Public source repository, linked in the About panel. `nil` hides the link. + static let repositoryURL: String? = "https://github.com/macx/pickup" + /// The author's website, linked in the About panel. `nil` hides the link. + static let websiteURL: String? = "https://davideiken.de" static var version: String { Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "0" diff --git a/App/Sources/Pickup/AppModel.swift b/App/Sources/Pickup/AppModel.swift index c963710..491db73 100644 --- a/App/Sources/Pickup/AppModel.swift +++ b/App/Sources/Pickup/AppModel.swift @@ -46,6 +46,17 @@ final class AppModel { private(set) var notice: String? private(set) var selectedAddress: String? private(set) var selectedName: String? + /// Recent calls for the dialer: the phone's call lists, or the calls seen since launch. + private(set) var callHistory = CallHistory() + /// A call was missed since the user last looked at the recent calls: red dot on the menu bar icon. + /// Saved, so the dot survives a relaunch. + private(set) var hasUnseenMissedCalls = false + /// The phone's battery level, 0...5 (HFP `battchg`); nil when not connected or not reported. + private(set) var phoneBattery: Int? + /// Where the battery is shown (Settings). + var batteryDisplay: BatteryDisplay = .popover { + didSet { if !TestMode.isActive { UserDefaults.standard.set(batteryDisplay.rawValue, forKey: BatteryDisplay.defaultsKey) } } + } let callUI = CallUIModel() let contacts = ContactsDirectory() @@ -61,6 +72,7 @@ final class AppModel { static let addressKey = "phoneAddress" static let nameKey = "phoneName" + static let unseenMissedCallsKey = "unseenMissedCalls" /// Launch argument `-PickupDemo `: static preview data, no Bluetooth. let isDemo = UserDefaults.standard.string(forKey: "PickupDemo") != nil @@ -86,11 +98,15 @@ final class AppModel { let defaults = UserDefaults.standard selectedAddress = defaults.string(forKey: Self.addressKey) selectedName = defaults.string(forKey: Self.nameKey) + hasUnseenMissedCalls = defaults.bool(forKey: Self.unseenMissedCallsKey) + batteryDisplay = defaults.string(forKey: BatteryDisplay.defaultsKey).flatMap(BatteryDisplay.init) ?? .popover } // MARK: Lifecycle - func start() { + /// `askForPermissions`: ask for Contacts and notifications shortly after launch (false while the + /// welcome window offers them). + func start(askForPermissions: Bool = true) { _ = panels if !TestMode.isActive { MissedCallNotifier.shared.install() } contacts.reload() @@ -110,7 +126,7 @@ final class AppModel { if TestMode.isActive { startTestScenario(); return } #endif if isDemo { apply(); return } - requestPermissionsAfterLaunch() + if askForPermissions { requestPermissionsAfterLaunch() } if let address = selectedAddress { makeEngine(address: address) } else { apply() } } @@ -129,6 +145,9 @@ final class AppModel { func reconnect() { engine?.reconnect() } + /// The dialer opened: read the phone's call lists again (throttled by the engine). + func refreshCallHistory() { engine?.refreshCallHistory() } + /// App termination: release the transport cleanly. func shutdown() { teardown() } @@ -200,6 +219,7 @@ final class AppModel { guard let engine else { set(\.connection, selectedAddress == nil ? .noPhone : .disconnected(phoneName: selectedName ?? fallbackPhoneName)) set(\.callIsLive, false) + set(\.phoneBattery, isDemo ? 3 : nil) set(\.guidance, bluetoothDenied ? .bluetoothDenied : (selectedAddress == nil ? .choosePhone : linkHint)) if !isDemo { setUI(incoming: nil, active: nil) } return @@ -216,6 +236,8 @@ final class AppModel { engine.nextReconnectAttempt != nil ? .reconnecting(phoneName: phoneName) : .disconnected(phoneName: phoneName) } set(\.connection, status) + set(\.callHistory, engine.callHistory) + set(\.phoneBattery, link == .connected ? engine.phoneStatus.battery : nil) trackStuckLink(connected: link == .connected) set(\.guidance, bluetoothDenied ? .bluetoothDenied : (showCannotConnect && link != .connected ? .cannotConnect(phoneName: engine.deviceName ?? selectedName) : linkHint)) @@ -274,7 +296,9 @@ final class AppModel { ActiveCallInfo( party: party, phase: phase, startedAt: call.connectedAt, phoneName: phoneName, audioOnMac: engine.isAudioOnMacEnabled && engine.audioRoute == .mac, isMuted: false, - waiting: engine.waitingCall.map { self.party(for: $0.callerID) }, notice: notice) + waiting: engine.waitingCall.map { self.party(for: $0.callerID) }, + held: engine.heldCall.map { self.party(for: $0.callerID) }, + canHold: engine.supportsCallHold, holdPending: engine.isCallHoldPending, notice: notice) } private func party(for callerID: CallerID) -> CallParty { @@ -346,7 +370,13 @@ final class AppModel { guard let digit = DTMFDigit(digit) else { return } self?.run { try $0.sendDTMF(digit) } }, - moveToPhone: { [weak self] in self?.run { try $0.transferAudioToPhone() } }) + moveToPhone: { [weak self] in self?.run { try $0.transferAudioToPhone() } }, + toggleHold: { [weak self] in self?.run { try $0.toggleHold() } }, + swapCalls: { [weak self] in self?.run { try $0.swapCalls() } }, + acceptWaitingHolding: { [weak self] in self?.run { try $0.acceptWaitingCall(endingCurrent: false) } }, + acceptWaitingEnding: { [weak self] in self?.run { try $0.acceptWaitingCall(endingCurrent: true) } }, + rejectWaiting: { [weak self] in self?.run { try $0.rejectWaitingCall() } }, + endHeld: { [weak self] in self?.run { try $0.endHeldCall() } }) } /// End Call in the HUD: the button shows progress until the phone confirms (usually 1–2 s). If the @@ -402,10 +432,27 @@ final class AppModel { // MARK: Missed calls private func callFinished(_ call: CallInfo) { - guard call.direction == .incoming, call.endReason == .missed else { return } + guard MissedCalls.isMissed(call) else { return } + setUnseenMissedCalls(true) let number = call.callerID.number let name = number.flatMap { contacts.match(for: $0)?.name } - MissedCallNotifier.shared.notifyMissed(name: name, number: number.map { PhoneNumberFormat.display($0) }) + MissedCallNotifier.shared.notifyMissed(name: name, number: number) + } + + /// The user opened the recent calls (the popover shows the missed calls then): the dot goes away. + func markMissedCallsSeen() { setUnseenMissedCalls(false) } + + private func setUnseenMissedCalls(_ unseen: Bool) { + guard hasUnseenMissedCalls != unseen else { return } + hasUnseenMissedCalls = unseen + if !TestMode.isActive { UserDefaults.standard.set(unseen, forKey: Self.unseenMissedCallsKey) } + } + + /// "Call Back" in a missed-call notification: an explicit user action, so it dials. A service code + /// from the phone is never dialed this way. + func callBack(_ number: String) { + guard let dialNumber = DialNumber(number), !dialNumber.isServiceCode else { return } + if let message = dial(dialNumber) { showNotice(message) } } // MARK: tel: / callto: links diff --git a/App/Sources/Pickup/CallHUDView.swift b/App/Sources/Pickup/CallHUDView.swift index a43524d..c03f8cd 100644 --- a/App/Sources/Pickup/CallHUDView.swift +++ b/App/Sources/Pickup/CallHUDView.swift @@ -39,12 +39,26 @@ struct CallHUDView: View { .multilineTextAlignment(.center) .fixedSize(horizontal: false, vertical: true) } + if showsHoldControl { + CircleControl(symbol: info.phase == .held ? "play.fill" : "pause.fill", + title: info.phase == .held ? "Resume" : "Hold", + style: info.phase == .held ? .toggled : .neutral, + identifier: "hud-hold", action: actions.toggleHold) + .disabled(info.holdPending) + } + if let held = info.held, info.phase == .active { + heldCallRow(held) + } if let waiting = info.waiting { - Text("Call waiting: \(waiting.primaryText ?? String(localized: "Unknown caller"))") - .font(.callout) - .foregroundStyle(.secondary) - .multilineTextAlignment(.center) - .fixedSize(horizontal: false, vertical: true) + if info.canHold { + waitingCallCard(waiting) + } else { + Text("Call waiting: \(waiting.primaryText ?? String(localized: "Unknown caller"))") + .font(.callout) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + .fixedSize(horizontal: false, vertical: true) + } } if let notice = info.notice { StatusMessage(text: notice) @@ -98,6 +112,80 @@ struct CallHUDView: View { .accessibilityIdentifier("call-hud") } + /// Hold / Resume for a single call (with two calls, the second call's row offers Swap instead). + private var showsHoldControl: Bool { + info.canHold && info.held == nil && info.waiting == nil && (info.phase == .active || info.phase == .held) + } + + /// The call on hold while another one is active: swap to it or end it. + private func heldCallRow(_ held: CallParty) -> some View { + let name = held.primaryText ?? String(localized: "Unknown caller") + // Without three-way calling (a second call set up on the phone) the row only informs. With a third, + // waiting caller, swapping would take that call instead, so it waits. + let canAct = info.canHold && !info.holdPending + return SecondCallRow(party: held, status: String(localized: "On hold")) { + if info.canHold { + Button(action: actions.swapCalls) { + Label("Swap", systemImage: "arrow.left.arrow.right") + } + .buttonStyle(.bordered) + .accessibilityLabel(Text("Swap to \(name)", comment: "VoiceOver label of the button that swaps to the call on hold. %@ is the caller's name or number.")) + .help(Text("Swap to \(name)")) + .accessibilityIdentifier("hud-swap") + .disabled(!canAct || info.waiting != nil) + Button(action: actions.endHeld) { + Image(systemName: "phone.down.fill") + .foregroundStyle(.white) + .frame(width: 28, height: 28) + .background(Color.red, in: Circle()) + .contentShape(Circle()) + } + .buttonStyle(PressableStyle()) + .accessibilityLabel(Text("End call with \(name)", comment: "VoiceOver label of the button that ends the call on hold. %@ is the caller's name or number.")) + .help(Text("End call with \(name)")) + .accessibilityIdentifier("hud-end-held") + .disabled(!canAct || info.waiting != nil) + } + } + } + + /// A second caller: take the call (holding or ending the current one) or decline it. + private func waitingCallCard(_ waiting: CallParty) -> some View { + let holdAndAccept = Button(action: actions.acceptWaitingHolding) { + Text("Hold & Accept", comment: "Button in the call window when a second call comes in: puts the current call on hold and answers the new one.") + .frame(maxWidth: .infinity) + } + .buttonStyle(.borderedProminent) + .tint(.callGreen) + .accessibilityIdentifier("hud-waiting-hold-accept") + let endAndAccept = Button(action: actions.acceptWaitingEnding) { + Text("End & Accept", comment: "Button in the call window when a second call comes in: ends the current call and answers the new one.") + .frame(maxWidth: .infinity) + } + .buttonStyle(.bordered) + .accessibilityIdentifier("hud-waiting-end-accept") + let decline = Button(action: actions.rejectWaiting) { + Text("Decline").frame(maxWidth: .infinity) + } + .buttonStyle(.bordered) + .tint(.red) + .accessibilityIdentifier("hud-waiting-decline") + // End & Accept needs an active call: a call on hold would stay (CHLD=1 ends active calls only). + let canEnd = info.phase == .active + return VStack(spacing: 8) { + SecondCallRow(party: waiting, status: String(localized: "Call waiting", comment: "Status of a second incoming call in the call window (call waiting).")) {} + if info.held == nil { holdAndAccept } + ViewThatFits(in: .horizontal) { + HStack(spacing: 8) { if canEnd { endAndAccept }; decline } + VStack(spacing: 8) { if canEnd { endAndAccept }; decline } + } + } + .disabled(info.holdPending) + .controlSize(.large) + .accessibilityElement(children: .contain) + .accessibilityIdentifier("hud-waiting") + } + @ViewBuilder private var statusLine: some View { switch info.phase { @@ -151,6 +239,31 @@ struct CallHUDView: View { } } +/// A second call (held or waiting) under the main call: avatar, name, status and its buttons. +private struct SecondCallRow: View { + let party: CallParty + let status: String + @ViewBuilder let actions: () -> Actions + + var body: some View { + HStack(spacing: 10) { + AvatarView(party: party, size: 32) + VStack(alignment: .leading, spacing: 1) { + Text(verbatim: party.primaryText ?? String(localized: "Unknown caller")) + .font(.callout.weight(.semibold)) + .lineLimit(1) + .truncationMode(.middle) + Text(verbatim: status).font(.caption).foregroundStyle(.secondary) + } + .accessibilityElement(children: .combine) + Spacer(minLength: 4) + actions() + } + .padding(8) + .background(.gray.opacity(0.2), in: RoundedRectangle(cornerRadius: 12, style: .continuous)) + } +} + struct KeypadView: View { let onDigit: (Character) -> Void /// The HUD keypad takes keyboard focus for typing digits; the dial pad leaves it to its text field. diff --git a/App/Sources/Pickup/CallUIModels.swift b/App/Sources/Pickup/CallUIModels.swift index fbc758c..26f6d92 100644 --- a/App/Sources/Pickup/CallUIModels.swift +++ b/App/Sources/Pickup/CallUIModels.swift @@ -53,6 +53,12 @@ struct ActiveCallInfo: Equatable, Sendable { var endDuration: String? = nil /// A second caller waiting on the line. var waiting: CallParty? = nil + /// A second call on hold while this one is active. + var held: CallParty? = nil + /// The phone supports holding, swapping and taking a waiting call. + var canHold = false + /// A hold/swap/waiting command is waiting for the phone: those buttons are disabled until it answers. + var holdPending = false var notice: String? = nil /// The user pressed End Call and the phone has not confirmed yet: the button shows progress. var isEnding = false @@ -67,6 +73,14 @@ struct CallActions { var toggleMute: () -> Void = {} var sendDigit: (Character) -> Void = { _ in } var moveToPhone: () -> Void = {} + var toggleHold: () -> Void = {} + var swapCalls: () -> Void = {} + /// Take the waiting call and hold the current one. + var acceptWaitingHolding: () -> Void = {} + /// Take the waiting call and end the current one. + var acceptWaitingEnding: () -> Void = {} + var rejectWaiting: () -> Void = {} + var endHeld: () -> Void = {} } enum ConnectionStatus: Equatable { diff --git a/App/Sources/Pickup/CallViewComponents.swift b/App/Sources/Pickup/CallViewComponents.swift index d71d965..711be3b 100644 --- a/App/Sources/Pickup/CallViewComponents.swift +++ b/App/Sources/Pickup/CallViewComponents.swift @@ -52,6 +52,39 @@ extension Color { /// symbols on them reach at least 3:1 (WCAG 1.4.11); the regular system green and orange give about 2:1. static let callGreen = Color(red: 0x24 / 255, green: 0x8A / 255, blue: 0x3D / 255) static let warningOrange = Color(red: 0xC9 / 255, green: 0x34 / 255, blue: 0x00 / 255) + + // Dialer list colors, light and dark. Measured against popover backgrounds from #E4E4E4 to white + // (light) and #1E1E1E to #363636 (dark), also on the selection fill: text reaches 4.5:1 (WCAG + // 1.4.3), the selection ring 3:1 (1.4.11). The system red and secondary label stay below that. + + /// Title of a missed call. + static let missedRed = adaptive(light: 0xA8221A, dark: 0xFFA39C) + /// Second line of a result or recent call row. + static let rowSubtitle = adaptive(light: 0x56565B, dark: 0xB4B4B9) + /// Outline of the selected row: the state is visible without relying on the faint fill. + static let selectionRing = adaptive(light: 0x0064E1, dark: 0x4DA3FF) + static let selectionFill = adaptive(light: 0x0064E1, dark: 0x4DA3FF, alpha: 0.15) + /// Placeholder shapes of the skeleton rows and the light sweeping over them. + static let skeleton = Color.primary.opacity(0.08) + /// White, stronger in light mode (on the light gray shapes) than in dark mode, like PrimeNG. + static let skeletonHighlight = Color(nsColor: NSColor(name: nil) { appearance in + let dark = appearance.bestMatch(from: [.aqua, .darkAqua]) == .darkAqua + return NSColor.white.withAlphaComponent(dark ? 0.10 : 0.5) + }) + /// Active recent calls tab: the system's selection color, derived from the user's accent color + /// (darker than the accent itself, so the white label reaches 4.5:1), with its label color. + static let activeSegment = Color(nsColor: .selectedContentBackgroundColor) + static let activeSegmentText = Color(nsColor: .alternateSelectedControlTextColor) + + static func adaptive(light: UInt32, dark: UInt32, alpha: CGFloat = 1) -> Color { + func color(_ hex: UInt32) -> NSColor { + NSColor(srgbRed: CGFloat((hex >> 16) & 0xFF) / 255, green: CGFloat((hex >> 8) & 0xFF) / 255, + blue: CGFloat(hex & 0xFF) / 255, alpha: alpha) + } + return Color(nsColor: NSColor(name: nil) { appearance in + appearance.bestMatch(from: [.aqua, .darkAqua]) == .darkAqua ? color(dark) : color(light) + }) + } } /// A warning or error line. The symbol carries the meaning; the text stays in the primary label color, diff --git a/App/Sources/Pickup/ContactCard.swift b/App/Sources/Pickup/ContactCard.swift new file mode 100644 index 0000000..b7f491f --- /dev/null +++ b/App/Sources/Pickup/ContactCard.swift @@ -0,0 +1,83 @@ +import AppKit +import UniformTypeIdentifiers + +/// "Add to Contacts…" for a number without a contact: a vCard handed to the app that opens vCards +/// on this Mac (Contacts, or e.g. Cardhop when the user made it the default). +enum ContactCard { + /// A vCard 3.0 with the number and, if the phone sent one, the name. Name and number come from the + /// phone (untrusted): both are escaped, so they cannot add lines or properties to the card. + static func vCard(name: String?, number: String) -> String { + let name = name?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + let (given, family) = split(name) + let lines = [ + "BEGIN:VCARD", + "VERSION:3.0", + "N:\(escape(family));\(escape(given));;;", + "FN:\(escape(name))", + "TEL;TYPE=VOICE:\(escape(number))", + "END:VCARD", + ] + return lines.joined(separator: "\r\n") + "\r\n" + } + + /// RFC 2426 text escaping; other control characters are dropped. + static func escape(_ text: String) -> String { + var result = "" + for character in text { + switch character { + case "\\": result += "\\\\" + case ",": result += "\\," + case ";": result += "\\;" + case "\n", "\r", "\r\n": result += "\\n" + default: + if character.unicodeScalars.allSatisfy({ !CharacterSet.controlCharacters.contains($0) }) { + result.append(character) + } + } + } + return result + } + + /// "Jane Doe" → ("Jane", "Doe"); a single word is the given name. + static func split(_ name: String) -> (given: String, family: String) { + guard let space = name.lastIndex(of: " ") else { return (name, "") } + return (String(name[.. Bool { + let folder = FileManager.default.temporaryDirectory.appendingPathComponent("Pickup-\(UUID().uuidString)", isDirectory: true) + let file = folder.appendingPathComponent(fileName(name: name, number: number)) + do { + try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true) + try Data(vCard(name: name, number: number).utf8).write(to: file, options: .atomic) + } catch { + return false + } + guard NSWorkspace.shared.open(file) else { + try? FileManager.default.removeItem(at: folder) + return false + } + Task { + try? await Task.sleep(for: .seconds(300)) + try? FileManager.default.removeItem(at: folder) + } + return true + } + + /// "Jane Doe.vcf"; the name (or number) without characters a file name cannot have. + static func fileName(name: String?, number: String) -> String { + let base = (name.flatMap { $0.isEmpty ? nil : $0 } ?? number) + .components(separatedBy: CharacterSet(charactersIn: "/:\\").union(.controlCharacters)).joined() + .trimmingCharacters(in: .whitespaces) + return (base.isEmpty ? "Contact" : String(base.prefix(80))) + ".vcf" + } +} diff --git a/App/Sources/Pickup/DialerRows.swift b/App/Sources/Pickup/DialerRows.swift index c38304a..cfdffec 100644 --- a/App/Sources/Pickup/DialerRows.swift +++ b/App/Sources/Pickup/DialerRows.swift @@ -6,6 +6,8 @@ struct DialerRow: Identifiable, Equatable { enum Kind: Equatable { case call(DialNumber) case contact(ContactSearchResult) + /// A recent call; `number` is nil when the entry has no dialable number (e.g. withheld). + case recent(CallHistoryEntry, number: DialNumber?, list: CallHistoryList) } var kind: Kind @@ -35,4 +37,43 @@ enum DialerRows { } return rows } + + /// Rows for the recent calls of one list. Numbers from the phone are untrusted: a service code + /// is never allowed directly and needs the second confirmation, like typed text. + static func recentRows(_ entries: [CallHistoryEntry], list: CallHistoryList) -> [DialerRow] { + entries.enumerated().map { index, entry in + let number = DialNumber(entry.number) + return DialerRow(kind: .recent(entry, number: number, list: list), id: "recent|\(list.rawValue)|\(index)", + allowServiceCode: false, requiresConfirmation: number?.isServiceCode ?? false) + } + } + + /// The number the keypad's call button puts into the empty field (redial): the newest dialed call. + static func redialNumber(in entries: [CallHistoryEntry]) -> String? { + entries.first { DialNumber($0.number) != nil }?.number + } +} + +extension DialerRow { + /// What ⌘C and "Copy Number" put on the pasteboard: the number as the row knows it; nil for a + /// withheld caller. + var copyableNumber: String? { + switch kind { + case .call(let number): number.value + case .contact(let result): result.number + case .recent(let entry, _, _): entry.number.isEmpty ? nil : entry.number + } + } +} + +/// Typing while the tabs or the list have keyboard focus goes on in the search field. +enum TypeAhead { + /// True for printable text; false for whitespace, control characters and the function keys' + /// private-use characters (arrows, Page Up, …). + static func accepts(_ characters: String) -> Bool { + guard !characters.isEmpty, !characters.allSatisfy(\.isWhitespace) else { return false } + return characters.unicodeScalars.allSatisfy { scalar in + !CharacterSet.controlCharacters.contains(scalar) && !(0xF700...0xF8FF).contains(scalar.value) + } + } } diff --git a/App/Sources/Pickup/DialerView.swift b/App/Sources/Pickup/DialerView.swift index 7ec9aa4..0d5787a 100644 --- a/App/Sources/Pickup/DialerView.swift +++ b/App/Sources/Pickup/DialerView.swift @@ -13,6 +13,8 @@ private enum DialerDemo { /// Top bar (keypad toggle + search capsule), guidance, then keypad or result list. struct DialerView: View { let model: AppModel + /// Width between the popover's side margins (the popover's width is set by the user). + let contentWidth: CGFloat let openSettings: () -> Void let close: () -> Void @@ -32,7 +34,17 @@ struct DialerView: View { @State private var pendingConfirmationID: String? /// The text exactly as the keypad produced it; any other edit (typing, paste) makes it untrusted. @State private var keypadText: String? = "" - @FocusState private var fieldFocused: Bool + /// Tab of the recent calls shown while the field is empty (remembered between launches). + @AppStorage("recentCallsList") private var recentListRaw = CallHistoryList.dialed.rawValue + /// The tab was changed from the keyboard while a row was selected: select the new tab's first row. + @State private var selectFirstRecentRow = false + /// The skeleton rows were shown for `skeletonLimit`: a message replaces them until the list arrives. + @State private var skeletonExpired = false + private static let skeletonLimit: Duration = .seconds(10) + /// Keyboard focus. Tab runs field → tabs → list in the system order (also without Full Keyboard + /// Access); the tabs and the list are one focus stop each. + private enum Focus: Hashable { case field, tabs, list } + @FocusState private var focus: Focus? @Environment(\.accessibilityReduceMotion) private var reduceMotion // MARK: Derived state @@ -51,8 +63,28 @@ struct DialerView: View { private var fromKeypad: Bool { text.isEmpty || text == keypadText } + private var recentList: CallHistoryList { CallHistoryList(rawValue: recentListRaw) ?? .dialed } + + /// Search results, or the recent calls of the selected tab while the field is empty. private var rows: [DialerRow] { - DialerRows.rows(for: text, contacts: model.contacts.search(text), fromKeypad: fromKeypad) + if queryIsEmpty { + // The keypad takes the recent calls' place: hidden rows can never be selected or dialed. + guard !showKeypad else { return [] } + return DialerRows.recentRows(model.callHistory.entries(recentList), list: recentList) + } + return DialerRows.rows(for: text, contacts: model.contacts.search(text), fromKeypad: fromKeypad) + } + + /// Number the keypad's call button puts into the empty field (redial). + private var redialNumber: String? { + DialerRows.redialNumber(in: model.callHistory.entries(.dialed)) + } + + /// The keypad's call button puts the last dialed number in instead of calling. + private var keypadCallsRedial: Bool { queryIsEmpty && redialNumber != nil } + + private var keypadCallEnabled: Bool { + model.canDial && (callRow != nil || (queryIsEmpty && redialNumber != nil)) } private var callRow: DialerRow? { @@ -61,7 +93,8 @@ struct DialerView: View { private var selectedIndex: Int { let rows = self.rows - return rows.firstIndex { $0.id == selectedID } ?? 0 + // Recent calls start without a selection, so a stray Return never redials. + return rows.firstIndex { $0.id == selectedID } ?? (queryIsEmpty ? -1 : 0) } private var showKeypad: Bool { @@ -85,6 +118,7 @@ struct DialerView: View { var body: some View { VStack(alignment: .leading, spacing: 12) { topBar + .frame(width: contentWidth) if let guidance = model.guidance { GuidanceCard(guidance: guidance, model: model, openSettings: openSettings) } @@ -94,23 +128,44 @@ struct DialerView: View { if let reason = unavailableReason, model.guidance == nil { Text(verbatim: reason).font(.caption).foregroundStyle(.secondary) } - if showKeypad { - keypad - .transition(reduceMotion ? .opacity : .opacity.combined(with: .move(edge: .top))) + if showKeypad || queryIsEmpty { + // Keypad and recent calls share one box of the keypad's height: toggling the keypad + // cross-fades in place, and loading the calls never resizes the popover. + ZStack(alignment: .top) { + if showKeypad { + keypad + .padding(.top, Self.keypadTopInset) + .transition(.opacity) + } + if queryIsEmpty && !showKeypad { + recentCalls.transition(.opacity) + } + } + .frame(width: contentWidth, height: Self.keypadHeight + Self.keypadTopInset, alignment: .top) } - if !queryIsEmpty || !showKeypad { + if !queryIsEmpty { content + .frame(width: contentWidth) } if let error { StatusMessage(text: error, kind: .error) } } - .padding([.horizontal, .top], 16) + .padding(.horizontal, Self.sideMargin) + .padding(.top, 16) .padding(.bottom, 12) .onAppear { focusFieldSoon() DialerKeyRouter.shared.move = { delta in moveSelection(delta) } DialerKeyRouter.shared.escape = { handleEscape() } + DialerKeyRouter.shared.switchTab = { delta in + guard let index = CallHistoryList.allCases.firstIndex(of: recentList) else { return false } + return selectTab(index + delta) + } + DialerKeyRouter.shared.selectTab = { index in selectTab(index) } + DialerKeyRouter.shared.submit = { submitSelectedRow() } + DialerKeyRouter.shared.copy = { copySelectedNumber() } + DialerKeyRouter.shared.typeText = { characters in typeIntoField(characters) } DialerKeyRouter.shared.physicalKey = { key, down in physicalKey(key, down: down) } } .onReceive(NotificationCenter.default.publisher(for: .pickupPopoverWillShow)) { _ in @@ -122,6 +177,11 @@ struct DialerView: View { error = nil selectedID = nil pendingConfirmationID = nil + if model.hasUnseenMissedCalls { + recentListRaw = CallHistoryList.missed.rawValue // the red dot led here + model.markMissedCallsSeen() + } + model.refreshCallHistory() focusFieldSoon() } .onReceive(NotificationCenter.default.publisher(for: .pickupPopoverDidShow)) { _ in focusFieldSoon() } @@ -132,14 +192,48 @@ struct DialerView: View { selectedID = nil pendingConfirmationID = nil } - .onChange(of: fieldFocused) { _, focused in if focused { collapseCaret() } } + .onChange(of: focus) { _, new in + switch new { + case .field: + collapseCaret() + case .list: + // Entering the list (Tab, ↓ from the tabs) selects its first row, so Return calls it. + let rows = self.rows + if !rows.contains(where: { $0.id == selectedID }), let first = rows.first { + selectedID = first.id + announceSelection(first) + } + case .tabs, nil: + if queryIsEmpty { selectedID = nil } // Return goes to the focused control now, not to a recent call + } + } + .onChange(of: showKeypad) { _, _ in + if queryIsEmpty { selectedID = nil; pendingConfirmationID = nil } + } + .onChange(of: model.callHistory.entries(recentList)) { old, new in + // Row IDs are positions: when the phone's list changes under a selected row, that row may now be + // another caller. Drop the selection (and a pending service-code confirmation) unless it is the same. + guard queryIsEmpty, let id = selectedID ?? pendingConfirmationID, id.hasPrefix("recent|"), + let index = id.split(separator: "|").last.flatMap({ Int($0) }) else { return } + if !old.indices.contains(index) || !new.indices.contains(index) || old[index] != new[index] { + selectedID = nil + pendingConfirmationID = nil + } + } + .onChange(of: recentListRaw) { _, _ in + pendingConfirmationID = nil + error = nil + let keepSelection = selectFirstRecentRow + selectFirstRecentRow = false + selectedID = keepSelection ? rows.first?.id : nil + } } /// The window is not key yet when the popover is about to open; focus the field once it is. private func focusFieldSoon() { Task { @MainActor in try? await Task.sleep(for: .milliseconds(200)) - fieldFocused = true + focus = .field collapseCaret() } } @@ -155,29 +249,83 @@ struct DialerView: View { } } - /// Esc: clear the field first; with an empty field the popover closes (return false). + /// Esc: back to the search field from the tabs, the list or a selected row first, then clear the + /// field; with an empty field the popover closes (return false). private func handleEscape() -> Bool { + if focus == .tabs || focus == .list || (queryIsEmpty && selectedID != nil) { + leaveList() + return true + } guard !text.isEmpty else { return false } text = "" keypadText = "" return true } + /// Switches the recent calls tab from the keyboard (only while the field is empty). A selected + /// row moves to the first row of the new tab; VoiceOver hears the tab and that row. + private func selectTab(_ index: Int) -> Bool { + guard queryIsEmpty else { return false } + let lists = CallHistoryList.allCases + let target = lists[min(max(index, 0), lists.count - 1)] + guard target != recentList else { return true } + let wasInList = rows.contains { $0.id == selectedID } + selectFirstRecentRow = wasInList + recentListRaw = target.rawValue + let newRows = DialerRows.recentRows(model.callHistory.entries(target), list: target) + + let detail = wasInList ? newRows.first.map(selectionText) : nil + let tabName = target.localizedTabName + announce(newRows.isEmpty + ? String(localized: "\(tabName) tab, no calls", comment: "VoiceOver announcement when the recent calls tab is switched from the keyboard and the tab is empty. %@ is the tab name, e.g. \"Missed\".") + : detail.map { String(localized: "\(tabName) tab, \($0)", comment: "VoiceOver announcement when the recent calls tab is switched from the keyboard. First %@ is the tab name, second the selected row, e.g. \"Jane Doe, selected\".") } + ?? String(localized: "\(tabName) tab", comment: "VoiceOver announcement when the recent calls tab is switched from the keyboard. %@ is the tab name, e.g. \"Missed\".")) + return true + } + + /// VoiceOver text for a selected row: name (or number) and details. + private func selectionText(_ row: DialerRow) -> String { + let display = row.display(contacts: model.contacts) + if let subtitle = display.subtitle { + return String(localized: "\(display.title), \(subtitle), selected", comment: "VoiceOver announcement when a row of the popover's list is selected with the keyboard. First %@ is the name or number, second the details (number, time).") + } + return String(localized: "\(display.title), selected", comment: "VoiceOver announcement when a row of the popover's list is selected with the keyboard. %@ is the name or number.") + } + + /// The text field or the list keeps keyboard focus while rows are selected, so VoiceOver is told explicitly. + private func announceSelection(_ row: DialerRow) { + announce(selectionText(row)) + } + + private func announce(_ text: String) { + AccessibilityNotification.Announcement(text).post() + } + + private func leaveList() { + selectedID = nil + pendingConfirmationID = nil + error = nil + focus = .field + collapseCaret() + } + // MARK: Top bar + /// No `GlassEffectContainer` here: the button and the capsule are exactly the container's blend + /// distance apart, so toggling the button made the glass merge and the capsule visibly shift. private var topBar: some View { - GlassEffectContainer(spacing: 8) { - HStack(spacing: 8) { + HStack(spacing: 8) { Button(action: toggleKeypad) { Image(systemName: "circle.grid.3x3.fill") .font(.system(size: 15, weight: .medium)) .foregroundStyle(showKeypad ? Color.white : Color.primary) .frame(width: 34, height: 34) - .glassEffect(showKeypad ? .regular.tint(.accentColor).interactive() : .regular.interactive(), - in: Circle()) + // One glass whose tint changes (no swap of glass variants), and no interactive + // press bounce: the button keeps its size and place when it is toggled. + .glassEffect(.regular.tint(showKeypad ? .accentColor : nil), in: Circle()) .contentShape(Circle()) } - .buttonStyle(.plain) + .buttonStyle(SteadyPressStyle()) // Like Apple's toolbar buttons: no focus ring, except for Full Keyboard Access users. .focusEffectDisabled(!NSApp.isFullKeyboardAccessEnabled) .accessibilityLabel(Text("Keypad")) @@ -191,12 +339,12 @@ struct DialerView: View { .accessibilityHidden(true) TextField("Search", text: $text) .textFieldStyle(.plain) - .focused($fieldFocused) + .focused($focus, equals: .field) .onSubmit(submitFromReturn) .accessibilityIdentifier("search-field") .accessibilityLabel(Text("Search contacts or enter a number", comment: "VoiceOver label of the popover's search field.")) if !text.isEmpty { - Button { text = ""; keypadText = ""; fieldFocused = true } label: { + Button { text = ""; keypadText = ""; focus = .field } label: { Image(systemName: "xmark.circle.fill").foregroundStyle(.secondary) } .buttonStyle(.plain) @@ -208,7 +356,6 @@ struct DialerView: View { .padding(.horizontal, 12) .frame(height: 34) .glassEffect(.regular, in: Capsule()) - } } } @@ -221,11 +368,24 @@ struct DialerView: View { } else { manuallyOpen = true } - fieldFocused = true + focus = .field } // MARK: Keypad + static let keySize: CGFloat = 60 + /// Margin on both sides. Every block of the dialer gets exactly `contentWidth`: a child with a + /// larger ideal width (the native segmented control in German, a legacy scroller) used to widen the + /// popover's content and shift it sideways. + static let sideMargin: CGFloat = 16 + static let keyColumnSpacing: CGFloat = 16 + static let keyRowSpacing: CGFloat = 12 + /// Height of the keypad (four rows of keys and the call button); the recent calls take the same. + static let keypadHeight: CGFloat = 5 * keySize + 4 * keyRowSpacing + static let callButtonSize: CGFloat = 28 + /// Extra room between the search bar and the first row of keys. + static let keypadTopInset: CGFloat = 10 + private static let keys: [[String]] = [["1", "2", "3"], ["4", "5", "6"], ["7", "8", "9"], ["*", "0", "#"]] /// One glass key. The tap inserts the digit; a long press on "0" inserts "+" instead. @@ -245,14 +405,12 @@ struct DialerView: View { .accessibilityIdentifier("key-plus") } } - .frame(width: 60, height: 60) - .glassEffect(.regular.interactive(), in: Circle()) - .overlay(Circle().fill(Color.primary.opacity(pressed ? 0.22 : 0))) - .scaleEffect(pressed ? 0.93 : 1) - .animation(reduceMotion ? nil : .easeOut(duration: 0.08), value: pressed) + .frame(width: Self.keySize, height: Self.keySize) + // Plain glass: the interactive variant stretches the key into an oval while it is pressed. + .glassEffect(.regular, in: Circle()) .contentShape(Circle()) } - .buttonStyle(.plain) + .buttonStyle(KeyPressStyle(highlighted: pressed, reduceMotion: reduceMotion)) .accessibilityLabel(Text(verbatim: key)) .accessibilityIdentifier(Self.identifier(forKey: key)) .modifier(PlusLongPress(enabled: plus != nil) { @@ -274,10 +432,10 @@ struct DialerView: View { } private var keypad: some View { - GlassEffectContainer(spacing: 12) { - VStack(spacing: 12) { + GlassEffectContainer(spacing: Self.keyRowSpacing) { + VStack(spacing: Self.keyRowSpacing) { ForEach(Self.keys, id: \.self) { row in - HStack(spacing: 16) { + HStack(spacing: Self.keyColumnSpacing) { ForEach(row, id: \.self) { key in keypadKey(key) } @@ -287,15 +445,15 @@ struct DialerView: View { Image(systemName: "phone.fill") .font(.system(size: 24)) .foregroundStyle(.white) - .frame(width: 60, height: 60) - .glassEffect(.regular.tint(.callGreen).interactive(), in: Circle()) + .frame(width: Self.keySize, height: Self.keySize) + .glassEffect(.regular.tint(.callGreen), in: Circle()) .contentShape(Circle()) } - .buttonStyle(.plain) - .disabled(callRow == nil || !model.canDial) - .opacity(callRow == nil || !model.canDial ? 0.45 : 1) - .accessibilityLabel(Text("Call")) - .help(Text("Call")) + .buttonStyle(KeyPressStyle(reduceMotion: reduceMotion)) + .disabled(!keypadCallEnabled) + .opacity(keypadCallEnabled ? 1 : 0.45) + .accessibilityLabel(keypadCallsRedial ? Text("Redial", comment: "Label of the keypad's call button while the field is empty: it puts the last dialed number in.") : Text("Call")) + .help(keypadCallsRedial ? Text("Redial") : Text("Call")) .accessibilityIdentifier("keypad-call") } .frame(maxWidth: .infinity) @@ -308,51 +466,170 @@ struct DialerView: View { private var content: some View { let rows = self.rows let hasLetters = text.contains(where: \.isLetter) - if queryIsEmpty { - emptyState - } else if rows.isEmpty { + if rows.isEmpty { if access == .granted || !hasLetters { - Text("No matches").font(.callout).foregroundStyle(.secondary).frame(maxWidth: .infinity) + Text("No matches").font(.callout).foregroundStyle(Color.rowSubtitle).frame(maxWidth: .infinity) .padding(.vertical, 8) } if access != .granted && hasLetters { accessHint } } else { - let selected = selectedIndex - ScrollViewReader { proxy in - ScrollView { - LazyVStack(spacing: 2) { - ForEach(Array(rows.enumerated()), id: \.element.id) { index, row in - Button { selectedID = row.id } label: { - ResultRow(row: row.display, selected: index == selected, enabled: model.canDial) - } - .buttonStyle(.plain) - .disabled(!model.canDial) - .id(row.id) - .accessibilityIdentifier(row.testIdentifier) - // Single click selects; double-click or Return calls (a stray click never dials). - .simultaneousGesture(TapGesture(count: 2).onEnded { activate(row) }) - .accessibilityAction(.default) { activate(row) } - .accessibilityHint(Text("Double-click or press Return to call.", comment: "VoiceOver hint of a search result row in the popover.")) + resultList(rows) + if access != .granted && hasLetters { accessHint } + } + } + + /// `fill`: take the height the parent offers (recent calls) instead of growing with the rows. + private func resultList(_ rows: [DialerRow], fill: Bool = false) -> some View { + let selected = selectedIndex + return ScrollViewReader { proxy in + ScrollView { + LazyVStack(spacing: 2) { + ForEach(Array(rows.enumerated()), id: \.element.id) { index, row in + Button { selectedID = row.id } label: { + ResultRow(row: row.display(contacts: model.contacts), selected: index == selected, + enabled: model.canDial, trailingInset: row.isRecent ? Self.callButtonSize + 8 : 0) + } + .buttonStyle(.plain) + // Rows never take keyboard focus: the list is one focus stop and the selection + // outline its only indicator (a second, system focus ring looked like two selections). + .focusable(false) + .disabled(!model.canDial) + .id(row.id) + .accessibilityIdentifier(row.testIdentifier) + // Single click selects; double-click or Return calls (a stray click never dials). + .simultaneousGesture(TapGesture(count: 2).onEnded { activate(row) }) + .accessibilityAction(.default) { activate(row) } + .accessibilityHint(Text("Double-click or press Return to call.", comment: "VoiceOver hint of a search result row in the popover.")) + .contextMenu { rowMenu(row) } + .overlay(alignment: .trailing) { + if row.isRecent { recentCallButton(row) } } } } - .frame(maxHeight: (showKeypad ? 3 : 6) * 50) - .onChange(of: selectedID) { _, new in - if let new { proxy.scrollTo(new) } + } + // Fixed width: with a mouse attached, macOS shows a legacy scroller that widened the list + // by its width, pushed the popover's content 5 pt to the left and made it jump when the + // keypad (no scroller) replaced the list. + .frame(width: contentWidth) + .frame(maxHeight: fill ? .infinity : (showKeypad ? 3 : 6) * 50, alignment: .top) + .scrollEdgeEffectStyle(.soft, for: .bottom) // rows fade out at the bottom instead of being cut + // One focus stop for the whole list: ↑/↓ select, Return calls, ⌘C copies. The selected row's + // outline is the focus indicator (no second system ring). + .focusable(interactions: .edit) + .focused($focus, equals: .list) + .focusEffectDisabled() + .accessibilityLabel(queryIsEmpty + ? Text("Recent Calls") + : Text("Search Results", comment: "VoiceOver label of the list of contacts and numbers matching the popover's search field.")) + .onChange(of: selectedID) { _, new in + if let new { proxy.scrollTo(new) } + } + } + } + + /// The call button at the end of a recent call row: one click dials (a service code asks first). + private func recentCallButton(_ row: DialerRow) -> some View { + let dialable = row.recentNumber != nil && model.canDial + let title = row.display(contacts: model.contacts).title + let label = row.recentNumber == nil ? String(localized: "Call") : String(localized: "Call \(title)") + return Button { selectedID = row.id; activate(row) } label: { + Image(systemName: "phone.fill") + .font(.system(size: 13)) + .foregroundStyle(.white) + .frame(width: Self.callButtonSize, height: Self.callButtonSize) + .background(Color.callGreen, in: Circle()) + .frame(width: Self.callButtonSize + 12, height: Self.callButtonSize + 12) // larger hit area than the circle + .contentShape(Circle()) + } + .buttonStyle(.plain) + .focusable(false) // Return calls the selected row; one Tab stop per row would make the list a long detour + .disabled(!dialable) + .opacity(dialable ? 1 : 0.35) + .padding(.trailing, 2) // circle 8 pt from the row's edge, like the avatar on the left + .accessibilityLabel(Text(verbatim: label)) + .help(Text(verbatim: label)) + .accessibilityIdentifier(row.testIdentifier + "-call") + } + + @ViewBuilder + private func rowMenu(_ row: DialerRow) -> some View { + Button("Call") { selectedID = row.id; activate(row) } + .disabled(!model.canDial) + Button { + copy(row) + } label: { + Text("Copy Number", comment: "Context menu item of a row in the popover's list: copies the phone number.") + } + .disabled(row.copyableNumber == nil) + if let card = row.newContact(contacts: model.contacts) { + Button { + if !ContactCard.open(name: card.name, number: card.number) { + error = String(localized: "The contact could not be handed to your contacts app.", + comment: "Inline error when Pickup could not open the new contact card in the contacts app.") + } + } label: { + if let app = ContactCard.appName { + Text("Add to \(app)…", comment: "Context menu item of a recent call or typed number without a contact: opens the contacts app (e.g. Contacts or Cardhop) with a new contact. %@ is the app name.") + } else { + Text("Add to Contacts…", comment: "Context menu item of a recent call or typed number without a contact, when the name of the contacts app is unknown.") } } - if access != .granted && hasLetters { accessHint } } } - private var emptyState: some View { - VStack(spacing: 6) { + // MARK: Recent calls + + private var recentCalls: some View { + let history = model.callHistory + let rows = self.rows + return VStack(spacing: 8) { + RecentCallsTabs(selection: $recentListRaw, focused: focus == .tabs) + .focusable(interactions: .edit) + .focused($focus, equals: .tabs) + .focusEffectDisabled() // RecentCallsTabs draws its own ring + .accessibilityIdentifier("recent-tabs") + .accessibilityHint(Text("Use the left and right arrow keys or Command-1 to 3 to switch tabs.", comment: "VoiceOver hint of the recent calls tabs in the popover.")) + + Group { + if !rows.isEmpty { + resultList(rows, fill: true) + } else if history.isLoading && !skeletonExpired { + // First read after launch: placeholder rows in the shape of the real ones, so the list + // fades in where they were (nothing jumps). Nothing is saved on the Mac between launches. + RecentCallsSkeleton(trailingInset: Self.callButtonSize + 8, callButtonSize: Self.callButtonSize) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) + .task { + try? await Task.sleep(for: Self.skeletonLimit) + if !Task.isCancelled { skeletonExpired = true } + } + } else if history.isLoading || history.lastReadUnanswered { + Text("Recent calls aren’t available right now.", comment: "Shown instead of the recent calls when the phone has not sent them within 10 seconds or did not answer.") + .font(.callout).foregroundStyle(Color.rowSubtitle) + .multilineTextAlignment(.center) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else { + Text("No calls", comment: "Empty state of a recent calls tab in the popover.") + .font(.callout).foregroundStyle(Color.rowSubtitle) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + } + .transition(.opacity) + .animation(reduceMotion ? nil : .easeOut(duration: 0.25), value: rows.isEmpty) + .animation(reduceMotion ? nil : .easeOut(duration: 0.25), value: history.isLoading) + .animation(reduceMotion ? nil : .easeOut(duration: 0.25), value: skeletonExpired) + .onChange(of: history.isLoading) { _, loading in + if !loading { skeletonExpired = false } // the next read shows the skeleton again + } + if history.source(recentList) == .session && !history.isLoading && history.phoneRefused { + Text("Calls since Pickup started. To see your phone's call history, allow access to contacts and call history in the phone's Bluetooth settings.", + comment: "Footnote below the recent calls when the phone did not share its call history and Pickup shows its own list.") + .font(.caption).foregroundStyle(Color.rowSubtitle) + .fixedSize(horizontal: false, vertical: true) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 8) // aligned with the rows' content + } if access != .granted { accessHint } - Text("Type a name or a number.", comment: "Calm empty state of the popover when the search field is empty.") - .font(.callout).foregroundStyle(.secondary) } - .frame(maxWidth: .infinity) - .padding(.vertical, 6) } @ViewBuilder @@ -397,15 +674,55 @@ struct DialerView: View { private func appendKey(_ key: String) { text.append(key) keypadText = text - fieldFocused = true + focus = .field collapseCaret() } private func moveSelection(_ delta: Int) -> Bool { let rows = self.rows guard !rows.isEmpty else { return false } + if focus == .tabs { + guard delta > 0 else { return false } + focus = .list // ↓ from the tabs enters the list at its first row + return true + } let index = min(max(selectedIndex + delta, 0), rows.count - 1) + if selectedID != rows[index].id { announceSelection(rows[index]) } selectedID = rows[index].id + if focus != .list { focus = .field } // arrow keys bring keyboard focus back from any button to the field + return true + } + + /// ⌘C on a selected row copies its number. A search text in the field keeps the normal copy. + private func copySelectedNumber() -> Bool { + guard focus != .tabs, focus != .field || queryIsEmpty else { return false } + let rows = self.rows + guard rows.indices.contains(selectedIndex), rows[selectedIndex].copyableNumber != nil else { return false } + copy(rows[selectedIndex]) + return true + } + + private func copy(_ row: DialerRow) { + guard let number = row.copyableNumber else { return } + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(number, forType: .string) + announce(String(localized: "Number copied", comment: "VoiceOver announcement after a phone number from the popover's list was copied with Command-C or the context menu.")) + } + + /// Typing while the tabs or the list have focus continues the search in the field. + private func typeIntoField(_ characters: String) -> Bool { + guard focus == .tabs || focus == .list, TypeAhead.accepts(characters) else { return false } + text.append(characters) + focus = .field + collapseCaret() + return true + } + + /// Return with an explicitly selected row (arrow keys, Tab, click): call it. Handled in the key + /// monitor, so it works even when keyboard focus is not in the text field. + private func submitSelectedRow() -> Bool { + guard let row = rows.first(where: { $0.id == selectedID }) else { return false } + activate(row) return true } @@ -416,7 +733,16 @@ struct DialerView: View { if rows.indices.contains(selectedIndex) { activate(rows[selectedIndex]) } } + /// The keypad's call button. With an empty field it puts the last dialed number in (redial); + /// the next press calls it. private func placeTypedCall() { + if queryIsEmpty, let number = redialNumber { + text = number + keypadText = nil // came from the phone, not from the keypad: service codes need a confirmation + focus = .field + collapseCaret() + return + } if let row = callRow { activate(row) } } @@ -431,6 +757,12 @@ struct DialerView: View { return } number = n + case .recent(_, let n, _): + guard let n else { + error = String(localized: "This number cannot be dialed.", comment: "Inline error when a contact's number is not a valid phone number.") + return + } + number = n } if row.requiresConfirmation && pendingConfirmationID != row.id { pendingConfirmationID = row.id @@ -456,22 +788,88 @@ final class DialerKeyRouter { static let shared = DialerKeyRouter() var move: ((Int) -> Bool)? var escape: (() -> Bool)? + /// Left/right arrow: previous/next recent calls tab (only while the field is empty). + var switchTab: ((Int) -> Bool)? + /// ⌘1 to ⌘3: recent calls tab by position. + var selectTab: ((Int) -> Bool)? + /// Return/Enter: calls the selected row, whichever control has keyboard focus; false = no row selected. + var submit: (() -> Bool)? + /// ⌘C: copies the selected row's number; false = the system copy applies. + var copy: (() -> Bool)? + /// Printable text typed while the tabs or the list have focus; true = it went to the search field. + var typeText: ((String) -> Bool)? /// A physical dial key (0-9 * #) went down/up; drives the on-screen key highlight. var physicalKey: ((String, Bool) -> Void)? } // MARK: - Rows +extension CallHistoryList { + /// Name of the recent calls tab. + var localizedTabName: String { + switch self { + case .dialed: String(localized: "Outgoing", comment: "Tab of the recent calls in the popover: calls placed from the phone.") + case .received: String(localized: "Incoming", comment: "Tab of the recent calls in the popover: answered incoming calls.") + case .missed: String(localized: "Missed", comment: "Tab of the recent calls in the popover: missed incoming calls.") + } + } +} + fileprivate extension DialerRow { - /// UI-test identifier: `call-row` or `contact-row--`. + /// UI-test identifier: `call-row`, `contact-row--` or `recent-row--`. var testIdentifier: String { switch kind { case .call: "call-row" case .contact: "contact-row-" + id.replacingOccurrences(of: "|", with: "-") + case .recent: id.replacingOccurrences(of: "recent|", with: "recent-row-").replacingOccurrences(of: "|", with: "-") + } + } + + var isRecent: Bool { if case .recent = kind { true } else { false } } + + var recentNumber: DialNumber? { if case .recent(_, let number, _) = kind { number } else { nil } } + + /// Name and number for "Add to Contacts…": a recent call or typed number that matches no contact + /// on this Mac. nil for contacts and withheld callers. + @MainActor + func newContact(contacts: ContactsDirectory) -> (name: String?, number: String)? { + switch kind { + case .recent(let entry, _, _): + guard !entry.number.isEmpty, contacts.match(for: entry.number) == nil else { return nil } + return (entry.name, entry.number) + case .call(let number): + guard !number.isServiceCode, contacts.match(for: number.value) == nil else { return nil } + return (nil, number.value) + case .contact: + return nil + } + } + + @MainActor + func display(contacts: ContactsDirectory) -> ResultRow.Display { + switch kind { + case .recent(let entry, _, let list): + // The Mac's contact (with photo) wins over the name the phone sent. + let match = entry.number.isEmpty ? nil : contacts.match(for: entry.number) + let name = match?.name ?? entry.name + let formatted = entry.number.isEmpty ? nil : PhoneNumberFormat.display(entry.number) + let title = name ?? formatted + ?? String(localized: "Unknown caller") + var details: [String] = [] + if name != nil, let formatted { details.append(formatted) } + if let date = entry.date { details.append(date.formatted(.relative(presentation: .named))) } + if entry.count > 1 { + details.append(String(localized: "\(entry.count) calls", comment: "Recent calls row: number of consecutive calls with the same number merged into this row, e.g. \"3 calls\".")) + } + return .init(title: title, subtitle: details.isEmpty ? nil : details.joined(separator: " · "), + party: CallParty(displayName: name, number: formatted, avatarPNG: match?.imageData), + isCall: false, warning: false, isMissed: list == .missed) + default: + return display } } - var display: ResultRow.Display { + private var display: ResultRow.Display { switch kind { case .call(let number): .init(title: String(localized: "Call \(PhoneNumberFormat.display(number.value))", @@ -479,21 +877,29 @@ fileprivate extension DialerRow { subtitle: requiresConfirmation ? String(localized: "Service code", comment: "Warning subtitle on the call row when the text is a service code (contains * or #), e.g. call forwarding.") : nil, - party: nil, isCall: true, warning: requiresConfirmation) + party: nil, isCall: true, warning: requiresConfirmation, isMissed: false) case .contact(let result): .init(title: result.name, subtitle: [result.label, PhoneNumberFormat.display(result.number)].compactMap { $0 }.joined(separator: " · "), party: CallParty(displayName: result.name, number: nil, avatarPNG: result.imageData), - isCall: false, warning: false) + isCall: false, warning: false, isMissed: false) + case .recent: + .init(title: "", subtitle: nil, party: nil, isCall: false, warning: false, isMissed: false) } } } private struct ResultRow: View { - struct Display { var title: String; var subtitle: String?; var party: CallParty?; var isCall: Bool; var warning: Bool } + struct Display { + var title: String; var subtitle: String?; var party: CallParty?; var isCall: Bool; var warning: Bool + /// Missed calls are titled in red, like in the Phone app. + var isMissed: Bool + } let row: Display let selected: Bool let enabled: Bool + /// Room kept free at the end for a button laid over the row. + var trailingInset: CGFloat = 0 var body: some View { HStack(spacing: 10) { @@ -509,16 +915,24 @@ private struct ResultRow: View { } VStack(alignment: .leading, spacing: 1) { Text(verbatim: row.title).font(.body).lineLimit(1).truncationMode(.tail) + .foregroundStyle(row.isMissed ? Color.missedRed : Color.primary) if let subtitle = row.subtitle { Text(verbatim: subtitle).font(.caption) - .foregroundStyle(.secondary).lineLimit(1) + .foregroundStyle(Color.rowSubtitle).lineLimit(1) } } Spacer(minLength: 0) } - .padding(.horizontal, 8) + .padding(.leading, 8) + .padding(.trailing, 8 + trailingInset) .frame(height: 48) - .background(selected ? Color.accentColor.opacity(0.22) : .clear, in: RoundedRectangle(cornerRadius: 10, style: .continuous)) + .background(selected ? Color.selectionFill : .clear, in: RoundedRectangle(cornerRadius: 10, style: .continuous)) + .overlay { + if selected { + RoundedRectangle(cornerRadius: 10, style: .continuous) + .strokeBorder(Color.selectionRing, lineWidth: 2) + } + } .contentShape(Rectangle()) .opacity(enabled ? 1 : 0.5) .accessibilityElement(children: .combine) @@ -542,3 +956,25 @@ private struct PlusLongPress: ViewModifier { } } } + +/// A round key: darkens and shrinks evenly (stays a circle) while the mouse is down or the matching +/// physical key is held. +private struct KeyPressStyle: ButtonStyle { + var highlighted = false + let reduceMotion: Bool + + func makeBody(configuration: Configuration) -> some View { + let pressed = highlighted || configuration.isPressed + configuration.label + .overlay(Circle().fill(Color.primary.opacity(pressed ? 0.22 : 0)).allowsHitTesting(false)) + .scaleEffect(pressed ? 0.93 : 1) + .animation(reduceMotion ? nil : .easeOut(duration: 0.08), value: pressed) + } +} + +/// Pressed feedback without moving anything: the label only dims while the mouse is down. +private struct SteadyPressStyle: ButtonStyle { + func makeBody(configuration: Configuration) -> some View { + configuration.label.opacity(configuration.isPressed ? 0.7 : 1) + } +} diff --git a/App/Sources/Pickup/MenuView.swift b/App/Sources/Pickup/MenuView.swift index 4a49a08..15584ba 100644 --- a/App/Sources/Pickup/MenuView.swift +++ b/App/Sources/Pickup/MenuView.swift @@ -8,26 +8,95 @@ struct MenuView: View { let openSettings: () -> Void let showAbout: () -> Void let close: () -> Void + @AppStorage(PopoverWidth.defaultsKey) private var savedWidth = Double(PopoverWidth.minimum) + /// Width while an edge is being dragged; saved when the drag ends. + @State private var liveWidth: CGFloat? + + private var width: CGFloat { liveWidth ?? PopoverWidth.clamped(savedWidth) } var body: some View { // Structure for step B: [top bar: grid button + search capsule] / [collapsible content: keypad or // results] / [bottom bar]. The three parts are separate stack children so the top bar and the // folding keypad can be added without reworking the bottom bar or the guidance area. VStack(spacing: 0) { - DialerView(model: model, openSettings: openSettings, close: close) + DialerView(model: model, contentWidth: width - 2 * DialerView.sideMargin, openSettings: openSettings, close: close) - BottomBar(status: model.connection, openSettings: openSettings, showAbout: showAbout) + BottomBar(status: model.connection, + battery: model.batteryDisplay == .popover ? model.phoneBattery : nil, + openSettings: openSettings, showAbout: showAbout) } - .frame(width: 300) + .frame(width: width) + .overlay(alignment: .leading) { resizeEdge(.leading) } + .overlay(alignment: .trailing) { resizeEdge(.trailing) } .accessibilityElement(children: .contain) .accessibilityIdentifier("popover-root") .onAppear { Task { await model.permissions.refresh() } } } } +extension MenuView { + private func resizeEdge(_ edge: HorizontalEdge) -> some View { + PopoverResizeEdge(edge: edge, width: width) { live in + liveWidth = live + } commit: { final in + liveWidth = nil + savedWidth = Double(final) + } + } +} + +/// An invisible strip along a side edge of the popover: dragging it changes the popover's width (the +/// keypad keeps its size; search and lists grow). VoiceOver adjusts it with increment/decrement. +private struct PopoverResizeEdge: View { + let edge: HorizontalEdge + let width: CGFloat + let live: (CGFloat) -> Void + let commit: (CGFloat) -> Void + /// Pointer x (screen coordinates) and width when the drag began. Screen coordinates, because the + /// popover moves under the pointer while it grows. + @State private var start: (x: CGFloat, width: CGFloat)? + + var body: some View { + Color.clear + .frame(width: 6) + .frame(maxHeight: .infinity) + .contentShape(Rectangle()) + .pointerStyle(.columnResize) + .gesture( + DragGesture(minimumDistance: 1) + .onChanged { _ in + let x = NSEvent.mouseLocation.x + if start == nil { + start = (x, width) + NotificationCenter.default.post(name: .pickupPopoverResizing, object: true) + } + guard let origin = start else { return } + live(PopoverWidth.resized(from: origin.width, outwardDelta: (x - origin.x) * (edge == .trailing ? 1 : -1))) + } + .onEnded { _ in + start = nil + commit(width) + NotificationCenter.default.post(name: .pickupPopoverResizing, object: false) + }) + .accessibilityElement() + .accessibilityLabel(Text("Window width", comment: "VoiceOver label of the invisible handle at the side of the popover that changes its width.")) + .accessibilityValue(Text(verbatim: "\(Int(width))")) + .accessibilityAdjustableAction { direction in + switch direction { + case .increment: commit(PopoverWidth.clamped(width + PopoverWidth.accessibilityStep)) + case .decrement: commit(PopoverWidth.clamped(width - PopoverWidth.accessibilityStep)) + @unknown default: break + } + } + .accessibilityHidden(edge == .leading) // one handle is enough for VoiceOver + } +} + /// Status on the left, one round Liquid Glass button with the app menu on the right. private struct BottomBar: View { let status: ConnectionStatus + /// Phone battery level 0...5, shown after the status text; nil hides it. + let battery: Int? let openSettings: () -> Void let showAbout: () -> Void @State private var anchor = AnchorView.Holder() @@ -40,6 +109,9 @@ private struct BottomBar: View { .foregroundStyle(.secondary) .lineLimit(1) .truncationMode(.tail) + if let battery { + BatteryGauge(level: battery) + } Spacer(minLength: 8) GlassEffectContainer { Button { @@ -87,6 +159,41 @@ private struct BottomBar: View { } } +/// The phone's battery: an outlined battery filled in system green up to the level, and the percentage. +private struct BatteryGauge: View { + let level: Int + + var body: some View { + let text = PhoneBattery.percentText(level: level) + HStack(spacing: 2) { + HStack(spacing: 1) { + RoundedRectangle(cornerRadius: 3, style: .continuous) + .strokeBorder(Color.secondary, lineWidth: 1) + .overlay(alignment: .leading) { + GeometryReader { proxy in + RoundedRectangle(cornerRadius: 1.5, style: .continuous) + .fill(Color(nsColor: .systemGreen)) + .frame(width: max(0, (proxy.size.width - 4) * PhoneBattery.fraction(level: level))) + .padding(2) + } + } + .frame(width: 22, height: 11) + RoundedRectangle(cornerRadius: 1, style: .continuous) + .fill(Color.secondary) + .frame(width: 1.5, height: 4) + } + Text(verbatim: text) + .font(.caption.monospacedDigit()) + .foregroundStyle(.secondary) + } + .fixedSize() + .accessibilityElement(children: .ignore) + .accessibilityLabel(Text("Phone battery")) + .accessibilityValue(Text(verbatim: text)) + .accessibilityIdentifier("phone-battery") + } +} + /// Short first-run / problem hint with one action, in the style of a system callout. struct GuidanceCard: View { let guidance: AppModel.Guidance diff --git a/App/Sources/Pickup/MissedCallNotifier.swift b/App/Sources/Pickup/MissedCallNotifier.swift index d469727..1a09815 100644 --- a/App/Sources/Pickup/MissedCallNotifier.swift +++ b/App/Sources/Pickup/MissedCallNotifier.swift @@ -1,19 +1,39 @@ import Foundation +import PickupCore import UserNotifications +/// Which finished calls count as missed (notification and red dot on the menu bar icon). +enum MissedCalls { + /// An incoming call nobody answered. Declined on this Mac is a choice, not missed. + static func isMissed(_ call: CallInfo) -> Bool { isMissed(direction: call.direction, endReason: call.endReason) } + + static func isMissed(direction: CallDirection, endReason: CallEndReason?) -> Bool { + direction == .incoming && endReason == .missed + } +} + /// Posts a local notification for missed calls, only when the user allowed notifications. final class MissedCallNotifier: NSObject, UNUserNotificationCenterDelegate { static let shared = MissedCallNotifier() static let enabledKey = "notifyMissedCalls" + private static let category = "missed-call" + private static let callBackAction = "call-back" + private static let numberKey = "number" /// Call once at launch: shows banners while the app is "active" (menu bar apps always are). func install() { guard PermissionsModel.notificationsAvailable else { return } - UNUserNotificationCenter.current().delegate = self + let center = UNUserNotificationCenter.current() + center.delegate = self + let callBack = UNNotificationAction(identifier: Self.callBackAction, + title: String(localized: "Call Back", comment: "Button of the missed-call notification: calls the number back on the phone.")) + center.setNotificationCategories([UNNotificationCategory(identifier: Self.category, actions: [callBack], intentIdentifiers: [])]) UserDefaults.standard.register(defaults: [Self.enabledKey: true]) } - func notifyMissed(name: String?, number: String?) { + /// `number` as the phone reported it (formatted for display here); nil when withheld. + func notifyMissed(name: String?, number rawNumber: String?) { + let number = rawNumber.map { PhoneNumberFormat.display($0) } guard !TestMode.isActive, PermissionsModel.notificationsAvailable, UserDefaults.standard.bool(forKey: Self.enabledKey) else { return } let center = UNUserNotificationCenter.current() @@ -24,6 +44,10 @@ final class MissedCallNotifier: NSObject, UNUserNotificationCenterDelegate { content.body = name ?? number ?? String(localized: "Unknown caller") if name != nil, let number { content.subtitle = number } content.threadIdentifier = "missed-calls" + if let rawNumber, DialNumber(rawNumber).map({ !$0.isServiceCode }) == true { + content.categoryIdentifier = Self.category // "Call Back" only for a dialable number + content.userInfo = [Self.numberKey: rawNumber] + } center.add(UNNotificationRequest(identifier: UUID().uuidString, content: content, trigger: nil)) } } @@ -32,4 +56,10 @@ final class MissedCallNotifier: NSObject, UNUserNotificationCenterDelegate { -> UNNotificationPresentationOptions { [.banner, .list, .sound] } + + func userNotificationCenter(_ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse) async { + guard response.actionIdentifier == Self.callBackAction, + let number = response.notification.request.content.userInfo[Self.numberKey] as? String else { return } + await MainActor.run { AppModel.shared.callBack(number) } + } } diff --git a/App/Sources/Pickup/PhoneBattery.swift b/App/Sources/Pickup/PhoneBattery.swift new file mode 100644 index 0000000..7761236 --- /dev/null +++ b/App/Sources/Pickup/PhoneBattery.swift @@ -0,0 +1,29 @@ +import Foundation + +/// Where Pickup shows the phone's battery charge (Settings). +enum BatteryDisplay: String, CaseIterable, Identifiable { + /// Battery gauge and percentage in the popover's status line ("Connected to …"). + case popover + /// Percentage next to the menu bar icon. + case menuBar + case off + + static let defaultsKey = "phoneBatteryDisplay" + var id: String { rawValue } +} + +/// The phone's battery as HFP reports it: a level from 0 to 5 (`battchg`), so the percentage moves +/// in steps of 20. +enum PhoneBattery { + static func percent(level: Int) -> Int { min(max(level, 0), 5) * 20 } + + /// Filled share of the gauge, 0...1. + static func fraction(level: Int) -> Double { Double(percent(level: level)) / 100 } + + /// "60 %" (German) or "60%" (English), in the user's locale. The space some locales put before + /// the percent sign is a narrow one (half the width), which reads better at small sizes. + static func percentText(level: Int, locale: Locale = .current) -> String { + (Double(percent(level: level)) / 100).formatted(.percent.locale(locale)) + .replacingOccurrences(of: "\u{00A0}", with: "\u{202F}") + } +} diff --git a/App/Sources/Pickup/PickupApp.swift b/App/Sources/Pickup/PickupApp.swift index ce62090..6418d9c 100644 --- a/App/Sources/Pickup/PickupApp.swift +++ b/App/Sources/Pickup/PickupApp.swift @@ -26,9 +26,14 @@ final class AppDelegate: NSObject, NSApplicationDelegate { @MainActor func applicationDidFinishLaunching(_ notification: Notification) { let model = AppModel.shared - model.start() + let welcome = WelcomePolicy.launchReason() + // The welcome window asks for the permissions itself; no automatic prompts on top of it. + model.start(askForPermissions: welcome == nil) let statusItem = StatusItemController(model: model) self.statusItem = statusItem + if let welcome { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { statusItem.showWelcome(welcome) } + } retargetSettingsMenuItem() NotificationCenter.default.addObserver(forName: NSApplication.didBecomeActiveNotification, object: nil, queue: .main) { _ in MainActor.assumeIsolated { (NSApp.delegate as? AppDelegate)?.retargetSettingsMenuItem() } diff --git a/App/Sources/Pickup/PopoverWidth.swift b/App/Sources/Pickup/PopoverWidth.swift new file mode 100644 index 0000000..016d7fa --- /dev/null +++ b/App/Sources/Pickup/PopoverWidth.swift @@ -0,0 +1,23 @@ +import CoreGraphics + +/// Width of the menu bar popover, which the user can drag between `minimum` and `maximum`. Saved in +/// the user defaults, so it survives relaunches and restarts. +enum PopoverWidth { + static let minimum: CGFloat = 300 + static let maximum: CGFloat = 2 * minimum + static let defaultsKey = "popoverWidth" + /// Step of the VoiceOver adjust actions (increment / decrement). + static let accessibilityStep: CGFloat = 50 + + static func clamped(_ width: CGFloat) -> CGFloat { + guard width.isFinite else { return minimum } + return min(max(width.rounded(), minimum), maximum) + } + + /// The popover stays centred under its icon, so dragging one edge by `delta` moves both edges: + /// the width changes by twice the distance and the dragged edge stays under the pointer. + /// `delta` is positive towards the outside of the dragged edge. + static func resized(from start: CGFloat, outwardDelta delta: CGFloat) -> CGFloat { + clamped(start + 2 * delta) + } +} diff --git a/App/Sources/Pickup/RecentCallsViews.swift b/App/Sources/Pickup/RecentCallsViews.swift new file mode 100644 index 0000000..8fca365 --- /dev/null +++ b/App/Sources/Pickup/RecentCallsViews.swift @@ -0,0 +1,130 @@ +import AppKit +import PickupCore +import SwiftUI + +/// Placeholder rows while the recent calls are read for the first time (like PrimeNG's Skeleton): +/// avatar, name and detail bars and the call button's circle, with a light sweeping across. Static +/// with Reduce Motion. VoiceOver hears one "Loading recent calls" element. +struct RecentCallsSkeleton: View { + let trailingInset: CGFloat + let callButtonSize: CGFloat + @Environment(\.accessibilityReduceMotion) private var reduceMotion + /// Varying bar lengths, so the placeholder reads as a list and not as a pattern. + private static let widths: [(title: CGFloat, detail: CGFloat)] = [ + (0.55, 0.38), (0.42, 0.30), (0.62, 0.44), (0.48, 0.34), (0.58, 0.40), (0.45, 0.32), + ] + + var body: some View { + rows(Color.skeleton) + .overlay { + // The band is cut to the shapes drawn opaque: a mask also takes over the opacity, and the + // faint shapes themselves let almost none of the band through. + if !reduceMotion { ShimmerBand().mask(rows(.black)) } + } + .accessibilityElement(children: .ignore) + .accessibilityLabel(Text("Loading recent calls", comment: "VoiceOver label of the placeholder rows shown while the recent calls are read from the phone.")) + .accessibilityIdentifier("recent-skeleton") + } + + private func rows(_ fill: Color) -> some View { + VStack(spacing: 2) { + ForEach(Self.widths.indices, id: \.self) { index in + row(Self.widths[index], fill: fill) + } + } + } + + private func row(_ widths: (title: CGFloat, detail: CGFloat), fill: Color) -> some View { + HStack(spacing: 10) { + Circle().fill(fill).frame(width: 32, height: 32) + GeometryReader { proxy in + VStack(alignment: .leading, spacing: 6) { + Capsule().fill(fill).frame(width: proxy.size.width * widths.title, height: 10) + Capsule().fill(fill).frame(width: proxy.size.width * widths.detail, height: 8) + } + .frame(maxHeight: .infinity, alignment: .center) + } + Circle().fill(fill).frame(width: callButtonSize, height: callButtonSize) + } + .padding(.leading, 8) + .padding(.trailing, 8 + 2) + .frame(height: 48) + } +} + +/// A light band sweeping from left to right (PrimeNG's "wave": 1.2 s, endless). Driven by the clock, +/// not by a started animation: a repeating animation begun while the popover was still opening was +/// dropped and the band stood still. +struct ShimmerBand: View { + private static let period: TimeInterval = 1.2 + + var body: some View { + TimelineView(.animation) { context in + let progress = context.date.timeIntervalSinceReferenceDate + .truncatingRemainder(dividingBy: Self.period) / Self.period + GeometryReader { proxy in + let band = proxy.size.width * 0.5 + LinearGradient(colors: [.clear, Color.skeletonHighlight, .clear], startPoint: .leading, endPoint: .trailing) + .frame(width: band) + .offset(x: -band + progress * (proxy.size.width + band)) + } + } + .allowsHitTesting(false) + } +} + +/// Tabs above the recent calls: equal segments that always fill the given width exactly. The native +/// segmented control sizes itself from its labels and grew wider than the popover with the German +/// texts, which shifted the whole content. VoiceOver still gets a real segmented picker. +struct RecentCallsTabs: View { + @Binding var selection: String + /// Keyboard focus is on the tabs (←/→ switch): drawn as a ring around the control. + var focused = false + + var body: some View { + HStack(spacing: 2) { + ForEach(CallHistoryList.allCases, id: \.self) { list in + let selected = selection == list.rawValue + Button { selection = list.rawValue } label: { + Text(verbatim: list.localizedTabName) + .font(.callout.weight(selected ? .semibold : .regular)) + .foregroundStyle(selected ? Color.activeSegmentText : Color.primary) + .lineLimit(1) + .minimumScaleFactor(0.75) + .frame(maxWidth: .infinity) + .frame(height: 24) + .background { + // Active tab like a native segmented control: filled with the accent color. + if selected { + RoundedRectangle(cornerRadius: 6, style: .continuous).fill(Color.activeSegment) + } + } + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .focusable(false) // the control is one focus stop; ←/→ switch the tab + } + } + .padding(2) + .background(Color.primary.opacity(0.06), in: RoundedRectangle(cornerRadius: 8, style: .continuous)) + .overlay { + if focused { + RoundedRectangle(cornerRadius: 8, style: .continuous).strokeBorder(Color.selectionRing, lineWidth: 2) + } + } + .frame(maxWidth: .infinity) + .accessibilityRepresentation { + Picker(selection: $selection) { + ForEach(CallHistoryList.allCases, id: \.self) { list in + Text(verbatim: list.localizedTabName).tag(list.rawValue) + } + } label: { + Text("Recent Calls", comment: "VoiceOver label of the tabs above the recent calls in the popover.") + } + .pickerStyle(.segmented) + .labelsHidden() + // As wide as the visible tabs: VoiceOver's cursor and hit area match what is on screen. + .frame(maxWidth: .infinity) + } + } +} diff --git a/App/Sources/Pickup/SettingsView.swift b/App/Sources/Pickup/SettingsView.swift index 93c2349..873412f 100644 --- a/App/Sources/Pickup/SettingsView.swift +++ b/App/Sources/Pickup/SettingsView.swift @@ -32,6 +32,16 @@ struct SettingsView: View { .disabled(true) .accessibilityIdentifier("settings-take-calls") .help(Text("Not available yet. For now, Pickup controls your calls from the Mac while the conversation stays on your phone.")) + Picker("Phone battery", selection: Binding(get: { model.batteryDisplay }, set: { model.batteryDisplay = $0 })) { + Text("In the menu window", comment: "Settings option for the phone's battery: gauge and percentage in the popover's status line.") + .tag(BatteryDisplay.popover) + Text("In the menu bar", comment: "Settings option for the phone's battery: percentage next to Pickup's menu bar icon.") + .tag(BatteryDisplay.menuBar) + Text("Don’t show", comment: "Settings option for the phone's battery: hidden.") + .tag(BatteryDisplay.off) + } + .accessibilityIdentifier("settings-battery") + .help(Text("Your phone reports its charge in steps of 20 percent.", comment: "Tooltip of the phone battery setting.")) LabeledContent("Global shortcut") { ShortcutRecorder() } .help(Text("Opens Pickup's menu bar window from anywhere. Press it again to close it.")) ShortcutHint() diff --git a/App/Sources/Pickup/StatusItemController.swift b/App/Sources/Pickup/StatusItemController.swift index 2322e4d..6ef601d 100644 --- a/App/Sources/Pickup/StatusItemController.swift +++ b/App/Sources/Pickup/StatusItemController.swift @@ -8,8 +8,11 @@ import SwiftUI final class StatusItemController: NSObject, NSPopoverDelegate { private let model: AppModel private let statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.squareLength) + /// Red dot on the icon for missed calls the user has not looked at yet. + private let missedDot = StatusBadgeDot() private let popover = NSPopover() private var settingsWindow: NSWindow? + private var welcomeWindow: NSWindow? private var keyMonitor: Any? private var shortcutMonitor: Any? /// App that was frontmost before the popover activated Pickup; reactivated on close. @@ -38,6 +41,13 @@ final class StatusItemController: NSObject, NSPopoverDelegate { if UserDefaults.standard.bool(forKey: "PickupOpenPopover") { popover.behavior = .applicationDefined } popover.animates = !NSWorkspace.shared.accessibilityDisplayShouldReduceMotion popover.delegate = self + // While an edge is dragged the popover follows the pointer without its resize animation. + NotificationCenter.default.addObserver(forName: .pickupPopoverResizing, object: nil, queue: .main) { [weak self] note in + let resizing = note.object as? Bool ?? false + MainActor.assumeIsolated { + self?.popover.animates = !resizing && !NSWorkspace.shared.accessibilityDisplayShouldReduceMotion + } + } trackIcon() installShortcutMonitor() // Global shortcut (Settings): toggles the popover from anywhere. @@ -49,17 +59,59 @@ final class StatusItemController: NSObject, NSPopoverDelegate { // MARK: Icon - /// Re-reads the connection state whenever it changes: filled handset with call arcs = connected, outlined - /// handset = disconnected (docs/ICON.md). + /// Re-reads the state whenever it changes: filled handset with call arcs = connected, outlined + /// handset = disconnected (docs/ICON.md); a red dot for unseen missed calls; the phone's battery + /// percentage next to the icon when chosen in Settings. private func trackIcon() { withObservationTracking { let connected = model.connection.isConnected || model.isDemo - statusItem.button?.image = Self.menuBarImage(connected: connected) + let battery = model.batteryDisplay == .menuBar ? model.phoneBattery : nil + updateIcon(connected: connected, battery: battery, missed: model.hasUnseenMissedCalls) } onChange: { [weak self] in Task { @MainActor in self?.trackIcon() } } } + private func updateIcon(connected: Bool, battery: Int?, missed: Bool) { + guard let button = statusItem.button else { return } + let icon = Self.menuBarImage(connected: connected) + if let battery, let icon { + // One template image (handset + percentage): NSButton's own image-title gap is too wide. + button.image = Self.withPercentage(icon, PhoneBattery.percentText(level: battery)) + statusItem.length = NSStatusItem.variableLength + } else { + button.image = icon + statusItem.length = NSStatusItem.squareLength + } + button.title = "" + button.imagePosition = .imageOnly + let name = String(localized: "Pickup", comment: "Accessibility label of the menu bar icon. The app name.") + var label = missed + ? String(localized: "\(name), missed calls", comment: "Accessibility label of the menu bar icon while it shows the red dot for missed calls. %@ is the app name.") + : name + if let battery { + label = String(localized: "\(label), phone battery \(PhoneBattery.percentText(level: battery))", + comment: "Accessibility label of the menu bar icon with the phone's battery next to it. First %@ is the app name (maybe with \"missed calls\"), second the percentage, e.g. \"60 %\".") + } + button.setAccessibilityLabel(label) + if missedDot.superview !== button { button.addSubview(missedDot) } + missedDot.isHidden = !missed + button.layoutSubtreeIfNeeded() + positionMissedDot() + } + + /// At the top right of the handset, also when the percentage widens the image. + private func positionMissedDot() { + guard let button = statusItem.button else { return } + let image = (button.cell as? NSButtonCell)?.imageRect(forBounds: button.bounds) + ?? NSRect(x: (button.bounds.width - 18) / 2, y: (button.bounds.height - 18) / 2, width: 18, height: 18) + // The handset is the image's left 18 pt (the percentage, if shown, follows it). + let handset = NSRect(x: image.minX, y: image.midY - 9, width: 18, height: 18) + let size = StatusBadgeDot.diameter + let y = button.isFlipped ? handset.minY - 1 : handset.maxY - size + 1 + missedDot.frame = NSRect(x: handset.maxX - size + 1, y: y, width: size, height: size) + } + /// The template PDFs from App/Resources/MenuBar (copied into the bundle by scripts/build-app.sh). Outside the /// app bundle (`swift run`, tests) they are missing, so the SF Symbols stand in. private static func menuBarImage(connected: Bool) -> NSImage? { @@ -73,6 +125,25 @@ final class StatusItemController: NSObject, NSPopoverDelegate { return image } + /// Gap between the handset and the battery percentage in the menu bar. + private static let percentageGap: CGFloat = 3 + + /// The handset with the percentage to its right, drawn as one template image so the menu bar + /// tints both for light, dark and selected. + private static func withPercentage(_ icon: NSImage, _ text: String) -> NSImage { + let font = NSFont.monospacedDigitSystemFont(ofSize: NSFont.systemFontSize - 2, weight: .regular) + let string = NSAttributedString(string: text, attributes: [.font: font, .foregroundColor: NSColor.black]) + let textSize = string.size() + let size = NSSize(width: ceil(icon.size.width + percentageGap + textSize.width), height: max(icon.size.height, ceil(textSize.height))) + let image = NSImage(size: size, flipped: false) { _ in + icon.draw(in: NSRect(x: 0, y: (size.height - icon.size.height) / 2, width: icon.size.width, height: icon.size.height)) + string.draw(at: NSPoint(x: icon.size.width + percentageGap, y: (size.height - textSize.height) / 2)) + return true + } + image.isTemplate = true + return image + } + /// Shows the chosen global shortcut as the menu bar icon's tooltip. private func trackShortcutTooltip() { withObservationTracking { @@ -147,18 +218,35 @@ final class StatusItemController: NSObject, NSPopoverDelegate { private func installKeyMonitor() { guard keyMonitor == nil else { return } keyMonitor = NSEvent.addLocalMonitorForEvents(matching: [.keyDown, .keyUp]) { [weak self] event in - guard let self, event.window === self.popover.contentViewController?.view.window, - event.modifierFlags.intersection([.command, .option, .control]).isEmpty else { return event } + guard let self, event.window === self.popover.contentViewController?.view.window else { return event } let router = DialerKeyRouter.shared + // ⌘1 / ⌘2 / ⌘3: recent calls tabs. + if event.type == .keyDown, event.modifierFlags.intersection([.command, .option, .control, .shift]) == .command, + let digit = event.charactersIgnoringModifiers.flatMap(Int.init), (1...3).contains(digit) { + return (router.selectTab?(digit - 1) ?? false) ? nil : event + } + // ⌘C on a selected row copies its number (otherwise the normal copy). + if event.type == .keyDown, event.modifierFlags.intersection([.command, .option, .control, .shift]) == .command, + event.charactersIgnoringModifiers?.lowercased() == "c" { + return (router.copy?() ?? false) ? nil : event + } + guard event.modifierFlags.intersection([.command, .option, .control]).isEmpty else { return event } // Physical dial keys (main row and numpad) light up the on-screen key; typing is untouched. - if let key = event.characters, key.count == 1, "0123456789*#".contains(key) { + let isDialKey = event.characters.map { $0.count == 1 && "0123456789*#".contains($0) } ?? false + if isDialKey, let key = event.characters { router.physicalKey?(key, event.type == .keyDown) - return event } guard event.type == .keyDown else { return event } + // Typing while the tabs or the list have focus goes on in the search field. + if let characters = event.characters, router.typeText?(characters) == true { return nil } + if isDialKey { return event } switch event.keyCode { case 126: return (router.move?(-1) ?? false) ? nil : event // up case 125: return (router.move?(1) ?? false) ? nil : event // down + case 123: return (router.switchTab?(-1) ?? false) ? nil : event // left: previous recent calls tab + case 124: return (router.switchTab?(1) ?? false) ? nil : event // right: next tab + case 36, 76: // return / enter: call the selected row + return (router.submit?() ?? false) ? nil : event case 53: // esc if router.escape?() == true { return nil } self.closePopover() @@ -207,6 +295,39 @@ final class StatusItemController: NSObject, NSPopoverDelegate { settingsWindow?.orderFrontRegardless() } + /// The welcome window (first launch, or an unsigned update). Marked as shown right away, so it + /// does not come back at the next launch even if the user quits instead of clicking Done. + func showWelcome(_ reason: WelcomePolicy.Reason) { + WelcomePolicy.markShown() + keepActiveOnClose = true + closePopover() + if welcomeWindow == nil { + let view = WelcomeView(reason: reason, model: model, + openSettings: { [weak self] in self?.openSettings() }, + close: { [weak self] in self?.welcomeWindow?.close() }) + let window = NSWindow(contentViewController: NSHostingController(rootView: view)) + window.title = String(localized: "Welcome to Pickup") + window.identifier = NSUserInterfaceItemIdentifier("welcome-window") + window.styleMask = [.titled, .closable] + window.titlebarAppearsTransparent = true + window.titleVisibility = .hidden // the header shows it; VoiceOver still gets the title + window.isReleasedWhenClosed = false + welcomeWindow = window + } + // Centred once SwiftUI has sized the content (centring the empty window put it in a corner). + if let window = welcomeWindow, let content = window.contentViewController?.view { + content.layoutSubtreeIfNeeded() + window.setContentSize(content.fittingSize) + if let screen = (NSScreen.main ?? NSScreen.screens.first)?.visibleFrame { + window.setFrameOrigin(NSPoint(x: (screen.midX - window.frame.width / 2).rounded(), + y: (screen.midY - window.frame.height / 2).rounded())) + } + } + NSApp.activate() + welcomeWindow?.makeKeyAndOrderFront(nil) + welcomeWindow?.orderFrontRegardless() + } + func showAbout() { keepActiveOnClose = true closePopover() @@ -223,22 +344,65 @@ final class StatusItemController: NSObject, NSPopoverDelegate { comment: "One-line description in the About panel."), attributes: [.font: NSFont.systemFont(ofSize: NSFont.smallSystemFontSize), .foregroundColor: NSColor.secondaryLabelColor]) - if let string = AppInfo.repositoryURL, let url = URL(string: string) { + // Links open in the default browser; Pickup itself never goes online. + let links: [(String?, String?)] = [ + (AppInfo.repositoryURL, String(localized: "Source code on GitHub", comment: "Link text in the About panel.")), + (AppInfo.websiteURL, nil), // shown as its domain + ] + let shown = links.compactMap { string, text -> (URL, String)? in + guard let string, let url = URL(string: string), let title = text ?? url.host() else { return nil } + return (url, title) + } + for (index, (url, title)) in shown.enumerated() { + // Centred like the rest of the panel; the links get room above the first and below the last. + let style = NSMutableParagraphStyle() + style.alignment = .center + style.paragraphSpacingBefore = index == 0 ? 10 : 2 + style.paragraphSpacing = index == shown.count - 1 ? 10 : 0 credits.append(NSAttributedString(string: "\n")) - credits.append(NSAttributedString( - string: String(localized: "Source code on GitHub", comment: "Link text in the About panel."), - attributes: [.link: url, .font: NSFont.systemFont(ofSize: NSFont.smallSystemFontSize)])) + credits.append(NSAttributedString(string: title, attributes: [ + .link: url, .font: NSFont.systemFont(ofSize: NSFont.smallSystemFontSize), .paragraphStyle: style, + ])) + } + let centered = NSMutableParagraphStyle() + centered.alignment = .center + credits.enumerateAttribute(.paragraphStyle, in: NSRange(location: 0, length: credits.length)) { value, range, _ in + if value == nil { credits.addAttribute(.paragraphStyle, value: centered, range: range) } } options[.credits] = credits NSApp.orderFrontStandardAboutPanel(options: options) } } +/// The missed-call dot: system red, follows light/dark mode and accent changes. +private final class StatusBadgeDot: NSView { + static let diameter: CGFloat = 7 + + override init(frame: NSRect) { + super.init(frame: frame) + wantsLayer = true + setAccessibilityElement(false) // the button's label says "missed calls" + } + + required init?(coder: NSCoder) { nil } + + override var wantsUpdateLayer: Bool { true } + + override func updateLayer() { + layer?.backgroundColor = NSColor.systemRed.cgColor + layer?.cornerRadius = bounds.width / 2 + } + + override func hitTest(_ point: NSPoint) -> NSView? { nil } // clicks go to the button +} + extension Notification.Name { /// Posted once the popover window is key; the dialer focuses the field and puts the caret at the end. static let pickupPopoverDidShow = Notification.Name("PickupPopoverDidShow") /// Posted right before the popover opens; the dialer resets and focuses its search field. static let pickupPopoverWillShow = Notification.Name("PickupPopoverWillShow") + /// Posted when the user starts (`true`) and stops (`false`) dragging the popover's width. + static let pickupPopoverResizing = Notification.Name("PickupPopoverResizing") } /// Settings window that opens without a focused control. With keyboard navigation turned on, AppKit diff --git a/App/Sources/Pickup/Testing/ScriptedPhone.swift b/App/Sources/Pickup/Testing/ScriptedPhone.swift index 5f1b8d5..700b342 100644 --- a/App/Sources/Pickup/Testing/ScriptedPhone.swift +++ b/App/Sources/Pickup/Testing/ScriptedPhone.swift @@ -82,6 +82,56 @@ final class ScriptedTransport: HFPTransport { } } + /// The phone's call lists (invented): it shares them like a Pixel that was granted access. + func readCallHistory(_ list: CallHistoryList, count: Int) throws { + try requireLink() + // The first entries are what the UI tests check; the rest fill the list for screenshots. Numbers without a + // name match the invented contacts (`ContactsDirectory.loadDemo`) or stay unknown. + let entries: [CallHistoryEntry] = switch list { + case .dialed: [CallHistoryEntry(number: Self.jane, name: "Jane Doe"), CallHistoryEntry(number: Self.john, name: "John Roe"), + CallHistoryEntry(number: Self.jane, name: "Jane Doe"), CallHistoryEntry(number: "+491700000111", count: 2), + CallHistoryEntry(number: "+4700000444"), CallHistoryEntry(number: "+49890000555"), + CallHistoryEntry(number: "+49300000222")] + case .received: [CallHistoryEntry(number: Self.john, name: "John Roe"), CallHistoryEntry(number: Self.jane, name: "Jane Doe"), + CallHistoryEntry(number: "+49400000333"), CallHistoryEntry(number: "+4700000444")] + case .missed: [CallHistoryEntry(number: Self.jane, name: "Jane Doe"), CallHistoryEntry(number: ""), + CallHistoryEntry(number: "+491700000111"), CallHistoryEntry(number: Self.john, name: "John Roe")] + } + later { [self] in emit(.callHistory(list, Array(entries.prefix(count)))) } + } + + /// Three-way calling, only with `-PickupTestCallHold YES` (screenshots of the waiting-call card). + /// Off by default, so the call-waiting UI test keeps covering phones without it. + var supportsCallHold: Bool { UserDefaults.standard.bool(forKey: "PickupTestCallHold") } + + /// `AT+CHLD` like a Pixel: OK first, then the indicators. + func callHold(_ action: CallHoldAction) throws { + try requireLink() + guard supportsCallHold else { throw TransportError.unsupported } + later { [self] in + emit(.commandCompleted(.callHold, .ok)) + let waiting = callList.contains { $0.status == .waiting } + let held = callList.contains { $0.status == .held } + switch action { + case .holdActiveAcceptOther: + callList = callList.map { call in + var call = call + call.status = call.status == .active ? .held : .active + return call + } + if waiting { emit(.callSetup(.none)) } + emit(.callHeld(callList.count > 1 ? .activeAndHeld : (callList.first?.status == .held ? .heldOnly : .none))) + case .releaseActiveAcceptOther: + callList = callList.filter { $0.status != .active }.map { var call = $0; call.status = .active; return call } + if waiting { emit(.callSetup(.none)) } + if held { emit(.callHeld(.none)) } + case .releaseHeldOrRejectWaiting: + callList = callList.filter { $0.status != (waiting ? .waiting : .held) } + if waiting { emit(.callSetup(.none)) } else if held { emit(.callHeld(.none)) } + } + } + } + // MARK: Phone side (driven by the scenario) func ringIncoming(number: String?) { diff --git a/App/Sources/Pickup/Welcome.swift b/App/Sources/Pickup/Welcome.swift new file mode 100644 index 0000000..ad92d8c --- /dev/null +++ b/App/Sources/Pickup/Welcome.swift @@ -0,0 +1,52 @@ +import Foundation +import Security + +/// When the welcome window appears: at the first launch, and, while Pickup is not signed with a +/// Developer ID, after every update. An ad-hoc signature changes with each build, so macOS may ask for +/// the permissions again; the window explains why and offers them in one place. +enum WelcomePolicy { + enum Reason: Equatable { + case firstLaunch + /// A new version without a Developer ID signature: permissions may have been reset. + case updatedUnsigned + } + + static let shownForVersionKey = "welcomeShownForVersion" + + /// `shownFor`: the version the window was last shown for (nil = never). Rebuilds of the same + /// version (development) do not show it again. + static func reason(shownFor: String?, currentVersion: String, developerIDSigned: Bool) -> Reason? { + guard let shownFor else { return .firstLaunch } + guard !developerIDSigned, shownFor != currentVersion else { return nil } + return .updatedUnsigned + } + + /// "0.3.0 (42)": version and build, so every release counts as an update. + static var currentVersion: String { "\(AppInfo.version) (\(AppInfo.build))" } + + /// True if the running app carries a team identifier (Developer ID or App Store), false for + /// ad-hoc signed or unsigned builds. + static var isDeveloperIDSigned: Bool { + var code: SecCode? + guard SecCodeCopySelf([], &code) == errSecSuccess, let code else { return false } + var staticCode: SecStaticCode? + guard SecCodeCopyStaticCode(code, [], &staticCode) == errSecSuccess, let staticCode else { return false } + var info: CFDictionary? + guard SecCodeCopySigningInformation(staticCode, SecCSFlags(rawValue: kSecCSSigningInformation), &info) == errSecSuccess, + let dictionary = info as? [String: Any] else { return false } + return dictionary[kSecCodeInfoTeamIdentifier as String] is String + } + + /// Reason to show the window at this launch, from the saved state. Never in tests. + static func launchReason(defaults: UserDefaults = .standard) -> Reason? { + guard !TestMode.isActive else { return nil } + if UserDefaults.standard.bool(forKey: "PickupOpenWelcome") { return .firstLaunch } // visual checks + return reason(shownFor: defaults.string(forKey: shownForVersionKey), currentVersion: currentVersion, + developerIDSigned: isDeveloperIDSigned) + } + + static func markShown(defaults: UserDefaults = .standard) { + guard !TestMode.isActive else { return } + defaults.set(currentVersion, forKey: shownForVersionKey) + } +} diff --git a/App/Sources/Pickup/WelcomeView.swift b/App/Sources/Pickup/WelcomeView.swift new file mode 100644 index 0000000..87d721d --- /dev/null +++ b/App/Sources/Pickup/WelcomeView.swift @@ -0,0 +1,209 @@ +import AppKit +import SwiftUI + +/// First-run and after-update window: what Pickup does and every permission it needs, each with its +/// state and the button that grants it. +struct WelcomeView: View { + let reason: WelcomePolicy.Reason + let model: AppModel + let openSettings: () -> Void + let close: () -> Void + + private var permissions: PermissionsModel { model.permissions } + + var body: some View { + VStack(spacing: 0) { + header + .padding(.horizontal, 24) + .padding(.top, 20) + Form { + Section("On this Mac") { + phoneRow + bluetoothRow + contactsRow + notificationsRow + } + Section("On your phone") { + row(symbol: "iphone.gen3", color: .green, + title: Text("Phone calls and call history", comment: "Welcome window: title of the row about the permissions on the Android phone."), + detail: Text("In the phone’s Bluetooth settings, open this Mac and allow “Phone calls” and “Contacts and call history”. Without the call history, Pickup lists only the calls since it started.", + comment: "Welcome window: what to allow on the Android phone (Pixel wording: Phone calls, Contacts and call history).")) { + EmptyView() + } + } + } + .formStyle(.grouped) + .scrollDisabled(true) + .fixedSize(horizontal: false, vertical: true) + footer + .padding(.horizontal, 24) + .padding(.bottom, 20) + } + .frame(width: 520) + .task { await permissions.refresh() } + .onReceive(NotificationCenter.default.publisher(for: NSApplication.didBecomeActiveNotification)) { _ in + Task { await permissions.refresh() } + } + .accessibilityElement(children: .contain) + .accessibilityIdentifier("welcome-window") + } + + // MARK: Header and footer + + private var header: some View { + HStack(alignment: .top, spacing: 16) { + Image(nsImage: NSApp.applicationIconImage) + .resizable() + .frame(width: 64, height: 64) + .accessibilityHidden(true) + VStack(alignment: .leading, spacing: 6) { + Group { + switch reason { + case .firstLaunch: + Text("Welcome to Pickup!", comment: "Headline of the welcome window at the first launch.") + case .updatedUnsigned: + Text("Pickup was updated", comment: "Title of the welcome window after an update of an unsigned build.") + } + } + .font(.title2.weight(.semibold)) + .accessibilityAddTraits(.isHeader) + Group { + switch reason { + case .firstLaunch: + Text( + "Pickup controls the incoming and outgoing calls of your Android phone, much like Continuity does for the iPhone. The conversation stays on your phone. Check the permissions below once, then you’re ready.", + comment: "Welcome window text at the first launch. Continuity is Apple’s name for the iPhone feature; keep it.") + case .updatedUnsigned: + Text("Pickup isn’t signed by Apple yet, so macOS treats every update like a new app and may ask for its permissions again. Here you see them all at a glance.", + comment: "Welcome window text after an update of a build without an Apple Developer ID signature.") + } + } + .font(.callout) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + Spacer(minLength: 0) + } + } + + private var footer: some View { + HStack(alignment: .firstTextBaseline, spacing: 12) { + if reason == .firstLaunch && !WelcomePolicy.isDeveloperIDSigned { + Text("Pickup isn’t signed by Apple yet. After an update, macOS may ask again; this window then helps you.", + comment: "Small note at the bottom of the welcome window for builds without an Apple Developer ID signature.") + .font(.caption) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + Spacer(minLength: 0) + Button(action: close) { + Text("Done", comment: "Button that closes the welcome window.") + .frame(minWidth: 72) + } + .keyboardShortcut(.defaultAction) + .controlSize(.large) + .accessibilityIdentifier("welcome-done") + } + } + + // MARK: Rows + + private var phoneRow: some View { + row(symbol: "phone.fill", color: .green, + title: Text("Phone"), + detail: Text("Pair it in System Settings › Bluetooth first, then choose it in Pickup.", + comment: "Welcome window: how to set up the phone.")) { + switch model.connection { + case .connected(let name): + granted(Text("Connected to \(name)")) + case .noPhone: + Button { openSettings() } label: { + Text("Choose Phone…") + } + case .connecting(let name), .reconnecting(let name), .disconnected(let name): + Text(verbatim: name).foregroundStyle(.secondary) + } + } + } + + private var bluetoothRow: some View { + row(symbol: "wave.3.right", color: .blue, + title: Text("Bluetooth"), + detail: Text("Needed to connect to your phone.", comment: "Welcome window: why Pickup needs Bluetooth.")) { + switch permissions.bluetooth { + case .allowedAlways: + granted(Text("Allowed")) + case .notDetermined: + Text("Asked when connecting", comment: "Welcome window: macOS asks for Bluetooth the first time Pickup connects to the phone.") + .foregroundStyle(.secondary) + default: + Button { PermissionsModel.openBluetoothPrivacySettings() } label: { Text("Open Settings…", comment: "Welcome window: button that opens the matching privacy pane in System Settings.") } + } + } + } + + private var contactsRow: some View { + row(symbol: "person.crop.circle.fill", color: .gray, + title: Text("Contacts"), + detail: Text("Shows names and photos and lets you search your contacts. Optional.", + comment: "Welcome window: why Pickup asks for Contacts access.")) { + if permissions.contactsGranted { + granted(Text("Allowed")) + } else if permissions.contacts == .notDetermined { + Button { + Task { if await permissions.requestContacts() { model.contacts.reload() } } + } label: { Text("Allow", comment: "Welcome window: button that asks macOS for a permission.") } + .accessibilityIdentifier("welcome-allow-contacts") + } else { + Button { PermissionsModel.openContactsPrivacySettings() } label: { Text("Open Settings…") } + } + } + } + + private var notificationsRow: some View { + row(symbol: "bell.badge.fill", color: .red, + title: Text("Notifications"), + detail: Text("Tells you about missed calls, with “Call Back”. Optional.", + comment: "Welcome window: why Pickup asks to send notifications.")) { + if permissions.notificationsGranted { + granted(Text("Allowed")) + } else if permissions.notifications == .notDetermined { + Button { + Task { await permissions.requestNotifications() } + } label: { Text("Allow") } + .accessibilityIdentifier("welcome-allow-notifications") + } else { + Button { PermissionsModel.openNotificationSettings() } label: { Text("Open Settings…") } + } + } + } + + private func granted(_ text: Text) -> some View { + Label { text } icon: { + Image(systemName: "checkmark.circle.fill").foregroundStyle(Color(nsColor: .systemGreen)) + } + .foregroundStyle(.secondary) + } + + /// Icon in a coloured rounded square (like System Settings), title, explanation and the state or + /// action on the right. + private func row(symbol: String, color: Color, title: Text, detail: Text, + @ViewBuilder trailing: () -> Trailing) -> some View { + HStack(alignment: .center, spacing: 12) { + Image(systemName: symbol) + .font(.system(size: 13, weight: .semibold)) + .foregroundStyle(.white) + .frame(width: 26, height: 26) + .background(color.gradient, in: RoundedRectangle(cornerRadius: 6, style: .continuous)) + .accessibilityHidden(true) + VStack(alignment: .leading, spacing: 2) { + title.font(.body) + detail.font(.caption).foregroundStyle(.secondary).fixedSize(horizontal: false, vertical: true) + } + Spacer(minLength: 8) + trailing() + .fixedSize() + } + .accessibilityElement(children: .contain) + } +} diff --git a/App/Tests/PickupAppTests/ColorContrastTests.swift b/App/Tests/PickupAppTests/ColorContrastTests.swift index be74dc0..6a27809 100644 --- a/App/Tests/PickupAppTests/ColorContrastTests.swift +++ b/App/Tests/PickupAppTests/ColorContrastTests.swift @@ -5,8 +5,12 @@ import XCTest /// White symbols on the accept/call and warning circles need at least 3:1 (WCAG 1.4.11). final class ColorContrastTests: XCTestCase { - private func luminance(_ color: Color) throws -> Double { - let rgb = try XCTUnwrap(NSColor(color).usingColorSpace(.sRGB)) + private func luminance(_ color: Color, dark: Bool = false) throws -> Double { + var resolved: NSColor? + try XCTUnwrap(NSAppearance(named: dark ? .darkAqua : .aqua)).performAsCurrentDrawingAppearance { + resolved = NSColor(color).usingColorSpace(.sRGB) + } + let rgb = try XCTUnwrap(resolved) func linear(_ c: CGFloat) -> Double { let c = Double(c) return c <= 0.04045 ? c / 12.92 : pow((c + 0.055) / 1.055, 2.4) @@ -22,4 +26,52 @@ final class ColorContrastTests: XCTestCase { XCTAssertGreaterThanOrEqual(try contrastWithWhite(.callGreen), 3) XCTAssertGreaterThanOrEqual(try contrastWithWhite(.warningOrange), 3) } + + // MARK: Dialer list (WCAG 1.4.3 text 4.5:1, 1.4.11 selection outline 3:1) + + private static let lightBackgrounds: [UInt32] = [0xFFFFFF, 0xF0F0F0, 0xE4E4E4] + private static let darkBackgrounds: [UInt32] = [0x1E1E1E, 0x2A2A2A, 0x363636] + + private func ratio(_ color: Color, dark: Bool, on background: UInt32) throws -> Double { + let bg = Color.adaptive(light: background, dark: background) + let a = try luminance(color, dark: dark), b = try luminance(bg, dark: dark) + return (max(a, b) + 0.05) / (min(a, b) + 0.05) + } + + /// The selection fill (15 % over the background), as the background of a selected row. + private func selectedBackground(_ background: UInt32, dark: Bool) -> UInt32 { + let fill: UInt32 = dark ? 0x4DA3FF : 0x0064E1 + func channel(_ shift: UInt32) -> UInt32 { + let f = Double((fill >> shift) & 0xFF), b = Double((background >> shift) & 0xFF) + return UInt32((f * 0.15 + b * 0.85).rounded()) << shift + } + return channel(16) | channel(8) | channel(0) + } + + func testDialerListTextReachesFourAndAHalfToOne() throws { + for (dark, backgrounds) in [(false, Self.lightBackgrounds), (true, Self.darkBackgrounds)] { + for background in backgrounds { + for surface in [background, selectedBackground(background, dark: dark)] { + XCTAssertGreaterThanOrEqual(try ratio(.missedRed, dark: dark, on: surface), 4.5, "missed red on \(String(surface, radix: 16))") + XCTAssertGreaterThanOrEqual(try ratio(.rowSubtitle, dark: dark, on: surface), 4.5, "subtitle on \(String(surface, radix: 16))") + } + } + } + } + + func testSelectionOutlineReachesThreeToOne() throws { + for (dark, backgrounds) in [(false, Self.lightBackgrounds), (true, Self.darkBackgrounds)] { + for background in backgrounds { + XCTAssertGreaterThanOrEqual(try ratio(.selectionRing, dark: dark, on: background), 3) + XCTAssertGreaterThanOrEqual(try ratio(.selectionRing, dark: dark, on: selectedBackground(background, dark: dark)), 3) + } + } + } + + func testActiveTabLabelReachesFourAndAHalfToOne() throws { + for dark in [false, true] { + let a = try luminance(.activeSegmentText, dark: dark), b = try luminance(.activeSegment, dark: dark) + XCTAssertGreaterThanOrEqual((max(a, b) + 0.05) / (min(a, b) + 0.05), 4.5, dark ? "dark" : "light") + } + } } diff --git a/App/Tests/PickupAppTests/ContactCardTests.swift b/App/Tests/PickupAppTests/ContactCardTests.swift new file mode 100644 index 0000000..7d1addb --- /dev/null +++ b/App/Tests/PickupAppTests/ContactCardTests.swift @@ -0,0 +1,27 @@ +import XCTest +@testable import Pickup + +/// The vCard for "Add to Contacts…": data from the phone must never add lines or properties. +final class ContactCardTests: XCTestCase { + func testCardCarriesNameAndNumber() { + let card = ContactCard.vCard(name: "Jane Doe", number: "+49 30 0000123") + XCTAssertEqual(card, "BEGIN:VCARD\r\nVERSION:3.0\r\nN:Doe;Jane;;;\r\nFN:Jane Doe\r\nTEL;TYPE=VOICE:+49 30 0000123\r\nEND:VCARD\r\n") + XCTAssertTrue(ContactCard.vCard(name: nil, number: "+49300000123").contains("\r\nN:;;;;\r\nFN:\r\n")) + } + + func testPhoneDataCannotInjectProperties() { + let card = ContactCard.vCard(name: "Jane\r\nEMAIL:x@example.com;Doe", number: "123\nEND:VCARD") + let lines = card.components(separatedBy: "\r\n").filter { !$0.isEmpty } + XCTAssertEqual(lines.count, 6, card) + XCTAssertFalse(lines.contains { $0.hasPrefix("EMAIL") }) + XCTAssertEqual(lines.filter { $0 == "END:VCARD" }.count, 1) + XCTAssertEqual(ContactCard.escape("a,b;c\\d\u{07}"), #"a\,b\;c\\d"#) + } + + func testFileNameIsSafe() { + XCTAssertEqual(ContactCard.fileName(name: "Jane Doe", number: "1"), "Jane Doe.vcf") + XCTAssertEqual(ContactCard.fileName(name: "../x/y", number: "1"), "..xy.vcf") + XCTAssertEqual(ContactCard.fileName(name: nil, number: "+49 30 0000123"), "+49 30 0000123.vcf") + XCTAssertEqual(ContactCard.fileName(name: "", number: ""), "Contact.vcf") + } +} diff --git a/App/Tests/PickupAppTests/PhoneBatteryTests.swift b/App/Tests/PickupAppTests/PhoneBatteryTests.swift new file mode 100644 index 0000000..7f4e539 --- /dev/null +++ b/App/Tests/PickupAppTests/PhoneBatteryTests.swift @@ -0,0 +1,26 @@ +import PickupCore +import XCTest +@testable import Pickup + +final class PhoneBatteryTests: XCTestCase { + func testLevelsMapToTwentyPercentSteps() { + XCTAssertEqual((0...5).map { PhoneBattery.percent(level: $0) }, [0, 20, 40, 60, 80, 100]) + XCTAssertEqual(PhoneBattery.percent(level: 9), 100, "out-of-range values from the phone are clamped") + XCTAssertEqual(PhoneBattery.percent(level: -1), 0) + XCTAssertEqual(PhoneBattery.fraction(level: 3), 0.6, accuracy: 0.001) + } + + func testPercentTextFollowsTheLocale() { + XCTAssertEqual(PhoneBattery.percentText(level: 3, locale: Locale(identifier: "en_US")), "60%") + XCTAssertEqual(PhoneBattery.percentText(level: 3, locale: Locale(identifier: "de_DE")), "60\u{202F}%", "a narrow no-break space") + } +} + +final class MissedCallsTests: XCTestCase { + func testOnlyUnansweredIncomingCallsCountAsMissed() { + XCTAssertTrue(MissedCalls.isMissed(direction: .incoming, endReason: .missed)) + XCTAssertFalse(MissedCalls.isMissed(direction: .incoming, endReason: .declined), "declining on this Mac is a choice") + XCTAssertFalse(MissedCalls.isMissed(direction: .outgoing, endReason: .unanswered)) + XCTAssertFalse(MissedCalls.isMissed(direction: .incoming, endReason: .remoteEnded)) + } +} diff --git a/App/Tests/PickupAppTests/PopoverWidthTests.swift b/App/Tests/PickupAppTests/PopoverWidthTests.swift new file mode 100644 index 0000000..c7b78b7 --- /dev/null +++ b/App/Tests/PickupAppTests/PopoverWidthTests.swift @@ -0,0 +1,18 @@ +import XCTest +@testable import Pickup + +final class PopoverWidthTests: XCTestCase { + func testWidthStaysBetweenTheMinimumAndTwiceTheMinimum() { + XCTAssertEqual(PopoverWidth.maximum, 2 * PopoverWidth.minimum) + XCTAssertEqual(PopoverWidth.clamped(120), PopoverWidth.minimum) + XCTAssertEqual(PopoverWidth.clamped(10_000), PopoverWidth.maximum) + XCTAssertEqual(PopoverWidth.clamped(420.4), 420) + XCTAssertEqual(PopoverWidth.clamped(.nan), PopoverWidth.minimum, "a broken saved value falls back to the minimum") + } + + func testDraggingAnEdgeChangesTheWidthByTwiceTheDistance() { + XCTAssertEqual(PopoverWidth.resized(from: 300, outwardDelta: 40), 380) + XCTAssertEqual(PopoverWidth.resized(from: 400, outwardDelta: -30), 340) + XCTAssertEqual(PopoverWidth.resized(from: 300, outwardDelta: -30), 300) + } +} diff --git a/App/Tests/PickupAppTests/RecentCallRowsTests.swift b/App/Tests/PickupAppTests/RecentCallRowsTests.swift new file mode 100644 index 0000000..647730c --- /dev/null +++ b/App/Tests/PickupAppTests/RecentCallRowsTests.swift @@ -0,0 +1,45 @@ +import PickupCore +import XCTest +@testable import Pickup + +/// Recent call rows: numbers from the phone are untrusted and never dial a service code directly. +final class RecentCallRowsTests: XCTestCase { + func testRecentRowsCarryTheirNumberAndAStableID() { + let rows = DialerRows.recentRows([CallHistoryEntry(number: "+49300000123", name: "Jane Doe"), + CallHistoryEntry(number: "")], list: .dialed) + XCTAssertEqual(rows.map(\.id), ["recent|DC|0", "recent|DC|1"]) + guard case .recent(_, let number, let list) = rows[0].kind else { return XCTFail("not a recent row") } + XCTAssertEqual(number?.value, "+49300000123") + XCTAssertEqual(list, .dialed) + guard case .recent(_, let withheld, _) = rows[1].kind else { return XCTFail("not a recent row") } + XCTAssertNil(withheld, "a withheld caller cannot be called back") + XCTAssertFalse(rows.contains { $0.allowServiceCode }) + } + + func testServiceCodeFromThePhoneNeedsConfirmation() { + let rows = DialerRows.recentRows([CallHistoryEntry(number: "*#21#")], list: .dialed) + XCTAssertTrue(rows[0].requiresConfirmation) + XCTAssertFalse(rows[0].allowServiceCode) + } + + func testRedialUsesTheNewestDialableNumber() { + XCTAssertEqual(DialerRows.redialNumber(in: [CallHistoryEntry(number: ""), CallHistoryEntry(number: "+49300000123"), + CallHistoryEntry(number: "+49300000456")]), "+49300000123") + XCTAssertNil(DialerRows.redialNumber(in: [])) + } + + func testCopyableNumber() { + let rows = DialerRows.recentRows([CallHistoryEntry(number: "+49300000123", name: "Jane Doe"), CallHistoryEntry(number: "")], + list: .missed) + XCTAssertEqual(rows.map(\.copyableNumber), ["+49300000123", nil], "a withheld caller has nothing to copy") + XCTAssertEqual(DialerRows.rows(for: "+49 30 0000123", contacts: [], fromKeypad: false).first?.copyableNumber, + DialNumber("+49 30 0000123")?.value) + } + + func testTypeAheadAcceptsOnlyPrintableText() { + for text in ["J", "j", "5", "+", "*", "#", "ä"] { XCTAssertTrue(TypeAhead.accepts(text), text) } + for text in ["", " ", "\t", "\r", "\u{1B}", "\u{F700}", "\u{F701}", "\u{7F}"] { + XCTAssertFalse(TypeAhead.accepts(text), text.debugDescription) + } + } +} diff --git a/App/Tests/PickupAppTests/WelcomePolicyTests.swift b/App/Tests/PickupAppTests/WelcomePolicyTests.swift new file mode 100644 index 0000000..57e6141 --- /dev/null +++ b/App/Tests/PickupAppTests/WelcomePolicyTests.swift @@ -0,0 +1,19 @@ +import XCTest +@testable import Pickup + +final class WelcomePolicyTests: XCTestCase { + func testFirstLaunchAlwaysShowsTheWelcome() { + XCTAssertEqual(WelcomePolicy.reason(shownFor: nil, currentVersion: "0.3.0 (42)", developerIDSigned: false), .firstLaunch) + XCTAssertEqual(WelcomePolicy.reason(shownFor: nil, currentVersion: "1.0.0 (60)", developerIDSigned: true), .firstLaunch) + } + + func testUnsignedUpdatesShowItAgainBecausePermissionsMayBeReset() { + XCTAssertEqual(WelcomePolicy.reason(shownFor: "0.3.0 (42)", currentVersion: "0.4.0 (47)", developerIDSigned: false), .updatedUnsigned) + XCTAssertNil(WelcomePolicy.reason(shownFor: "0.3.0 (42)", currentVersion: "0.3.0 (42)", developerIDSigned: false), + "rebuilds of the same version (development) stay quiet") + } + + func testSignedUpdatesKeepTheirPermissionsAndStayQuiet() { + XCTAssertNil(WelcomePolicy.reason(shownFor: "1.0.0 (60)", currentVersion: "1.1.0 (65)", developerIDSigned: true)) + } +} diff --git a/App/Tests/PickupUITests/Cases/AX.swift b/App/Tests/PickupUITests/Cases/AX.swift index 32e45fe..bdaf098 100644 --- a/App/Tests/PickupUITests/Cases/AX.swift +++ b/App/Tests/PickupUITests/Cases/AX.swift @@ -118,11 +118,14 @@ enum Key { static let escape: CGKeyCode = 53 static let down: CGKeyCode = 125 static let up: CGKeyCode = 126 + static let left: CGKeyCode = 123 + static let right: CGKeyCode = 124 static let tab: CGKeyCode = 48 static let space: CGKeyCode = 49 static let comma: CGKeyCode = 43 static let q: CGKeyCode = 12 static let w: CGKeyCode = 13 + static let c: CGKeyCode = 8 } /// Keyboard events posted straight to the process (they do not go to whatever app is in front). diff --git a/App/Tests/PickupUITests/Cases/LayoutAccessibilityTests.swift b/App/Tests/PickupUITests/Cases/LayoutAccessibilityTests.swift index b2cfb2a..3ca3ac0 100644 --- a/App/Tests/PickupUITests/Cases/LayoutAccessibilityTests.swift +++ b/App/Tests/PickupUITests/Cases/LayoutAccessibilityTests.swift @@ -23,7 +23,8 @@ final class LayoutAccessibilityTests: PickupUITestCase { openSettings() let window = element("settings-window") let frame = try XCTUnwrap(stableFrame("settings-window"), "[\(language)] no window frame") - XCTAssertLessThanOrEqual(frame.height, 385, "[\(language)] Settings is \(frame.height) pt high") + // 405: the phone battery row (2026-10-30) added one row to the former 385. + XCTAssertLessThanOrEqual(frame.height, 405, "[\(language)] Settings is \(frame.height) pt high") let ids = Array(Set(settingsIdentifiers())).sorted() XCTAssertGreaterThanOrEqual(ids.count, 4, "[\(language)] only found \(ids)") assertControlsHaveLabels(in: window) diff --git a/App/Tests/PickupUITests/Cases/PopoverTests.swift b/App/Tests/PickupUITests/Cases/PopoverTests.swift index feb03f1..695daf8 100644 --- a/App/Tests/PickupUITests/Cases/PopoverTests.swift +++ b/App/Tests/PickupUITests/Cases/PopoverTests.swift @@ -1,3 +1,4 @@ +import AppKit import XCTest /// Popover, search, keypad, keyboard handling and window shortcuts (docs/TESTING.md, section 4). @@ -34,6 +35,84 @@ final class PopoverTests: PickupUITestCase { XCTAssertNotNil(appLog.range(of: #"dial .*123"#, options: .regularExpression)) } + func testRecentCallsAreReachedWithTabAndCalledWithReturn() throws { + try launch("outgoing-answered") + openPopover() + XCTAssertTrue(waitExists("recent-row-DC-2"), "the phone's dialed calls show while the field is empty") + XCTAssertFalse(element("recent-row-DC-0").isSelected, "no row starts selected: a stray Return never redials") + XCTAssertTrue(exists("recent-row-DC-0-call"), "every row has its own call button") + + // ↓ from the search field selects the first row, like in the search results. + keyboard.press(Key.down) + XCTAssertTrue(Wait.until(3) { self.element("recent-row-DC-0").isSelected }, "Down selects the first row") + keyboard.press(Key.down) + XCTAssertTrue(Wait.until(3) { self.element("recent-row-DC-1").isSelected }) + + // ⌘C copies the selected number (the pasteboard is restored afterwards). + let pasteboard = NSPasteboard.general + let saved = pasteboard.string(forType: .string) + defer { pasteboard.clearContents(); if let saved { pasteboard.setString(saved, forType: .string) } } + pasteboard.clearContents() + keyboard.press(Key.c, flags: .maskCommand) + XCTAssertTrue(Wait.until(3) { pasteboard.string(forType: .string)?.contains("0000") == true }, + "Command-C copies the selected row's number") + + keyboard.press(Key.escape) + XCTAssertTrue(Wait.until(3) { !self.element("recent-row-DC-1").isSelected }, "Esc returns to the search field") + XCTAssertTrue(exists("popover-root"), "…and keeps the popover open") + + // Tab follows the system order: field → tabs → list (which selects its first row). + keyboard.press(Key.tab) + XCTAssertFalse(element("recent-row-DC-0").isSelected, "the first Tab stops at the tabs") + keyboard.press(Key.tab) + XCTAssertTrue(Wait.until(3) { self.element("recent-row-DC-0").isSelected }, "the second Tab enters the list") + // Tabs: Outgoing (DC) → Incoming (RC) → Missed (MC). + keyboard.press(Key.right) + XCTAssertTrue(Wait.until(3) { self.exists("recent-row-RC-0") && self.element("recent-row-RC-0").isSelected }, + "Right switches to the next tab and keeps the keyboard in the list") + keyboard.press(Key.right) + XCTAssertTrue(Wait.until(3) { self.exists("recent-row-MC-0") && self.element("recent-row-MC-0").isSelected }) + keyboard.press(Key.left) + XCTAssertTrue(Wait.until(3) { self.exists("recent-row-RC-0") && self.element("recent-row-RC-0").isSelected }) + keyboard.press(Key.left) + XCTAssertTrue(Wait.until(3) { self.exists("recent-row-DC-0") && self.element("recent-row-DC-0").isSelected }) + XCTAssertFalse(appLog.contains("-> dial()"), "moving through the list never dials") + + keyboard.press(Key.returnKey) + XCTAssertTrue(waitExists("call-hud"), "Return calls the selected recent call") + XCTAssertNotNil(appLog.range(of: #"dial .*123"#, options: .regularExpression), "Jane's number was dialed:\n\(appLog)") + } + + /// Neither the popover nor the top bar may move when the keypad replaces the recent calls, in + /// English and in German (the native segmented control used to widen the content in German). + func testTogglingTheKeypadKeepsTheLayoutSteady() throws { + for language in ["en", "de"] { + try launch("idle", language: language) + openPopover() + XCTAssertTrue(waitExists("recent-row-DC-0")) + guard let before = stableFrame("popover-root"), let field = stableFrame("search-field"), + let tabs = stableFrame("recent-tabs") else { return XCTFail("[\(language)] no frames") } + XCTAssertEqual(tabs.minX, before.minX + 16, accuracy: 1, "[\(language)] tabs start at the content margin") + XCTAssertEqual(tabs.maxX, before.maxX - 16, accuracy: 1, "[\(language)] tabs end at the content margin") + XCTAssertTrue(element("keypad-toggle").press()) + XCTAssertTrue(waitExists("key-5")) + guard let keypad = stableFrame("popover-root"), let keypadField = stableFrame("search-field") else { + return XCTFail("[\(language)] no frames") + } + XCTAssertEqual(keypad.height, before.height, accuracy: 1, "[\(language)] the keypad takes the recent calls' place") + XCTAssertEqual(keypadField.minX, field.minX, accuracy: 0.5, "[\(language)] the search field does not move") + XCTAssertEqual(keypadField.width, field.width, accuracy: 0.5, "[\(language)] the search field keeps its width") + XCTAssertTrue(element("keypad-toggle").press()) + XCTAssertTrue(waitExists("recent-row-DC-0")) + guard let after = stableFrame("popover-root"), let afterField = stableFrame("search-field") else { + return XCTFail("[\(language)] no frames") + } + XCTAssertEqual(after.height, before.height, accuracy: 1) + XCTAssertEqual(afterField.minX, field.minX, accuracy: 0.5) + terminateApp() + } + } + func testTypingDigitsOpensTheKeypad() throws { try launch("idle") openPopover() diff --git a/HISTORY.md b/HISTORY.md new file mode 100644 index 0000000..cbde46e --- /dev/null +++ b/HISTORY.md @@ -0,0 +1,51 @@ +# History + +What changed in each version of Pickup, in plain words. `CHANGELOG.md` is the technical list generated from the +commits at release time; this file is written by hand and can be used for release notes, the website or posts. + +## 0.3.0 (upcoming) + +**Recent calls in the menu bar.** With an empty search field, the popover shows your phone's recent calls in three +tabs: Outgoing, Incoming and Missed. Every row has its own call button; unknown numbers can be added to the +contacts app you use on your Mac (Contacts, Cardhop, …), and ⌘C copies a number. The list comes straight from the +phone's call log over Bluetooth and is never stored on the Mac. While it loads for the first time, placeholder rows +show where it will appear. + +**Call waiting, hold and swap.** When a second call comes in, the call window offers *Hold & Accept*, *End & Accept* +and *Decline*. A call can be put on hold and resumed, and with two calls you can swap between them or end the one on +hold. Works with phones that support three-way calling (the Pixel does). + +**Never miss a call.** A missed call puts a small red dot on the menu bar icon, and the notification now has a +*Call Back* button. + +**Your phone's battery.** Shown next to "Connected to …" in the popover, as a percentage in the menu bar, or not at +all (Settings). + +**More comfortable.** +- The popover can be made wider by dragging its edge (up to twice its width); search and lists grow with it. +- Full keyboard control of the recent calls: Tab, the arrow keys, Return, Esc, ⌘1–3. +- Round keypad keys with more room below the search field. +- A welcome window explains the permissions Pickup needs, on first launch and, while Pickup is not yet signed by + Apple, after updates. +- The German version now says "du". +- The About window links to the source code and to the author's website. + +**Fixes.** A crash right after connecting (call lists were read while macOS was still setting up the link), a +recent-calls spinner that could run forever, and a number of accessibility improvements (contrast, VoiceOver +announcements, keyboard focus). + +## 0.2.0 (2026-10-23) + +- The menu bar icon brings an open call window to the front; Return ends a focused call. +- Larger text and higher contrast in the call windows. +- Hanging up shows its progress, and the call window closes once the phone has ended the call. + +## 0.1.0 (2026-10-13) + +The first public version. +- Incoming call banner with name, number and photo from your contacts, with Accept and Decline. +- Call window with the running time and End Call. +- Dial from the menu bar: a Phone-style popover with keypad and contact search. +- Phone number links (`tel:`, `callto:`) place the call on your phone after a confirmation. +- Missed call notifications, a global shortcut, launch at login, automatic reconnects. +- Native Liquid Glass design for macOS 26, light and dark, English and German. diff --git a/README.md b/README.md index 13b1cd8..462a5e6 100644 --- a/README.md +++ b/README.md @@ -10,10 +10,7 @@ Your phone rings, your Mac shows who is calling. Accept, decline, dial. Nothing else in the way.

- - - An incoming call from Jane Doe as a banner on the Mac, next to the Pickup menu bar popover with keypad and contact search - + The Pickup menu bar popover with the phone's recent calls, an incoming call from Jane Doe as a banner, and the call window with a second caller waiting

## Why Pickup @@ -25,20 +22,27 @@ Your phone rings, your Mac shows who is calling. Accept, decline, dial. Nothing ## Features - **Incoming call banner** with caller name (from your Contacts), number and photo, with **Accept** and **Decline**. -- **Call window** with the running time and **End Call**, plus a note when a second call is waiting. +- **Call window** with the running time and **End Call**. **Hold**, **Swap** between two calls, and when a second call comes in: **Hold & Accept**, **End & Accept** or **Decline** (with phones that support three-way calling). +- **Recent calls** from your phone (Outgoing, Incoming, Missed), with a call button on every row, redial, **Add to Contacts** for unknown numbers and full keyboard control. Nothing is stored on your Mac. - **Dial from the menu bar:** a Phone-style popover with a collapsible keypad and **search by name**. Arrow keys and Return, or just type digits and the keypad opens by itself. - **Phone number links:** click a `tel:` or `callto:` link anywhere and Pickup places the call on your phone (always after a confirmation). -- **Missed call notifications**, a **global shortcut** to open the popover from anywhere, **launch at login**. +- **Missed calls** as a red dot on the menu bar icon and as a notification with **Call Back**, the phone's **battery level** in the popover or the menu bar, a **global shortcut** to open the popover from anywhere, **launch at login**. - **Reconnects by itself** when your phone goes out of range, and after the Mac wakes from sleep. +- A **resizable** popover and a **welcome window** that explains the permissions Pickup needs. - Native **Liquid Glass** design for macOS 26, light and dark, English and German, keyboard and VoiceOver friendly. - - + + + + +
Call window with running time and End Call buttonPickup settings: phone, global shortcut, phone links, contacts, notifications, launch at loginThe popover with the recent calls, and with the keypad open while searching for a contact
Call window with a second caller waiting (Hold & Accept, End & Accept, Decline) and with a call on hold (Swap)Pickup settings: phone, phone battery, global shortcut, phone links, contacts, notifications, launch at login
+What changed in each version: [HISTORY.md](HISTORY.md). + ## Requirements - A Mac with **Apple Silicon** running **macOS 26 (Tahoe) or newer**. diff --git a/Sources/PickupBluetooth/AppleHFPTransport.swift b/Sources/PickupBluetooth/AppleHFPTransport.swift index 6797486..14f0f66 100644 --- a/Sources/PickupBluetooth/AppleHFPTransport.swift +++ b/Sources/PickupBluetooth/AppleHFPTransport.swift @@ -51,6 +51,16 @@ public final class AppleHFPTransport: HFPTransport, ATWriteSink { public var deviceAppearedDelay: TimeInterval = 2.5 /// An SLC that still looks up after an unconfirmed disconnect must answer `AT+CLCC` within this. public var verifySLC: TimeInterval = 3 + /// Longest wait for IOBluetooth's own follow-up commands after the SLC (`AT+VGS`, `AT+VGM`) + /// before a call list is read anyway. Interleaving ours with them crashed IOBluetooth on + /// 2026-10-29 (see `readCallHistory`); usually they are sent and answered much sooner. + public var historyQuietPeriod: TimeInterval = 10 + /// How often a waiting call list read checks again whether the link is idle. + public var historyRetry: TimeInterval = 0.5 + /// A call list command the phone does not answer within this is given up, with the lists still + /// queued (seen on 2026-10-30: the command was never written after a failed write of + /// IOBluetooth's own `AT+VGM`, and the read waited forever). + public var historyCommand: TimeInterval = 8 public init() {} } @@ -135,6 +145,21 @@ public final class AppleHFPTransport: HFPTransport, ATWriteSink { private var incomingSanitizer = ATLogSanitizer() private let outgoingTapInstalled: Bool private var fallbackCalls: [HFPCall] = [] + /// Call lists still to be read, in request order. + private var historyQueue: [(list: CallHistoryList, count: Int)] = [] + /// The call list read in progress: the phonebook is being selected, then read. + private var historyStep: (list: CallHistoryList, count: Int, reading: Bool)? + /// When the current SLC came up; call list reads wait `timeouts.historyQuietPeriod` after it. + private var slcEstablishedAt: Date? + /// IOBluetooth's follow-up commands of the current SLC seen on the wire (they may go out even + /// before the SLC counts as up). Once both went out and were answered, call lists may be read. + private var slcFollowUpsSent: Set = [] + private static let slcFollowUps: Set = ["AT+VGS", "AT+VGM"] + private var historyRetryTask: ScheduledTask? + /// Gives up the current call list command if the phone never answers it. + private var historyTimeoutTask: ScheduledTask? + /// When the first queued list started waiting for an idle link; the wait is bounded (see `sendNextHistoryCommand`). + private var historyWaitingSince: Date? private var fallbackFlush: ScheduledTask? private var connectObserver: AnyObject? @@ -251,6 +276,10 @@ public final class AppleHFPTransport: HFPTransport, ATWriteSink { verifyTimer = nil deviceAppearedTask = nil fallbackFlush = nil + historyRetryTask?.cancel() + historyRetryTask = nil + historyTimeoutTask?.cancel() + historyTimeoutTask = nil connectObserver = nil // The registry parks the object (closes its link, keeps it alive and listening) and // remembers a disconnect in flight for the next owner. @@ -296,6 +325,113 @@ public final class AppleHFPTransport: HFPTransport, ATWriteSink { } } + public func readCallHistory(_ list: CallHistoryList, count: Int) throws { + guard !isInvalidated else { throw TransportError.invalidated } + // Without the write hook, the phone's answers cannot be matched to the commands. + guard outgoingTapInstalled else { throw TransportError.unsupported } + guard linkState == .connected, link != nil else { throw TransportError.notConnected } + if historyQueue.isEmpty && historyStep == nil { historyWaitingSince = scheduler.now } + historyQueue.append((list, max(1, count))) + sendNextHistoryCommand() + } + + /// Starts the next queued call list read: `AT+CPBS=""`, then `AT+CPBR=1,` once the + /// phone selected it (see `historyExchangeEnded`). + /// + /// Crash of 2026-10-29 (SIGSEGV in `-[IOBluetoothHandsFreeDevice processResultCode:]`): the read + /// started 2 s after the SLC, while IOBluetooth's own `AT+VGS`/`AT+VGM` were still due; they went + /// out 6 s late and IOBluetooth crashed on the next incoming data. Reads that started after those + /// commands never failed. So a read starts only after the quiet period and when no command is + /// waiting for its reply. + private func sendNextHistoryCommand() { + guard historyStep == nil, !historyQueue.isEmpty, historyRetryTask == nil else { return } + let followUpsDone = slcFollowUpsSent.isSuperset(of: Self.slcFollowUps) + let quietUntil = followUpsDone ? scheduler.now + : slcEstablishedAt.map { $0.addingTimeInterval(timeouts.historyQuietPeriod) } ?? scheduler.now + if scheduler.now < quietUntil || tracker.pendingCount > 0 { + // A command that never gets an answer keeps the link from looking idle: give up the queued lists + // after the quiet period plus one command time, instead of waiting forever (IOBluetooth's safety + // gate is never bypassed). + if let since = historyWaitingSince, + scheduler.now.timeIntervalSince(since) >= timeouts.historyQuietPeriod + timeouts.historyCommand { + log.log(.warning, "link never became idle for the call list read; giving up") + historyUnanswered() + return + } + // Check again soon: the follow-ups may be answered long before the quiet period ends. + let delay = timeouts.historyRetry + historyRetryTask = scheduler.schedule(after: delay) { [weak self] in + guard let self else { return } + self.historyRetryTask = nil + guard self.linkState == .connected else { return } + self.sendNextHistoryCommand() + } + return + } + let next = historyQueue.removeFirst() + historyWaitingSince = nil + historyStep = (next.list, next.count, false) + sendHistoryCommand("AT+CPBS=\"\(next.list.rawValue)\"") + } + + private func sendHistoryCommand(_ line: String) { + historyTimeoutTask?.cancel() + historyTimeoutTask = scheduler.schedule(after: timeouts.historyCommand) { [weak self] in + self?.historyTimedOut() + } + do { + try issue("sendATCommand(\(line))") { $0.sendATCommand(line) } + } catch { + historyExchangeEnded(nil) + } + } + + /// No answer: this list and the queued ones are reported as not answered (not as refused), so + /// the engine asks again later. The link is evidently not taking commands now. + private func historyTimedOut() { + historyTimeoutTask = nil + guard let step = historyStep else { return } + log.log(.warning, "no answer to the call list read (\(step.list.rawValue)) within \(timeouts.historyCommand) s; giving up") + historyUnanswered() + } + + /// The current and all queued lists got no answer (timeout, discarded or not sent). Not a refusal: + /// that needs an explicit error from the phone. + private func historyUnanswered() { + historyTimeoutTask?.cancel() + historyTimeoutTask = nil + historyRetryTask?.cancel() + historyRetryTask = nil + let lists = (historyStep.map { [$0.list] } ?? []) + historyQueue.map(\.list) + historyStep = nil + historyQueue.removeAll() + historyWaitingSince = nil + for list in lists { emit(.callHistoryUnanswered(list)) } + } + + /// The phone answered the current call list command (nil = discarded or not sent: unanswered). + private func historyExchangeEnded(_ exchange: ATExchange?) { + guard let step = historyStep else { return } + guard let exchange else { + historyUnanswered() + return + } + // A late answer to an earlier, given-up command does not belong to this step. + if exchange.command != (step.reading ? .readPhonebook : .selectPhonebook) { return } + historyTimeoutTask?.cancel() + historyTimeoutTask = nil + if exchange.result.isSuccess, !step.reading { + historyStep = (step.list, step.count, true) + sendHistoryCommand("AT+CPBR=1,\(step.count)") + return + } + historyStep = nil + // An explicit error is a refusal (no access to the call history); success carries the entries. + let entries = exchange.result.isSuccess ? exchange.responses.compactMap(ATParser.parseCPBR) : nil + emit(.callHistory(step.list, entries)) + sendNextHistoryCommand() + } + /// Checks the link now and runs the command on it, deferred to the next main-queue turn if an /// IOBluetooth callback is running (IOBluetooth's command queue is not re-entrant). A deferred /// command is dropped if the link went down in between. @@ -552,6 +688,7 @@ public final class AppleHFPTransport: HFPTransport, ATWriteSink { default: break } + slcEstablishedAt = scheduler.now setLinkState(.connected) unconfirmedDisconnects = 0 setLinkSetupBlocked(false) @@ -571,6 +708,12 @@ public final class AppleHFPTransport: HFPTransport, ATWriteSink { /// IOBluetooth sent `AT+BRSF` (always the first SLC command) on our link while the transport is /// disconnected: the phone reconnected on its own and IOBluetooth runs the SLC set-up. Follow /// it instead of starting a second one. + private func noteSLCFollowUp(_ line: String) { + let command = line.trimmingCharacters(in: .whitespacesAndNewlines).uppercased() + if command.hasPrefix("AT+BRSF") { slcFollowUpsSent = [] } // a new SLC set-up begins + if let name = Self.slcFollowUps.first(where: { command.hasPrefix($0 + "=") }) { slcFollowUpsSent.insert(name) } + } + private func noteSLCStart(_ line: String) { guard linkState == .disconnected, !isInvalidated, line.trimmingCharacters(in: .whitespaces).uppercased().hasPrefix("AT+BRSF") else { return } @@ -616,6 +759,14 @@ public final class AppleHFPTransport: HFPTransport, ATWriteSink { } if state != .connected { tracker.reset() + historyQueue.removeAll() + historyStep = nil + historyRetryTask?.cancel() + historyRetryTask = nil + historyTimeoutTask?.cancel() + historyTimeoutTask = nil + slcEstablishedAt = nil + slcFollowUpsSent = [] } emit(.linkStateChanged(state)) } @@ -675,6 +826,7 @@ public final class AppleHFPTransport: HFPTransport, ATWriteSink { // Runs inside IOBluetooth's write (inside its command queue): defer any command. inCallback { noteSLCStart(line) + noteSLCFollowUp(line) handle(tracker.outgoing(line, at: scheduler.now)) } } @@ -702,6 +854,7 @@ public final class AppleHFPTransport: HFPTransport, ATWriteSink { break // Indicators, RING and +CLIP arrive through the delegate. case .discarded(let command): log.log(.warning, "no reply to \(command); dropped from the command queue") + if command == .selectPhonebook || command == .readPhonebook { historyExchangeEnded(nil) } case .completed(let exchange): if isVerifyingSLC, exchange.command == .currentCalls, let link { log.log(.state, "[\(linkTag)] SLC answered AT+CLCC (\(exchange.result)); adopting it") @@ -711,6 +864,9 @@ public final class AppleHFPTransport: HFPTransport, ATWriteSink { emit(.currentCalls(exchange.currentCalls)) } emit(.commandCompleted(exchange.command, exchange.result)) + if exchange.command == .selectPhonebook || exchange.command == .readPhonebook { + historyExchangeEnded(exchange) + } } } } @@ -729,7 +885,7 @@ public final class AppleHFPTransport: HFPTransport, ATWriteSink { /// to an SLC nobody wants (e.g. one completing during a disconnect). private func emit(_ event: TransportEvent) { switch event { - case .indicator, .ring, .callerID, .callWaiting, .currentCalls, .commandCompleted: + case .indicator, .ring, .callerID, .callWaiting, .currentCalls, .commandCompleted, .callHistory, .callHistoryUnanswered: guard linkState == .connecting || linkState == .connected else { log.log(.state, "dropped \(event) while \(linkState.rawValue) [\(linkTag)]") return @@ -741,6 +897,29 @@ public final class AppleHFPTransport: HFPTransport, ATWriteSink { } } +// MARK: - Three-way calling + +extension AppleHFPTransport { + /// `+BRSF` bit 0 (three-way calling) and `+CHLD` modes 0, 1 and 2 (IOBluetooth's bitmap: bit 0 = "0", + /// bit 1 = "1", bit 3 = "2"; see `HFPFeatures.callHoldModes`). + public var supportsCallHold: Bool { + // Without the write hook the phone's OK cannot be matched to AT+CHLD (as for the call list reads). + guard outgoingTapInstalled, let link, linkState == .connected else { return false } + return Self.supportsCallHold(features: link.audioGatewayFeatures, holdModes: link.callHoldModes) + } + + nonisolated static func supportsCallHold(features: UInt32, holdModes: UInt32) -> Bool { + let required: UInt32 = 0b1011 + return features & 1 != 0 && holdModes & required == required + } + + public func callHold(_ action: CallHoldAction) throws { + guard supportsCallHold else { throw TransportError.unsupported } + let line = "AT+CHLD=\(action.rawValue)" + try issue("sendATCommand(\(line))") { $0.sendATCommand(line) } + } +} + // MARK: - Delegate callbacks extension AppleHFPTransport: HandsFreeDelegateTarget { diff --git a/Sources/PickupBluetooth/HandsFreeLink.swift b/Sources/PickupBluetooth/HandsFreeLink.swift index 6c571de..8319f9b 100644 --- a/Sources/PickupBluetooth/HandsFreeLink.swift +++ b/Sources/PickupBluetooth/HandsFreeLink.swift @@ -16,6 +16,9 @@ protocol HandsFreeLink: AnyObject { /// The phone's baseband (ACL) link is up. var isACLConnected: Bool { get } var deviceName: String? { get } + /// The phone's `+BRSF` feature bitmap and `+CHLD` modes from the SLC set-up (0 until known). + var audioGatewayFeatures: UInt32 { get } + var callHoldModes: UInt32 { get } /// Makes `delegate` receive the callbacks and `incoming` the raw AT lines. Every delegate ever /// attached is kept alive by the link (`delegate` is `assign` in IOBluetooth). @@ -33,6 +36,8 @@ protocol HandsFreeLink: AnyObject { func sendDTMF(_ digit: String) func transferAudioToPhone() func currentCallList() + /// Sends a raw AT command (IOBluetooth appends the CR and queues it behind pending commands). + func sendATCommand(_ line: String) /// True if `channel` (an `IOBluetoothRFCOMMChannel`) belongs to this link. func owns(channel: AnyObject) -> Bool @@ -59,6 +64,8 @@ final class IOBluetoothHandsFreeLink: HandsFreeLink { var isSCOConnected: Bool { device.isSCOConnected() } var isACLConnected: Bool { device.device?.isConnected() ?? false } var deviceName: String? { device.device?.name } + var audioGatewayFeatures: UInt32 { device.deviceSupportedFeatures } + var callHoldModes: UInt32 { device.deviceCallHoldModes } func attach(delegate: HandsFreeDelegate, incoming: @escaping PUBATLogHandler) { if !delegates.contains(where: { $0 === delegate }) { delegates.append(delegate) } @@ -82,6 +89,7 @@ final class IOBluetoothHandsFreeLink: HandsFreeLink { func sendDTMF(_ digit: String) { device.sendDTMF(digit) } func transferAudioToPhone() { device.transferAudioToPhone() } func currentCallList() { device.currentCallList() } + func sendATCommand(_ line: String) { device.send(atCommand: line) } func owns(channel: AnyObject) -> Bool { guard let channel = channel as? IOBluetoothRFCOMMChannel else { return false } diff --git a/Sources/PickupCore/CallEngine+Audio.swift b/Sources/PickupCore/CallEngine+Audio.swift new file mode 100644 index 0000000..4891eb1 --- /dev/null +++ b/Sources/PickupCore/CallEngine+Audio.swift @@ -0,0 +1,58 @@ +import Foundation + +// Call audio: handing SCO back to the phone (remote-control mode and the per-call fallback). Split from +// CallEngine.swift; the state lives there. +extension CallEngine { + /// Per-call fallback: move the audio to the phone and leave a notice. The call continues in + /// remote-control mode. Safe to call in any mode and phase. Returns false if the transport + /// could not send the request (it is retried when the link comes up). + @discardableResult + public func handBackAudioToPhone(_ reason: AudioFallbackReason) -> Bool { + log.log(.command, "hand audio back to phone (\(reason.rawValue)) -> transferAudioToPhone()") + audioWatchdogTask?.cancel() + audioWatchdogTask = nil + do { + try transport.transferAudioToPhone() + } catch { + log.log(.error, "transferAudioToPhone failed: \(error)") + return false + } + expectingAudioClose = true + lastAudioFallback = AudioFallback(reason: reason, date: scheduler.now, callID: call?.id) + return true + } + + func audioConnectionChanged(isOpen: Bool) { + if isOpen { + // The transport re-reports an open SCO after the SLC is up; a hand-back may be under way. + let handBackPending = audioRoute == .mac && expectingAudioClose + audioRoute = .mac + audioWatchdogTask?.cancel() + audioWatchdogTask = nil + if !isAudioOnMacEnabled, !handBackPending { + // Remote-control mode: the phone opened SCO on its own; the conversation belongs on the phone. + handBackAudioToPhone(.audioOnMacDisabled) + } + } else { + let wasOnMac = audioRoute == .mac + audioRoute = .phone + if wasOnMac, !expectingAudioClose, isAudioOnMacEnabled, phase == .active || phase == .held { + lastAudioFallback = AudioFallback(reason: .audioDropped, date: scheduler.now, callID: call?.id) + log.log(.warning, "audio link dropped during the call; audio is on the phone now") + } + expectingAudioClose = false + } + } + + func startAudioWatchdog() { + guard isAudioOnMacEnabled else { return } + audioWatchdogTask?.cancel() + audioWatchdogTask = scheduler.schedule(after: configuration.audioSetupTimeout) { [weak self] in + guard let self else { return } + self.audioWatchdogTask = nil + if self.audioRoute != .mac, self.phase == .active { + self.handBackAudioToPhone(.audioSetupTimedOut) + } + } + } +} diff --git a/Sources/PickupCore/CallEngine+CallHold.swift b/Sources/PickupCore/CallEngine+CallHold.swift new file mode 100644 index 0000000..cf8bb1d --- /dev/null +++ b/Sources/PickupCore/CallEngine+CallHold.swift @@ -0,0 +1,184 @@ +import Foundation + +// Three-way calling (AT+CHLD): hold and resume, swap, and the waiting call (take it holding or ending the +// current call, or decline it). Split from CallEngine.swift to keep the type readable; the state lives there. +extension CallEngine { + /// Puts the call on hold, or resumes it. Only with a single call. + public func toggleHold() throws { + try requireCallHold() + guard heldCall == nil, waitingCall == nil, phase == .active || phase == .held else { + throw CallEngineError.invalidPhase(phase) + } + try sendHold(.holdActiveAcceptOther, .toggle) + } + + /// Swaps the active and the held call. + public func swapCalls() throws { + try requireCallHold() + // With a third (waiting) caller, CHLD=2 would take that call instead of swapping. + guard heldCall != nil, waitingCall == nil, phase == .active else { throw CallEngineError.invalidPhase(phase) } + try sendHold(.holdActiveAcceptOther, .swap) + } + + /// Takes the waiting call. `endingCurrent`: ends the current call instead of holding it. With a + /// call already on hold, only ending the current call is possible. + public func acceptWaitingCall(endingCurrent: Bool) throws { + try requireCallHold() + guard waitingCall != nil, phase == .active || phase == .held else { throw CallEngineError.invalidPhase(phase) } + guard endingCurrent || heldCall == nil else { throw CallEngineError.unsupported } + // CHLD=1 ends active calls only: a call that is on hold would stay, not end. + guard !endingCurrent || phase == .active else { throw CallEngineError.invalidPhase(phase) } + if endingCurrent, let call { localEndRequestedFor = call.id } + try sendHold(endingCurrent ? .releaseActiveAcceptOther : .holdActiveAcceptOther, + endingCurrent ? .acceptWaitingEnding : .acceptWaitingHolding) + } + + /// Rejects the waiting call; the current call goes on. + public func rejectWaitingCall() throws { + try requireCallHold() + guard waitingCall != nil else { throw CallEngineError.invalidPhase(phase) } + try sendHold(.releaseHeldOrRejectWaiting, .rejectWaiting) + } + + /// Ends the held call; the active call goes on. + public func endHeldCall() throws { + try requireCallHold() + guard heldCall != nil, waitingCall == nil else { throw CallEngineError.invalidPhase(phase) } + try sendHold(.releaseHeldOrRejectWaiting, .endHeld) + } + + func requireCallHold() throws { + try requireConnected() + guard transport.supportsCallHold else { throw CallEngineError.unsupported } + } + + /// One three-way calling command at a time: a reply could otherwise be taken for the wrong request. + func sendHold(_ action: CallHoldAction, _ request: HoldRequest) throws { + guard holdRequest == nil else { throw CallEngineError.invalidPhase(phase) } + log.log(.command, "call hold \(action.rawValue) (\(request)) -> callHold()") + try perform { try transport.callHold(action) } + holdRequest = request + holdTimeoutTask?.cancel() + holdTimeoutTask = scheduler.schedule(after: configuration.callHoldReplyTimeout) { [weak self] in + guard let self, self.holdRequest != nil else { return } + self.log.log(.warning, "no answer to the call hold command; giving up") + if self.holdRequest == .endActive || self.holdRequest == .acceptWaitingEnding { self.localEndRequestedFor = nil } + self.holdRequest = nil + self.holdTimeoutTask = nil + if self.phase.isLive { self.requestCallList(resetAttempts: true) } + } + } + + /// A waiting caller that stopped ringing and was not taken (declined or gave up): a missed call + /// (recent calls, red dot, notification). + func finalizeResolvedWaiting() { + resolvedWaitingTask?.cancel() + resolvedWaitingTask = nil + guard let waiting = resolvedWaiting?.call else { return } + resolvedWaiting = nil + recordBackground(waiting, .missed) + } + + var recentlyResolvedWaiting: CallInfo? { + guard let resolvedWaiting, + scheduler.now.timeIntervalSince(resolvedWaiting.at) <= configuration.waitingCallTakenWindow else { return nil } + return resolvedWaiting.call + } + + /// The waiting call becomes the active call; the current one is held or ended. + func promoteWaiting(holdingCurrent: Bool) { + guard let waiting = waitingCall ?? recentlyResolvedWaiting, var current = call, + current.phase == .active || current.phase == .held else { return } + waitingCall = nil + resolvedWaiting = nil + resolvedWaitingTask?.cancel() + resolvedWaitingTask = nil + var next = waiting + next.phase = .active + next.connectedAt = scheduler.now + if holdingCurrent { + transition(¤t, to: .held) + heldCall = current + } else { + finish(localEndRequestedFor == current.id ? .hungUp : .remoteEnded) + endedClearTask?.cancel() + endedClearTask = nil + } + log.log(.state, "call \(next.id.uuidString.prefix(8)) waiting -> active\(holdingCurrent ? "; previous call held" : "")") + self.call = next + if next.callerID == .unknown { requestCallList(resetAttempts: true) } + } + + /// The active call ended; the held call takes its place (`phase`: held, or active when the phone + /// resumed it). + func resumeHeld(after reason: CallEndReason, phase: CallPhase) { + guard let held = heldCall else { return } + heldCall = nil + holdRequest = nil + finish(reason) + endedClearTask?.cancel() + endedClearTask = nil + var resumed = held + transition(&resumed, to: phase) + self.call = resumed + } + + func swapActiveAndHeld() { + guard var active = call, var held = heldCall, active.phase == .active else { return } + transition(&active, to: .held) + transition(&held, to: .active) + heldCall = active + self.call = held + } + + /// A call that ended while it was not the shown call (held or waiting): into the recent calls. + func recordBackground(_ background: CallInfo, _ reason: CallEndReason) { + var ended = background + ended.phase = .ended + ended.endedAt = scheduler.now + ended.endReason = reason + log.log(.state, "call \(ended.id.uuidString.prefix(8)) ended in the background: \(reason.rawValue)") + callHistory.record(ended) + onCallFinished?(ended) + } + + /// The phone answered a three-way calling command. Success applies the request (unless an + /// indicator already did); the call list then confirms numbers and states. + func holdCompleted(_ result: CommandResult) { + guard let request = holdRequest else { return } + holdRequest = nil + holdTimeoutTask?.cancel() + holdTimeoutTask = nil + guard result.isSuccess else { + lastCommandFailure = CommandFailure(command: .callHold, result: result, date: scheduler.now) + if request == .endActive || request == .acceptWaitingEnding { localEndRequestedFor = nil } + log.log(.warning, "phone rejected the call hold command (\(request)): \(result)") + return + } + switch request { + case .toggle: + break // The callheld indicator moves the call between active and held. + case .swap: + swapActiveAndHeld() + case .acceptWaitingHolding: + promoteWaiting(holdingCurrent: true) + case .acceptWaitingEnding: + promoteWaiting(holdingCurrent: false) + case .rejectWaiting: + if let waiting = waitingCall { + waitingCall = nil + recordBackground(waiting, .declined) + } + case .endActive: + if let call, heldCall != nil { + resumeHeld(after: localEndRequestedFor == call.id ? .hungUp : .remoteEnded, phase: .active) + } + case .endHeld: + if let held = heldCall { + heldCall = nil + recordBackground(held, .hungUp) + } + } + if phase.isLive { requestCallList(resetAttempts: true) } + } +} diff --git a/Sources/PickupCore/CallEngine+History.swift b/Sources/PickupCore/CallEngine+History.swift new file mode 100644 index 0000000..625ff7e --- /dev/null +++ b/Sources/PickupCore/CallEngine+History.swift @@ -0,0 +1,53 @@ +import Foundation + +// Recent calls: when the phone's call lists are read, and the call lost with the link that is recorded only +// if it is not resumed. Split from CallEngine.swift; the state lives there. +extension CallEngine { + /// Asks the phone for the current call list again (refreshes the number). + /// Reads the call lists from the phone again (e.g. when the dialer opens). Skipped while a call is + /// live, while a read is running, or if the last read started less than + /// `callHistoryMinimumInterval` ago. Without an answer the session list stays in use. + public func refreshCallHistory() { + if let last = lastHistoryRequest, scheduler.now.timeIntervalSince(last) < configuration.callHistoryMinimumInterval { + return + } + requestCallHistory() + } + + /// Reads the call lists a little later: after the link came up or a call ended. A phone that + /// refused before is not asked again automatically (only when the dialer opens). + func scheduleHistoryRefresh() { + guard lastHistoryRequest == nil || !callHistory.phoneRefused else { return } + historyRefreshTask?.cancel() + historyRefreshTask = scheduler.schedule(after: configuration.callHistoryRefreshDelay) { [weak self] in + guard let self else { return } + self.historyRefreshTask = nil + self.requestCallHistory() + } + } + + func requestCallHistory() { + guard !isInvalidated, linkState == .connected, !phase.isLive, !callHistory.isLoading else { return } + lastHistoryRequest = scheduler.now + log.log(.command, "read call history -> readCallHistory()") + for list in CallHistoryList.allCases { + // Registered first: a transport may answer (or fail) before the call returns. + callHistory.requested(list) + do { + try transport.readCallHistory(list, count: configuration.callHistoryLength) + } catch { + log.log(.info, "readCallHistory(\(list.rawValue)) unavailable: \(error)") + callHistory.received(list, entries: nil) + } + } + } + + /// The call lost with the link was not resumed: into the recent calls now. + func recordLostCall() { + lostCallRecordTask?.cancel() + lostCallRecordTask = nil + guard let lost = recentlyLost else { return } + recentlyLost = nil + callHistory.record(lost) + } +} diff --git a/Sources/PickupCore/CallEngine.swift b/Sources/PickupCore/CallEngine.swift index 8d7e29a..3a6c487 100644 --- a/Sources/PickupCore/CallEngine.swift +++ b/Sources/PickupCore/CallEngine.swift @@ -35,6 +35,19 @@ public final class CallEngine { /// A call lost with the link is resumed (same ID, number, start time) if the link comes back /// within this window and the phone still reports a call. public var lostCallRestoreWindow: TimeInterval = 120 + /// Entries read per call list from the phone. + public var callHistoryLength = 25 + /// Delay before the call lists are read after the link came up or a call ended (the phone + /// writes its call log shortly after the call). + public var callHistoryRefreshDelay: TimeInterval = 2 + /// `refreshCallHistory()` does nothing if the lists were requested less than this ago. + public var callHistoryMinimumInterval: TimeInterval = 15 + /// A waiting call that stopped ringing counts as taken on the phone (with the current call on + /// hold) if the phone reports an active and a held call within this time. + public var waitingCallTakenWindow: TimeInterval = 5 + /// A three-way calling command the phone does not answer within this is given up, so the next + /// one can be sent. + public var callHoldReplyTimeout: TimeInterval = 10 public init() {} } @@ -49,15 +62,20 @@ public final class CallEngine { public private(set) var isLinkSetupBlocked = false public private(set) var deviceName: String? /// The current call, or the call that just ended (phase `.ended`) until it is cleared. - public private(set) var call: CallInfo? - /// A second incoming call while `call` is active (call waiting). Shown only; not controllable yet. - public private(set) var waitingCall: CallInfo? + public internal(set) var call: CallInfo? + /// A second incoming call while `call` is active (call waiting). + public internal(set) var waitingCall: CallInfo? + /// A second call on hold while `call` is active (after taking a waiting call, or swapping). + /// Whenever it is set, `call` is the active one. + public internal(set) var heldCall: CallInfo? public private(set) var indicators = HFPIndicators() - public private(set) var audioRoute: AudioRoute = .phone + public internal(set) var audioRoute: AudioRoute = .phone /// Most recent time the engine moved the audio back to the phone, for a short notice. - public private(set) var lastAudioFallback: AudioFallback? + public internal(set) var lastAudioFallback: AudioFallback? /// Most recent call command the phone rejected (accept, end, dial, DTMF). - public private(set) var lastCommandFailure: CommandFailure? + public internal(set) var lastCommandFailure: CommandFailure? + /// Recent calls: the phone's call lists, or the calls seen since launch. + public internal(set) var callHistory = CallHistory() /// User setting "Take calls on this Mac". Off by default until the audio extension ships. public var takeCallsOnMac = false @@ -65,6 +83,10 @@ public final class CallEngine { public var supportsAudio: Bool { transport.supportsAudio } /// Audio on the Mac is possible for this call: transport capability and user setting. public var isAudioOnMacEnabled: Bool { supportsAudio && takeCallsOnMac } + /// The phone supports holding, swapping and taking a waiting call. + public var supportsCallHold: Bool { transport.supportsCallHold } + /// A three-way calling command is waiting for the phone's answer; the next one is refused until then. + public private(set) var isCallHoldPending = false public var phoneStatus: PhoneStatus { PhoneStatus( hasService: indicators.service, signal: indicators.signal, @@ -77,31 +99,48 @@ public final class CallEngine { // MARK: Dependencies and bookkeeping - @ObservationIgnored private let transport: HFPTransport - @ObservationIgnored private let scheduler: Scheduler - @ObservationIgnored private let log: DiagnosticLogging + @ObservationIgnored let transport: HFPTransport + @ObservationIgnored let scheduler: Scheduler + @ObservationIgnored let log: DiagnosticLogging @ObservationIgnored public let configuration: Configuration /// The call the user ended on this Mac (decline, cancel, hang up), to pick the end reason. - @ObservationIgnored private var localEndRequestedFor: UUID? + @ObservationIgnored var localEndRequestedFor: UUID? /// An outgoing call created by `dial` that the phone has not confirmed with `callsetup` yet. @ObservationIgnored private var unconfirmedDialFor: UUID? @ObservationIgnored private var lookupAttempts = 0 - @ObservationIgnored private var recentlyLost: CallInfo? - @ObservationIgnored private var expectingAudioClose = false + @ObservationIgnored var recentlyLost: CallInfo? + @ObservationIgnored var expectingAudioClose = false /// A call list request was skipped because the SLC was not up yet. @ObservationIgnored private var callListWantedOnConnect = false /// The user cancelled this outgoing call. Survives a rejected `AT+CHUP`: if the phone starts the /// call anyway, the engine ends it again. @ObservationIgnored private var cancelRequestedFor: UUID? @ObservationIgnored private var cancelResentFor: UUID? - @ObservationIgnored private var isInvalidated = false + @ObservationIgnored var isInvalidated = false + + /// The three-way calling command sent and not answered yet; its effect is applied when the phone + /// confirms it (or earlier, when the matching indicator arrives first). + enum HoldRequest { case toggle, swap, acceptWaitingHolding, acceptWaitingEnding, rejectWaiting, endActive, endHeld } + @ObservationIgnored var holdRequest: HoldRequest? { + didSet { isCallHoldPending = holdRequest != nil } + } + @ObservationIgnored var holdTimeoutTask: ScheduledTask? + @ObservationIgnored var resolvedWaitingTask: ScheduledTask? + /// A call lost with the link is recorded in the recent calls only once it is clear it will not be + /// resumed (it would otherwise appear twice). + @ObservationIgnored var lostCallRecordTask: ScheduledTask? + /// The waiting call that stopped ringing without a request from this Mac (taken, declined or + /// missed on the phone), to recognise it when the phone reports it as active. + @ObservationIgnored var resolvedWaiting: (call: CallInfo, at: Date)? @ObservationIgnored private var setupEndTask: ScheduledTask? - @ObservationIgnored private var endedClearTask: ScheduledTask? + @ObservationIgnored var endedClearTask: ScheduledTask? @ObservationIgnored private var dialTimeoutTask: ScheduledTask? @ObservationIgnored private var lookupRetryTask: ScheduledTask? - @ObservationIgnored private var audioWatchdogTask: ScheduledTask? + @ObservationIgnored var audioWatchdogTask: ScheduledTask? + @ObservationIgnored var historyRefreshTask: ScheduledTask? + @ObservationIgnored var lastHistoryRequest: Date? public init( transport: HFPTransport, @@ -150,7 +189,8 @@ public final class CallEngine { transport.invalidate() transport.eventHandler = nil linkStateChanged(.disconnected) - for task in [setupEndTask, endedClearTask, dialTimeoutTask, lookupRetryTask, audioWatchdogTask] { + for task in [setupEndTask, endedClearTask, dialTimeoutTask, lookupRetryTask, audioWatchdogTask, historyRefreshTask, + holdTimeoutTask, resolvedWaitingTask, lostCallRecordTask] { task?.cancel() } } @@ -195,6 +235,18 @@ public final class CallEngine { guard [.dialing, .active, .held].contains(phase), let call else { throw CallEngineError.invalidPhase(phase) } + if heldCall != nil, phase == .active, transport.supportsCallHold { + // End the active call and go back to the held one, as on the phone. Without three-way + // calling (a second call the phone set up on its own), AT+CHUP below still ends the call. + localEndRequestedFor = call.id + do { + try sendHold(.releaseActiveAcceptOther, .endActive) + } catch { + localEndRequestedFor = nil + throw error + } + return + } log.log(.command, "hang up -> endCall()") localEndRequestedFor = call.id if phase == .dialing { @@ -255,30 +307,10 @@ public final class CallEngine { handBackAudioToPhone(.userRequest) } - /// Asks the phone for the current call list again (refreshes the number). public func refreshCallList() { requestCallList(resetAttempts: true) } - /// Per-call fallback: move the audio to the phone and leave a notice. The call continues in - /// remote-control mode. Safe to call in any mode and phase. Returns false if the transport - /// could not send the request (it is retried when the link comes up). - @discardableResult - public func handBackAudioToPhone(_ reason: AudioFallbackReason) -> Bool { - log.log(.command, "hand audio back to phone (\(reason.rawValue)) -> transferAudioToPhone()") - audioWatchdogTask?.cancel() - audioWatchdogTask = nil - do { - try transport.transferAudioToPhone() - } catch { - log.log(.error, "transferAudioToPhone failed: \(error)") - return false - } - expectingAudioClose = true - lastAudioFallback = AudioFallback(reason: reason, date: scheduler.now, callID: call?.id) - return true - } - // MARK: Event handling func handle(_ event: TransportEvent) { @@ -312,6 +344,10 @@ public final class CallEngine { currentCallsReceived(calls) case .commandCompleted(let command, let result): commandCompleted(command, result) + case .callHistory(let list, let entries): + callHistory.received(list, entries: entries) + case .callHistoryUnanswered(let list): + callHistory.unanswered(list) case .audioConnectionChanged(let isOpen): audioConnectionChanged(isOpen: isOpen) } @@ -329,6 +365,7 @@ public final class CallEngine { requestCallList(resetAttempts: true) } callListWantedOnConnect = false + scheduleHistoryRefresh() // SCO opened during the SLC set-up and could not be handed back then. if audioRoute == .mac, !isAudioOnMacEnabled { handBackAudioToPhone(.audioOnMacDisabled) @@ -337,7 +374,17 @@ public final class CallEngine { } // Indicators belong to one SLC; the next one reports them afresh. indicators = HFPIndicators() + callHistory.cancelPending() + historyRefreshTask?.cancel() + historyRefreshTask = nil waitingCall = nil + holdRequest = nil + holdTimeoutTask?.cancel() + finalizeResolvedWaiting() + if let held = heldCall { + heldCall = nil + recordBackground(held, .connectionLost) + } audioRoute = .phone // A hand-back deferred by the transport is dropped when the link goes down. expectingAudioClose = false @@ -347,7 +394,11 @@ public final class CallEngine { } private func indicatorChanged(_ indicator: HFPIndicator) { - guard indicators.apply(indicator) else { return } + guard indicators.apply(indicator) else { + // The phone repeats callheld=1 when the calls are swapped on the phone: the call list says which is active. + if indicator == .callHeld(.activeAndHeld), heldCall != nil { requestCallList(resetAttempts: true) } + return + } switch indicator { case .call(let isActive): if isActive { callIndicatorSet() } else { callIndicatorCleared() } @@ -393,9 +444,24 @@ public final class CallEngine { } case .none: - if waitingCall != nil, phase == .active || phase == .held { - log.log(.state, "waiting call gone") - waitingCall = nil + if let waiting = waitingCall, phase == .active || phase == .held { + switch holdRequest { + case .acceptWaitingHolding: + promoteWaiting(holdingCurrent: true) + case .acceptWaitingEnding: + promoteWaiting(holdingCurrent: false) + case .rejectWaiting: + waitingCall = nil + recordBackground(waiting, .declined) + default: + log.log(.state, "waiting call gone") + waitingCall = nil + resolvedWaiting = (waiting, scheduler.now) + resolvedWaitingTask?.cancel() + resolvedWaitingTask = scheduler.schedule(after: configuration.waitingCallTakenWindow) { [weak self] in + self?.finalizeResolvedWaiting() + } + } // If the waiting call was taken (calls swapped), the list shows the new active call. requestCallList(resetAttempts: true) } else if phase == .ringing || phase == .dialing, indicators.call != true, let call { @@ -445,6 +511,12 @@ public final class CallEngine { private func callIndicatorCleared() { guard let call, call.phase == .active || call.phase == .held else { return } + if let held = heldCall { + // No call left at all: the held one is over too. + heldCall = nil + recordBackground(held, localEndRequestedFor == call.id ? .hungUp : .remoteEnded) + } + holdRequest = nil let waiting = waitingCall finish(localEndRequestedFor == call.id ? .hungUp : .remoteEnded) // HFP: when the active call ends while another one waits, the waiting call keeps ringing. @@ -458,13 +530,47 @@ public final class CallEngine { private func callHeldChanged(_ held: CallHeldState) { guard var call, call.phase == .active || call.phase == .held else { return } + if heldCall != nil { + switch held { + case .activeAndHeld: + return // still one active and one held call (also after a swap) + case .heldOnly: + // The active call ended (other party or on the phone); the held one stays on hold. + resumeHeld(after: localEndRequestedFor == call.id ? .hungUp : .remoteEnded, phase: .held) + case .none: + if holdRequest == .endActive { + resumeHeld(after: .hungUp, phase: .active) + } else if let other = heldCall { + // The held call ended (on this Mac, by the other party or on the phone). + heldCall = nil + recordBackground(other, holdRequest == .endHeld ? .hungUp : .remoteEnded) + if holdRequest == .endHeld { holdRequest = nil } + } + } + return + } switch held { case .heldOnly: transition(&call, to: .held) - case .none, .activeAndHeld: + self.call = call + case .none: transition(&call, to: .active) + self.call = call + case .activeAndHeld: + if waitingCall != nil || recentlyResolvedWaiting != nil { + // The waiting call was taken (on this Mac or on the phone) and the current call held. + promoteWaiting(holdingCurrent: true) + } else { + // A second call we did not see coming (e.g. dialed on the phone): the old one is held. + log.log(.state, "second call appeared; the current call is on hold") + transition(&call, to: .held) + heldCall = call + var second = CallInfo(direction: .unknown, callerID: .unknown, phase: .active, startedAt: scheduler.now) + second.connectedAt = scheduler.now + self.call = second + } + requestCallList(resetAttempts: true) } - self.call = call } private func callerIDReceived(_ callerID: CallerID) { @@ -494,6 +600,21 @@ public final class CallEngine { waitingCall = waiting } + if let active = call, let held = heldCall, + let activeEntry = calls.first(where: { $0.status == .active }), + let heldEntry = calls.first(where: { $0.status == .held || $0.status == .heldByResponseAndHold }), + let activeNumber = active.number, let heldNumber = held.number, !Self.sameNumber(activeNumber, heldNumber), + Self.sameNumber(activeNumber, heldEntry.number), Self.sameNumber(heldNumber, activeEntry.number) { + // Swapped on the phone: keep each call's identity and times, exchange their roles. + log.log(.state, "calls were swapped on the phone") + swapActiveAndHeld() + } + if var held = heldCall, let entry = calls.first(where: { $0.status == .held || $0.status == .heldByResponseAndHold }) { + held.callerID = merged(held.callerID, with: CallerID(listedNumber: entry.number)) + if held.direction == .unknown { held.direction = entry.direction } + heldCall = held + } + guard var call, call.phase.isLive else { return } if let entry = Self.entry(in: calls, matching: call) { let listed = CallerID(listedNumber: entry.number) @@ -520,6 +641,10 @@ public final class CallEngine { } private func commandCompleted(_ command: HFPCommand, _ result: CommandResult) { + if command == .callHold { + holdCompleted(result) + return + } guard !result.isSuccess else { return } switch command { case .dial: @@ -537,33 +662,15 @@ public final class CallEngine { if call?.callerID == .unknown, call?.phase.isLive == true { scheduleLookupRetry() } + case .selectPhonebook, .readPhonebook: + break // Reported as `.callHistory` by the transport. + case .callHold: + break // Handled above. case .other: break // Commands IOBluetooth sends on its own (SMS probing etc.) fail routinely. } } - private func audioConnectionChanged(isOpen: Bool) { - if isOpen { - // The transport re-reports an open SCO after the SLC is up; a hand-back may be under way. - let handBackPending = audioRoute == .mac && expectingAudioClose - audioRoute = .mac - audioWatchdogTask?.cancel() - audioWatchdogTask = nil - if !isAudioOnMacEnabled, !handBackPending { - // Remote-control mode: the phone opened SCO on its own; the conversation belongs on the phone. - handBackAudioToPhone(.audioOnMacDisabled) - } - } else { - let wasOnMac = audioRoute == .mac - audioRoute = .phone - if wasOnMac, !expectingAudioClose, isAudioOnMacEnabled, phase == .active || phase == .held { - lastAudioFallback = AudioFallback(reason: .audioDropped, date: scheduler.now, callID: call?.id) - log.log(.warning, "audio link dropped during the call; audio is on the phone now") - } - expectingAudioClose = false - } - } - // MARK: Helpers /// Starts a new call, finalising any call still waiting for its set-up grace period. @@ -590,7 +697,7 @@ public final class CallEngine { lookupAttempts = 0 localEndRequestedFor = nil unconfirmedDialFor = nil - recentlyLost = nil + recordLostCall() // a new call: the lost one is not coming back let call = CallInfo(direction: direction, callerID: callerID, phase: phase, startedAt: scheduler.now) log.log(.state, "call \(call.id.uuidString.prefix(8)) \(direction.rawValue) -> \(phase.rawValue)") @@ -611,6 +718,8 @@ public final class CallEngine { lookupRetryTask = nil lookupAttempts = 0 recentlyLost = nil + lostCallRecordTask?.cancel() // resumed: it is recorded once, when it really ends + lostCallRecordTask = nil var call = lost call.phase = .active call.endedAt = nil @@ -626,13 +735,13 @@ public final class CallEngine { return call } - private func transition(_ call: inout CallInfo, to phase: CallPhase) { + func transition(_ call: inout CallInfo, to phase: CallPhase) { guard call.phase != phase else { return } log.log(.state, "call \(call.id.uuidString.prefix(8)) \(call.phase.rawValue) -> \(phase.rawValue)") call.phase = phase } - private func finish(_ reason: CallEndReason) { + func finish(_ reason: CallEndReason) { guard var call, call.phase.isLive else { return } for task in [setupEndTask, dialTimeoutTask, lookupRetryTask, audioWatchdogTask] { task?.cancel() @@ -661,6 +770,17 @@ public final class CallEngine { self.call = nil self.log.log(.state, "idle") } + if reason == .connectionLost { + // Recorded only if the link does not come back with the same call in time. + lostCallRecordTask?.cancel() + // A moment after the window: a link that comes back right at its edge still resumes the call. + lostCallRecordTask = scheduler.schedule(after: configuration.lostCallRestoreWindow + 1) { [weak self] in + self?.recordLostCall() + } + } else { + callHistory.record(call) + scheduleHistoryRefresh() + } onCallFinished?(call) } @@ -670,6 +790,12 @@ public final class CallEngine { return new } + /// Same number, ignoring formatting ("+49 30 0000123" and "+49300000123"). + static func sameNumber(_ a: String?, _ b: String?) -> Bool { + let digits = { (n: String?) in (n ?? "").filter { $0.isNumber || $0 == "+" } } + return !digits(a).isEmpty && digits(a) == digits(b) + } + /// The call list entry that describes `call`, by status; a single entry always matches. static func entry(in calls: [HFPCall], matching call: CallInfo) -> HFPCall? { let statuses: [CallListStatus] @@ -686,7 +812,7 @@ public final class CallEngine { return calls.count == 1 ? calls[0] : nil } - private func requestCallList(resetAttempts: Bool) { + func requestCallList(resetAttempts: Bool) { if resetAttempts { lookupAttempts = 0 } guard linkState == .connected else { // Indicators arrive during the SLC set-up, before commands are possible. @@ -712,18 +838,6 @@ public final class CallEngine { } } - private func startAudioWatchdog() { - guard isAudioOnMacEnabled else { return } - audioWatchdogTask?.cancel() - audioWatchdogTask = scheduler.schedule(after: configuration.audioSetupTimeout) { [weak self] in - guard let self else { return } - self.audioWatchdogTask = nil - if self.audioRoute != .mac, self.phase == .active { - self.handBackAudioToPhone(.audioSetupTimedOut) - } - } - } - /// The user cancelled this outgoing call but the phone started it anyway (e.g. its `AT+CHUP` /// was rejected while the call was not set up yet): end it again, once. private func endAgainIfCancelled(_ id: UUID) { @@ -738,11 +852,11 @@ public final class CallEngine { } } - private func requireConnected() throws { + func requireConnected() throws { guard !isInvalidated, linkState == .connected else { throw CallEngineError.notConnected } } - private func perform(_ command: () throws -> Void) throws { + func perform(_ command: () throws -> Void) throws { do { try command() } catch TransportError.notConnected { diff --git a/Sources/PickupCore/CallHistory.swift b/Sources/PickupCore/CallHistory.swift new file mode 100644 index 0000000..eb1bf87 --- /dev/null +++ b/Sources/PickupCore/CallHistory.swift @@ -0,0 +1,149 @@ +import Foundation + +/// A call list kept by the phone, read over HFP as a phonebook memory (`AT+CPBS`, 3GPP TS 27.007). +/// Android serves these from its call log, newest first, once the phone has granted the paired Mac +/// access to contacts and call history. +public enum CallHistoryList: String, CaseIterable, Equatable, Hashable, Sendable { + /// `"DC"`: dialled calls. + case dialed = "DC" + /// `"RC"`: received (answered) calls. + case received = "RC" + /// `"MC"`: missed calls. + case missed = "MC" +} + +/// One call of a call list. Contains personal data: its descriptions never print number or name. +public struct CallHistoryEntry: Equatable, Sendable, CustomStringConvertible, CustomDebugStringConvertible { + /// As the phone reported it; empty when the number was withheld or unknown. + public var number: String + /// The name the phone attached (from its contacts), if any. + public var name: String? + /// When the call started. Only known for calls Pickup saw itself (the phone sends no dates). + public var date: Date? + /// Consecutive calls with the same number, shown as one entry. + public var count: Int + + public init(number: String, name: String? = nil, date: Date? = nil, count: Int = 1) { + self.number = number + self.name = name + self.date = date + self.count = count + } + + public var description: String { + "CallHistoryEntry(number: \(number.isEmpty ? "none" : ""), name: \(name == nil ? "none" : ""), count: \(count))" + } + + public var debugDescription: String { description } +} + +/// The recent calls the UI shows: the phone's lists when it shares them, otherwise the calls Pickup +/// saw since it was launched (kept in memory only). +public struct CallHistory: Equatable, Sendable { + public enum Source: Equatable, Sendable { + /// Read from the phone. + case phone + /// Calls seen by Pickup since launch. + case session + } + + /// Lists read from the phone, newest first. A list is missing until it was read successfully. + public private(set) var phoneLists: [CallHistoryList: [CallHistoryEntry]] = [:] + /// Calls seen by Pickup since launch, newest first. + public private(set) var sessionCalls: [CallHistoryList: [CallHistoryEntry]] = [:] + /// The last attempt to read a list from the phone failed (refused, unsupported or no answer). + public private(set) var phoneRefused = false + /// Lists requested from the phone and not answered yet. + public private(set) var pending: Set = [] + /// The last read got no answer in time (not a refusal); cleared by the next list that arrives. + public private(set) var lastReadUnanswered = false + + /// Upper bound for the session lists. + public static let sessionLimit = 50 + + public init() {} + + public var isLoading: Bool { !pending.isEmpty } + + public func source(_ list: CallHistoryList) -> Source { + phoneLists[list] != nil ? .phone : .session + } + + /// What to show for `list`: consecutive calls with the same number are merged into one entry. + public func entries(_ list: CallHistoryList) -> [CallHistoryEntry] { + Self.collapsed(phoneLists[list] ?? sessionCalls[list] ?? []) + } + + /// Records a call that ended (session list). Failed dial attempts never reached the phone's log. + public mutating func record(_ call: CallInfo) { + guard let list = Self.list(for: call) else { return } + let entry = CallHistoryEntry(number: call.callerID.number ?? "", date: call.startedAt) + var calls = sessionCalls[list] ?? [] + calls.insert(entry, at: 0) + sessionCalls[list] = Array(calls.prefix(Self.sessionLimit)) + } + + mutating func requested(_ list: CallHistoryList) { + pending.insert(list) + } + + /// The phone answered a request for `list`; nil = it refused or did not answer. + mutating func received(_ list: CallHistoryList, entries: [CallHistoryEntry]?) { + pending.remove(list) + if let entries { + phoneLists[list] = entries + phoneRefused = false + lastReadUnanswered = false + } else { + // Refused (e.g. access revoked): drop the old snapshot, the session list takes over. + phoneLists[list] = nil + phoneRefused = true + } + } + + /// The phone did not answer in time: nothing changes but the pending state. + mutating func unanswered(_ list: CallHistoryList) { + pending.remove(list) + lastReadUnanswered = true + } + + /// The link went down: answers still outstanding will never arrive. + mutating func cancelPending() { + pending.removeAll() + } + + /// Which list a finished call belongs to. Declined calls count as missed (nobody talked). + static func list(for call: CallInfo) -> CallHistoryList? { + switch (call.direction, call.endReason) { + case (_, .failed?), (_, nil): + return nil + case (.outgoing, _): + return .dialed + case (.incoming, .missed?), (.incoming, .declined?): + return .missed + case (.incoming, _): + return call.connectedAt == nil ? .missed : .received + case (.unknown, _): + return nil + } + } + + /// Merges runs of the same number (compared by digits) into one entry with a count. + static func collapsed(_ entries: [CallHistoryEntry]) -> [CallHistoryEntry] { + var result: [CallHistoryEntry] = [] + for entry in entries { + if var last = result.last, !entry.number.isEmpty, digits(last.number) == digits(entry.number) { + last.count += entry.count + if last.name == nil { last.name = entry.name } + result[result.count - 1] = last + } else { + result.append(entry) + } + } + return result + } + + private static func digits(_ number: String) -> String { + String(number.filter { $0.isASCII && ($0.isNumber || $0 == "+" || $0 == "*" || $0 == "#") }) + } +} diff --git a/Sources/PickupCore/HFPEvent.swift b/Sources/PickupCore/HFPEvent.swift index d8233a8..3a38f32 100644 --- a/Sources/PickupCore/HFPEvent.swift +++ b/Sources/PickupCore/HFPEvent.swift @@ -100,6 +100,21 @@ public enum ATParser { ) } + /// Parses one call list entry, `+CPBR: ,"",,""`. Android appends the + /// number type to contact names ("Jane Doe/M"); that suffix is removed. Entries without a number + /// (withheld callers) keep an empty number and no name (the phone puts a generic text there). + public static func parseCPBR(_ line: String) -> CallHistoryEntry? { + guard let fields = fields(of: line, prefix: "+CPBR:"), fields.count >= 2, Int(fields[0]) != nil else { return nil } + let number = fields[1].trimmingCharacters(in: .whitespaces) + guard !number.isEmpty else { return CallHistoryEntry(number: "") } + var name = fields.count > 3 ? fields[3].trimmingCharacters(in: .whitespaces) : "" + if let slash = name.lastIndex(of: "/"), name.distance(from: slash, to: name.endIndex) == 2, + let type = name.last, "HMWO".contains(type) { + name = String(name[.. [String]? { diff --git a/Sources/PickupCore/HFPTransport.swift b/Sources/PickupCore/HFPTransport.swift index f28f63e..1e226c0 100644 --- a/Sources/PickupCore/HFPTransport.swift +++ b/Sources/PickupCore/HFPTransport.swift @@ -24,6 +24,12 @@ public enum TransportEvent: Equatable, Sendable { case currentCalls([HFPCall]) /// The phone answered a command with its final result code. case commandCompleted(HFPCommand, CommandResult) + /// A call list read with `readCallHistory`, newest first; nil = the phone refused it (no access + /// to the call history granted), does not support it or did not answer. + case callHistory(CallHistoryList, [CallHistoryEntry]?) + /// A call list read got no answer in time (the link is not taking commands). Not a refusal: the + /// lists already shown stay, and the next refresh asks again. + case callHistoryUnanswered(CallHistoryList) /// The audio link (SCO) opened or closed. Remote-control transports may still report it, /// because the phone can open SCO on its own; the core then hands the audio back. case audioConnectionChanged(isOpen: Bool) @@ -88,4 +94,26 @@ public protocol HFPTransport: AnyObject { func transferAudioToPhone() throws /// Asks for the current call list. `AT+CLCC`; the answer arrives as `.currentCalls`. func requestCurrentCalls() throws + /// Reads the newest `count` entries of a call list (`AT+CPBS="DC"`, then `AT+CPBR=1,`). + /// The answer arrives as `.callHistory`. Requests are queued and answered in order. + func readCallHistory(_ list: CallHistoryList, count: Int) throws + /// True if the phone supports three-way calling (`+BRSF` and `+CHLD` modes 0, 1 and 2). + var supportsCallHold: Bool { get } + /// Holds, resumes, swaps or ends calls, or answers/rejects a waiting call. `AT+CHLD=`; + /// the answer arrives as `.commandCompleted(.callHold, …)`, the effect as indicator changes. + func callHold(_ action: CallHoldAction) throws +} + +public extension HFPTransport { + /// Transports without access to the phone's call lists. + func readCallHistory(_ list: CallHistoryList, count: Int) throws { + throw TransportError.unsupported + } + + /// Transports without three-way calling. + var supportsCallHold: Bool { false } + + func callHold(_ action: CallHoldAction) throws { + throw TransportError.unsupported + } } diff --git a/Sources/PickupCore/HFPTypes.swift b/Sources/PickupCore/HFPTypes.swift index fdd1ee9..f2d0976 100644 --- a/Sources/PickupCore/HFPTypes.swift +++ b/Sources/PickupCore/HFPTypes.swift @@ -142,6 +142,9 @@ public enum HFPCommand: Equatable, Sendable { case dial // ATD; case dtmf // AT+VTS= case currentCalls // AT+CLCC + case selectPhonebook // AT+CPBS="" + case readPhonebook // AT+CPBR=, + case callHold // AT+CHLD= (not the AT+CHLD=? query of the SLC set-up) case other(String) // e.g. "AT+BRSF", "AT+CIND?" public init(atCommand line: String) { @@ -156,6 +159,12 @@ public enum HFPCommand: Equatable, Sendable { self = .dtmf } else if command == "AT+CLCC" { self = .currentCalls + } else if command.hasPrefix("AT+CPBS=\"") { + self = .selectPhonebook + } else if command.hasPrefix("AT+CPBR="), command != "AT+CPBR=?" { + self = .readPhonebook + } else if command.hasPrefix("AT+CHLD="), command != "AT+CHLD=?" { + self = .callHold } else { self = .other(HFPCommand.name(of: command)) } @@ -174,8 +183,8 @@ public enum HFPCommand: Equatable, Sendable { /// /// Only action (`AT+CLCC`), read (`AT+CIND?`) and test (`AT+CHLD=?`) commands get information /// responses. Set commands (`AT+CLIP=1`, `AT+CCWA=1`) get none, and their prefix must not swallow - /// the unsolicited result of the same name (`+CLIP:` while ringing). `AT+BRSF=` is the one HFP - /// set command that is answered with `+BRSF:`. + /// the unsolicited result of the same name (`+CLIP:` while ringing). `AT+BRSF=` and `AT+CPBR=` + /// are the set commands answered with information responses (`+BRSF:`, `+CPBR:`). static func responsePrefix(of line: String) -> String? { let command = line.trimmingCharacters(in: .whitespacesAndNewlines).uppercased() guard command.hasPrefix("AT+") else { return nil } @@ -184,11 +193,22 @@ public enum HFPCommand: Equatable, Sendable { guard !name.isEmpty else { return nil } let rest = body.dropFirst(name.count) let isSetCommand = rest.hasPrefix("=") && rest != "=?" - if isSetCommand && name != "+BRSF" { return nil } + if isSetCommand && name != "+BRSF" && name != "+CPBR" { return nil } return String(name) } } +/// Three-way calling commands (`AT+CHLD=`, HFP 1.8 section 4.22). The phone applies them to its +/// calls: with one call, `2` holds or resumes it; with a held and an active call, `2` swaps them. +public enum CallHoldAction: Int, Equatable, Sendable { + /// `0`: releases the held call, or rejects the waiting call. + case releaseHeldOrRejectWaiting = 0 + /// `1`: ends the active call and takes the held or waiting call. + case releaseActiveAcceptOther = 1 + /// `2`: puts the active call on hold and takes the held or waiting call. + case holdActiveAcceptOther = 2 +} + /// Final result of an AT command. public enum CommandResult: Equatable, Sendable { case ok diff --git a/Tests/PickupBluetoothTests/AppleHFPTransportTests.swift b/Tests/PickupBluetoothTests/AppleHFPTransportTests.swift index 1305d26..6335dd7 100644 --- a/Tests/PickupBluetoothTests/AppleHFPTransportTests.swift +++ b/Tests/PickupBluetoothTests/AppleHFPTransportTests.swift @@ -16,6 +16,8 @@ final class AppleHFPTransportTests: XCTestCase { var disconnectDropsSLC = false var onClosed: (@MainActor () -> Void)? var deviceName: String? = "Pixel 9 Pro" + var audioGatewayFeatures: UInt32 = 0 + var callHoldModes: UInt32 = 0 var underlying: AnyObject { self } private(set) var calls: [String] = [] private(set) var delegates: [HandsFreeDelegate] = [] @@ -41,6 +43,7 @@ final class AppleHFPTransportTests: XCTestCase { func sendDTMF(_ digit: String) { calls.append("VTS") } func transferAudioToPhone() { calls.append("phone") } func currentCallList() { calls.append("CLCC") } + func sendATCommand(_ line: String) { calls.append(line) } func owns(channel: AnyObject) -> Bool { channel === self.channel } func indicatorSnapshot() -> [HFPIndicator] { [.call(false), .callSetup(.none)] } func diagnostics() -> [String] { [] } @@ -600,4 +603,186 @@ final class AppleHFPTransportTests: XCTestCase { transport.receivedLine("OK", detail: nil) // AT+CLCC itself is invisible without the tap XCTAssertEqual(transport.linkState, .connected) } + + // MARK: Call history + + private var historyEvents: [TransportEvent] { + events.filter { if case .callHistory = $0 { true } else { false } } + } + + func testCallHoldNeedsThreeWayCallingAndReportsTheAnswer() throws { + let transport = makeTransport() + let link = connected(transport) + XCTAssertFalse(transport.supportsCallHold) + XCTAssertThrowsError(try transport.callHold(.holdActiveAcceptOther)) { XCTAssertEqual($0 as? TransportError, .unsupported) } + XCTAssertFalse(link.calls.contains { $0.hasPrefix("AT+CHLD") }) + + link.audioGatewayFeatures = 0b1 // three-way calling + link.callHoldModes = 0b1011 // CHLD 0, 1 and 2 + XCTAssertTrue(transport.supportsCallHold) + try transport.callHold(.holdActiveAcceptOther) + XCTAssertEqual(link.calls.last, "AT+CHLD=2") + transport.sentLine("AT+CHLD=2", detail: nil, channel: link.channel) + transport.receivedLine("OK", detail: nil) + XCTAssertTrue(events.contains(.commandCompleted(.callHold, .ok))) + } + + func testCallHoldSupportNeedsModesZeroOneAndTwo() { + XCTAssertTrue(AppleHFPTransport.supportsCallHold(features: 0x3ef, holdModes: 0x7f)) + XCTAssertFalse(AppleHFPTransport.supportsCallHold(features: 0x3ee, holdModes: 0x7f), "no three-way calling bit") + XCTAssertFalse(AppleHFPTransport.supportsCallHold(features: 0x1, holdModes: 0b0011), "mode 2 missing") + } + + /// Regression (2026-10-30): `AT+CPBS` was never written after a failed write of IOBluetooth's own + /// command, and the read waited for an answer forever (endless spinner in the dialer). + func testUnansweredCallListReadIsGivenUp() throws { + let transport = makeTransport() + let link = connected(transport) + clock.advance(by: 10) + try transport.readCallHistory(.dialed, count: 2) + try transport.readCallHistory(.missed, count: 2) + XCTAssertEqual(link.calls.last, #"AT+CPBS="DC""#) + clock.advance(by: 7) + XCTAssertFalse(events.contains(.callHistoryUnanswered(.dialed))) + clock.advance(by: 1) + XCTAssertEqual(events.filter { if case .callHistoryUnanswered = $0 { true } else { false } }, + [.callHistoryUnanswered(.dialed), .callHistoryUnanswered(.missed)]) + + // A late answer to the given-up command is ignored; the next read starts cleanly. + transport.sentLine(#"AT+CPBS="DC""#, detail: nil, channel: link.channel) + transport.receivedLine("OK", detail: nil) + XCTAssertFalse(link.calls.contains("AT+CPBR=1,2")) + try transport.readCallHistory(.received, count: 2) + XCTAssertEqual(link.calls.last, #"AT+CPBS="RC""#) + } + + // MARK: Review fixes (PR #7) + + func testCallHoldNeedsTheWriteHook() { + let transport = makeTransport(address: "aa-bb-cc-dd-ee-02", assumeOutgoingTap: false) + let link = connected(transport) + link.audioGatewayFeatures = 0b1 + link.callHoldModes = 0b1011 + XCTAssertFalse(transport.supportsCallHold, "without the hook the phone's OK cannot be matched to AT+CHLD") + } + + func testCallListReadGivesUpWhenTheLinkNeverBecomesIdle() throws { + let transport = makeTransport() + let link = connected(transport) + try transport.readCallHistory(.dialed, count: 5) + transport.sentLine("AT+VGS=7", detail: nil, channel: link.channel) // never answered, silent link + clock.advance(by: 17.5) + XCTAssertFalse(events.contains(.callHistoryUnanswered(.dialed))) + clock.advance(by: 1) + XCTAssertTrue(events.contains(.callHistoryUnanswered(.dialed)), "the queued read is not left waiting forever") + XCTAssertFalse(link.calls.contains { $0.hasPrefix("AT+CPB") }, "IOBluetooth's safety gate is never bypassed") + } + + func testDiscardedCallListCommandIsUnansweredNotRefused() throws { + let transport = makeTransport() + let link = connected(transport) + clock.advance(by: 10) + try transport.readCallHistory(.dialed, count: 5) + transport.sentLine(#"AT+CPBS="DC""#, detail: nil, channel: link.channel) + clock.advance(by: 6) + transport.receivedLine("+CIEV: 5,3", detail: nil) // an unrelated line: the tracker drops the stale command + XCTAssertTrue(events.contains(.callHistoryUnanswered(.dialed))) + XCTAssertFalse(events.contains(.callHistory(.dialed, nil)), "no answer is not a refusal") + } + + func testCallHistoryIsSelectedThenReadOneListAtATime() async throws { + let transport = makeTransport() + let link = connected(transport) + clock.advance(by: 10) + try transport.readCallHistory(.dialed, count: 2) + try transport.readCallHistory(.missed, count: 2) + XCTAssertEqual(link.calls.filter { $0.hasPrefix("AT+") }, [#"AT+CPBS="DC""#], "the second list waits for the first") + + transport.sentLine(#"AT+CPBS="DC""#, detail: nil, channel: link.channel) + transport.receivedLine("OK", detail: nil) + await drainMainQueue() // the follow-up command is deferred out of the callback + XCTAssertEqual(link.calls.last, "AT+CPBR=1,2") + + transport.sentLine("AT+CPBR=1,2", detail: nil, channel: link.channel) + transport.receivedLine(#"+CPBR: 1,"+49300000123",145,"Jane Doe""#, detail: nil) + transport.receivedLine(#"+CPBR: 2,"+49300000456",145,"""#, detail: nil) + transport.receivedLine("OK", detail: nil) + await drainMainQueue() + XCTAssertEqual(historyEvents, [.callHistory(.dialed, [ + CallHistoryEntry(number: "+49300000123", name: "Jane Doe"), CallHistoryEntry(number: "+49300000456"), + ])]) + XCTAssertEqual(link.calls.last, #"AT+CPBS="MC""#) + + // The phone refuses the second list (no access to the call history). + transport.sentLine(#"AT+CPBS="MC""#, detail: nil, channel: link.channel) + transport.receivedLine("OK", detail: nil) + await drainMainQueue() + transport.sentLine("AT+CPBR=1,2", detail: nil, channel: link.channel) + transport.receivedLine("+CME ERROR: 3", detail: nil) + XCTAssertEqual(historyEvents.last, .callHistory(.missed, nil)) + } + + /// Regression test for the crash of 2026-10-29 (SIGSEGV in `-[IOBluetoothHandsFreeDevice + /// processResultCode:]`): a read interleaved with IOBluetooth's own commands right after the SLC. + func testCallHistoryWaitsForTheQuietPeriodAndAnIdleLink() throws { + let transport = makeTransport() + let link = connected(transport) + clock.advance(by: 2) + try transport.readCallHistory(.dialed, count: 5) + XCTAssertFalse(link.calls.contains { $0.hasPrefix("AT+CPB") }, "not while IOBluetooth still finishes the set-up") + + // IOBluetooth's own command is still waiting for its reply when the quiet period ends. + transport.sentLine("AT+VGS=7", detail: nil, channel: link.channel) + clock.advance(by: 8) + XCTAssertFalse(link.calls.contains { $0.hasPrefix("AT+CPB") }, "not while another command waits for its reply") + transport.receivedLine("OK", detail: nil) + clock.advance(by: 0.5) + XCTAssertEqual(link.calls.filter { $0.hasPrefix("AT+CPB") }, [#"AT+CPBS="DC""#]) + } + + /// The read starts as soon as IOBluetooth's follow-up commands went out and were answered, instead + /// of always waiting the full quiet period. + func testCallHistoryStartsOnceIOBluetoothsFollowUpsAreAnswered() throws { + let transport = makeTransport() + let link = connected(transport) + try transport.readCallHistory(.dialed, count: 5) + transport.sentLine("AT+VGS=7", detail: nil, channel: link.channel) + transport.receivedLine("OK", detail: nil) + clock.advance(by: 0.5) + XCTAssertFalse(link.calls.contains { $0.hasPrefix("AT+CPB") }, "AT+VGM is still due") + transport.sentLine("AT+VGM=7", detail: nil, channel: link.channel) + clock.advance(by: 0.5) + XCTAssertFalse(link.calls.contains { $0.hasPrefix("AT+CPB") }, "AT+VGM waits for its reply") + transport.receivedLine("OK", detail: nil) + clock.advance(by: 0.5) + XCTAssertEqual(link.calls.filter { $0.hasPrefix("AT+CPB") }, [#"AT+CPBS="DC""#], "about 1.5 s after the link came up, not 10 s") + } + + func testCallHistoryNeedsTheWriteHookAndALink() throws { + let untapped = makeTransport(address: "aa-bb-cc-dd-ee-01", assumeOutgoingTap: false) + _ = connected(untapped) + XCTAssertThrowsError(try untapped.readCallHistory(.dialed, count: 5)) { + XCTAssertEqual($0 as? TransportError, .unsupported) + } + let transport = makeTransport() + XCTAssertThrowsError(try transport.readCallHistory(.dialed, count: 5)) { + XCTAssertEqual($0 as? TransportError, .notConnected) + } + } + + func testLinkLossDropsQueuedCallHistoryReads() throws { + let transport = makeTransport() + let link = connected(transport) + try transport.readCallHistory(.dialed, count: 5) + try transport.readCallHistory(.received, count: 5) + dropped(transport, link) + clock.advance(by: 2) + let again = made.last! + again.isConnected = true + transport.handsFree(again, connectedWithStatus: 0) + try transport.readCallHistory(.missed, count: 5) + clock.advance(by: 10) + XCTAssertEqual(again.calls.filter { $0.hasPrefix("AT+CPBS") }.last, #"AT+CPBS="MC""#, + "a fresh link starts with the new request, not with the old queue") + } } diff --git a/Tests/PickupBluetoothTests/LogCanaryTests.swift b/Tests/PickupBluetoothTests/LogCanaryTests.swift index 7469c4f..415a944 100644 --- a/Tests/PickupBluetoothTests/LogCanaryTests.swift +++ b/Tests/PickupBluetoothTests/LogCanaryTests.swift @@ -75,6 +75,14 @@ final class LogCanaryTests: XCTestCase { transport.receivedLine("OK", detail: nil) transport.receivedLine("+CNUM: ,\"\(canaryNumber)\",145,,4", detail: nil) + // Call history read from the phone (the engine's event log must not show it either). + transport.sentLine("AT+CPBS=\"RC\"", detail: nil, channel: link.channel) + transport.receivedLine("OK", detail: nil) + transport.sentLine("AT+CPBR=1,25", detail: nil, channel: link.channel) + transport.receivedLine("+CPBR: 1,\"\(canaryNumber)\",145,\"\(canaryName)\"", detail: nil) + transport.receivedLine("OK", detail: nil) + transport.eventHandler?(.callHistory(.received, [CallHistoryEntry(number: canaryNumber, name: canaryName)])) + // DTMF: a PIN typed during the call. for character in canaryPIN { try engine.sendDTMF(DTMFDigit(character)!) diff --git a/Tests/PickupCoreTests/CallHistoryTests.swift b/Tests/PickupCoreTests/CallHistoryTests.swift new file mode 100644 index 0000000..53556f2 --- /dev/null +++ b/Tests/PickupCoreTests/CallHistoryTests.swift @@ -0,0 +1,246 @@ +import XCTest +@testable import PickupCore + +/// Recent calls: parsing the phone's call lists, the session list and the engine's refresh rules. +@MainActor +final class CallHistoryTests: XCTestCase { + private var transport: MockHFPTransport! + private var scheduler: ManualScheduler! + private var engine: CallEngine! + + override func setUp() async throws { + transport = MockHFPTransport() + transport.supportsCallHistory = true + scheduler = ManualScheduler() + engine = CallEngine(transport: transport, scheduler: scheduler) + } + + private var historyRequests: [CallHistoryList] { + transport.commands.compactMap { if case .readCallHistory(let list) = $0 { list } else { nil } } + } + + private func answerAll(_ entries: [CallHistoryEntry]?) { + for list in CallHistoryList.allCases { transport.emit(.callHistory(list, entries)) } + } + + // MARK: Parsing + + func testParsesCPBREntries() { + XCTAssertEqual(ATParser.parseCPBR(#"+CPBR: 1,"+49300000123",145,"Jane Doe""#), + CallHistoryEntry(number: "+49300000123", name: "Jane Doe")) + XCTAssertEqual(ATParser.parseCPBR(#"+CPBR: 2,"0300000123",129,"""#), + CallHistoryEntry(number: "0300000123")) + XCTAssertEqual(ATParser.parseCPBR(#"+CPBR: 3,"0300000123",129,"Jane Doe/M""#)?.name, "Jane Doe", + "Android's number type suffix is removed") + XCTAssertEqual(ATParser.parseCPBR(#"+CPBR: 4,"",128,"Unknown""#), CallHistoryEntry(number: ""), + "a withheld caller has no number and the phone's placeholder text is dropped") + XCTAssertNil(ATParser.parseCPBR("+CPBR: (1-25),30,30"), "the test response is no entry") + XCTAssertNil(ATParser.parseCPBR("+CLCC: 1,0,0,0,0")) + } + + func testPhonebookCommandsAreRecognisedAndCollectTheirResponses() { + XCTAssertEqual(HFPCommand(atCommand: #"AT+CPBS="DC""#), .selectPhonebook) + XCTAssertEqual(HFPCommand(atCommand: "AT+CPBR=1,25"), .readPhonebook) + XCTAssertEqual(HFPCommand.responsePrefix(of: "AT+CPBR=1,25"), "+CPBR") + XCTAssertNil(HFPCommand.responsePrefix(of: #"AT+CPBS="DC""#)) + + var tracker = ATExchangeTracker() + tracker.outgoing("AT+CPBR=1,25") + _ = tracker.incoming(#"+CPBR: 1,"+49300000123",145,"Jane Doe""#) + _ = tracker.incoming(#"+CPBR: 2,"+49300000456",145,"""#) + let outputs = tracker.incoming("OK") + guard case .completed(let exchange)? = outputs.last else { return XCTFail("no exchange: \(outputs)") } + XCTAssertEqual(exchange.command, .readPhonebook) + XCTAssertEqual(exchange.responses.compactMap(ATParser.parseCPBR).map(\.number), ["+49300000123", "+49300000456"]) + } + + // MARK: Session list and display + + func testRunsOfTheSameNumberAreMerged() { + let entries = [ + CallHistoryEntry(number: "+49 30 0000123"), CallHistoryEntry(number: "+49300000123", name: "Jane Doe"), + CallHistoryEntry(number: "+49300000456"), CallHistoryEntry(number: "+49300000123"), + CallHistoryEntry(number: ""), CallHistoryEntry(number: ""), + ] + let collapsed = CallHistory.collapsed(entries) + XCTAssertEqual(collapsed.map(\.count), [2, 1, 1, 1, 1], "withheld calls are never merged") + XCTAssertEqual(collapsed.first?.name, "Jane Doe") + } + + func testFinishedCallsGoToTheSessionListUntilThePhoneSharesItsLists() { + transport.supportsCallHistory = false + transport.bringUp() + // Missed incoming call. + transport.emit(.callSetup(.incoming)) + transport.emit(.currentCalls([HFPCall(index: 1, direction: .incoming, status: .incoming, number: "+49300000123")])) + transport.emit(.callSetup(.none)) + scheduler.advance(by: 1) + // Outgoing call. + try? engine.dial(DialNumber("+49300000456")!) + transport.emit(.callSetup(.outgoing)) + transport.emit(.callSetup(.none)) + scheduler.advance(by: 5) + + XCTAssertEqual(engine.callHistory.source(.missed), .session) + XCTAssertEqual(engine.callHistory.entries(.missed).map(\.number), ["+49300000123"]) + XCTAssertEqual(engine.callHistory.entries(.dialed).map(\.number), ["+49300000456"]) + XCTAssertNotNil(engine.callHistory.entries(.dialed).first?.date, "session entries know when the call was") + XCTAssertEqual(engine.callHistory.entries(.received), []) + XCTAssertTrue(engine.callHistory.phoneRefused) + + engine.refreshCallHistory() // the phone still does not share: the session list stays + XCTAssertEqual(engine.callHistory.entries(.missed).count, 1) + } + + func testListForAFinishedCall() { + func call(_ direction: CallDirection, _ reason: CallEndReason, connected: Bool = false) -> CallInfo { + var info = CallInfo(direction: direction, callerID: .number("+49300000123"), phase: .ended, startedAt: Date()) + info.endReason = reason + if connected { info.connectedAt = Date() } + return info + } + XCTAssertEqual(CallHistory.list(for: call(.incoming, .missed)), .missed) + XCTAssertEqual(CallHistory.list(for: call(.incoming, .declined)), .missed) + XCTAssertEqual(CallHistory.list(for: call(.incoming, .remoteEnded, connected: true)), .received) + XCTAssertEqual(CallHistory.list(for: call(.outgoing, .unanswered)), .dialed) + XCTAssertEqual(CallHistory.list(for: call(.outgoing, .hungUp, connected: true)), .dialed) + XCTAssertNil(CallHistory.list(for: call(.outgoing, .failed)), "a refused dial never reached the phone's log") + } + + // MARK: Engine refresh + + func testListsAreReadShortlyAfterTheLinkCameUp() { + transport.bringUp() + XCTAssertEqual(historyRequests, [], "not during the SLC set-up") + scheduler.advance(by: 2) + XCTAssertEqual(historyRequests, CallHistoryList.allCases) + XCTAssertTrue(engine.callHistory.isLoading) + + transport.emit(.callHistory(.dialed, [CallHistoryEntry(number: "+49300000123", name: "Jane Doe")])) + XCTAssertEqual(engine.callHistory.source(.dialed), .phone) + XCTAssertEqual(engine.callHistory.entries(.dialed).first?.name, "Jane Doe") + XCTAssertTrue(engine.callHistory.isLoading) + transport.emit(.callHistory(.received, [])) + transport.emit(.callHistory(.missed, [])) + XCTAssertFalse(engine.callHistory.isLoading) + XCTAssertFalse(engine.callHistory.phoneRefused) + } + + func testRefreshIsThrottledAndSkippedDuringACall() { + transport.bringUp() + scheduler.advance(by: 2) + answerAll([]) + transport.clearCommands() + + engine.refreshCallHistory() + XCTAssertEqual(historyRequests, [], "requested less than the minimum interval ago") + + scheduler.advance(by: 20) + transport.emit(.callSetup(.incoming)) + transport.clearCommands() + engine.refreshCallHistory() + XCTAssertEqual(historyRequests, [], "never while a call is live") + } + + func testListsAreReadAgainAfterACallEnded() { + transport.bringUp() + scheduler.advance(by: 2) + answerAll([]) + transport.emit(.callSetup(.incoming)) + transport.emit(.callSetup(.none)) + transport.clearCommands() + scheduler.advance(by: 3) + XCTAssertEqual(historyRequests, CallHistoryList.allCases) + } + + func testAPhoneThatRefusedIsNotAskedAgainAutomatically() { + transport.bringUp() + scheduler.advance(by: 2) + answerAll(nil) + XCTAssertTrue(engine.callHistory.phoneRefused) + transport.emit(.callSetup(.incoming)) + transport.emit(.callSetup(.none)) + transport.clearCommands() + scheduler.advance(by: 30) + XCTAssertEqual(historyRequests, []) + + engine.refreshCallHistory() // the dialer opened: ask again + XCTAssertEqual(historyRequests, CallHistoryList.allCases) + } + + func testLinkLossCancelsOutstandingReads() { + transport.bringUp() + scheduler.advance(by: 2) + XCTAssertTrue(engine.callHistory.isLoading) + transport.emit(.linkStateChanged(.disconnected)) + XCTAssertFalse(engine.callHistory.isLoading) + } + + func testEntryDescriptionNeverContainsPersonalData() { + let entry = CallHistoryEntry(number: "+49300000123", name: "Jane Doe") + for text in ["\(entry)", String(reflecting: entry), "\(TransportEvent.callHistory(.dialed, [entry]))"] { + XCTAssertFalse(text.contains("0000123"), text) + XCTAssertFalse(text.contains("Jane"), text) + } + } + + // MARK: Unanswered reads + + /// Regression (2026-10-30): a read the phone never answered left the dialer loading forever. + func testUnansweredReadEndsLoadingWithoutCountingAsRefused() { + transport.bringUp() + scheduler.advance(by: 2) + XCTAssertTrue(engine.callHistory.isLoading) + for list in CallHistoryList.allCases { transport.emit(.callHistoryUnanswered(list)) } + XCTAssertFalse(engine.callHistory.isLoading) + XCTAssertFalse(engine.callHistory.phoneRefused, "no answer is not a refusal: no hint about the phone's permission") + XCTAssertTrue(engine.callHistory.lastReadUnanswered) + transport.emit(.callHistory(.dialed, [])) + XCTAssertFalse(engine.callHistory.lastReadUnanswered, "a later answer clears it") + } + + // MARK: Review fixes (PR #7) + + func testAReadAnsweredBeforeTheCallReturnsDoesNotStayLoading() { + transport.answerHistorySynchronously = .some(nil) + transport.bringUp() + scheduler.advance(by: 2) + XCTAssertFalse(engine.callHistory.isLoading, "the pending list is registered before the transport is asked") + } + + func testARefusedReadDropsTheOldSnapshot() { + transport.bringUp() + scheduler.advance(by: 2) + transport.emit(.callHistory(.dialed, [CallHistoryEntry(number: "+49300000123")])) + XCTAssertEqual(engine.callHistory.source(.dialed), .phone) + transport.emit(.callHistory(.dialed, nil)) // access revoked + XCTAssertEqual(engine.callHistory.source(.dialed), .session) + } + + func testACallLostWithTheLinkAndResumedIsRecordedOnce() { + transport.supportsCallHistory = false + transport.bringUp() + transport.emit(.callSetup(.incoming)) + transport.emit(.currentCalls([HFPCall(index: 1, direction: .incoming, status: .incoming, number: "+49300000123")])) + transport.emit(.call(true)) + transport.emit(.callSetup(.none)) + transport.emit(.linkStateChanged(.disconnected)) + XCTAssertEqual(engine.callHistory.entries(.received), [], "not yet: the call may come back") + transport.bringUp(call: true) // the same call resumes + transport.emit(.call(false)) // and ends + scheduler.advance(by: 300) + XCTAssertEqual(engine.callHistory.entries(.received).count, 1) + } + + func testACallLostForGoodIsRecordedAfterTheRestoreWindow() { + transport.supportsCallHistory = false + transport.bringUp() + transport.emit(.callSetup(.incoming)) + transport.emit(.currentCalls([HFPCall(index: 1, direction: .incoming, status: .incoming, number: "+49300000123")])) + transport.emit(.call(true)) + transport.emit(.callSetup(.none)) + transport.emit(.linkStateChanged(.disconnected)) + scheduler.advance(by: engine.configuration.lostCallRestoreWindow + 1) + XCTAssertEqual(engine.callHistory.entries(.received).count, 1) + } +} diff --git a/Tests/PickupCoreTests/CallHoldTests.swift b/Tests/PickupCoreTests/CallHoldTests.swift new file mode 100644 index 0000000..9344850 --- /dev/null +++ b/Tests/PickupCoreTests/CallHoldTests.swift @@ -0,0 +1,312 @@ +import XCTest +@testable import PickupCore + +/// Three-way calling: hold/resume, call waiting (hold and take, end and take, reject), swapping and +/// ending one of two calls. The phone's indicators may arrive before or after its OK. +@MainActor +final class CallHoldTests: XCTestCase { + private var transport: MockHFPTransport! + private var scheduler: ManualScheduler! + private var engine: CallEngine! + private var finished: [CallInfo] = [] + + private let jane = "+49300000123" + private let john = "+49300000456" + + override func setUp() async throws { + transport = MockHFPTransport() + transport.supportsCallHold = true + scheduler = ManualScheduler() + engine = CallEngine(transport: transport, scheduler: scheduler) + engine.onCallFinished = { [weak self] in self?.finished.append($0) } + transport.bringUp() + } + + private var holdCommands: [CallHoldAction] { + transport.commands.compactMap { if case .callHold(let action) = $0 { action } else { nil } } + } + + /// Jane's incoming call, answered. + private func activeCallWithJane() { + transport.emit(.callSetup(.incoming)) + transport.emit(.currentCalls([HFPCall(index: 1, direction: .incoming, status: .incoming, number: jane)])) + transport.emit(.call(true)) + transport.emit(.callSetup(.none)) + XCTAssertEqual(engine.phase, .active) + } + + /// John calls while the call with Jane is active. + private func johnWaits() { + transport.emit(.callSetup(.incoming)) + transport.emit(.callWaiting(john)) + XCTAssertEqual(engine.waitingCall?.number, john) + } + + private func ok() { transport.emit(.commandCompleted(.callHold, .ok)) } + + // MARK: Single call + + func testHoldAndResumeASingleCall() throws { + activeCallWithJane() + try engine.toggleHold() + XCTAssertEqual(holdCommands, [.holdActiveAcceptOther]) + ok() + transport.emit(.callHeld(.heldOnly)) + XCTAssertEqual(engine.phase, .held) + + try engine.toggleHold() + ok() + transport.emit(.callHeld(.none)) + XCTAssertEqual(engine.phase, .active) + XCTAssertEqual(engine.call?.number, jane) + } + + func testPhonesWithoutThreeWayCallingRefuse() { + transport.supportsCallHold = false + activeCallWithJane() + XCTAssertThrowsError(try engine.toggleHold()) { XCTAssertEqual($0 as? CallEngineError, .unsupported) } + XCTAssertFalse(engine.supportsCallHold) + } + + // MARK: Call waiting + + func testHoldAndTakeTheWaitingCallOKFirst() throws { + activeCallWithJane() + johnWaits() + try engine.acceptWaitingCall(endingCurrent: false) + XCTAssertEqual(holdCommands, [.holdActiveAcceptOther]) + ok() + transport.emit(.callSetup(.none)) + transport.emit(.callHeld(.activeAndHeld)) + XCTAssertEqual(engine.call?.number, john) + XCTAssertEqual(engine.phase, .active) + XCTAssertEqual(engine.heldCall?.number, jane) + XCTAssertEqual(engine.heldCall?.phase, .held) + XCTAssertNil(engine.waitingCall) + } + + func testHoldAndTakeTheWaitingCallIndicatorsFirst() throws { + activeCallWithJane() + johnWaits() + try engine.acceptWaitingCall(endingCurrent: false) + transport.emit(.callHeld(.activeAndHeld)) + transport.emit(.callSetup(.none)) + ok() + XCTAssertEqual(engine.call?.number, john) + XCTAssertEqual(engine.heldCall?.number, jane) + } + + func testWaitingCallTakenOnThePhoneIsRecognised() { + activeCallWithJane() + johnWaits() + transport.emit(.callSetup(.none)) // no request from this Mac + transport.emit(.callHeld(.activeAndHeld)) + XCTAssertEqual(engine.call?.number, john) + XCTAssertEqual(engine.heldCall?.number, jane) + } + + func testEndAndTakeTheWaitingCall() throws { + activeCallWithJane() + johnWaits() + try engine.acceptWaitingCall(endingCurrent: true) + XCTAssertEqual(holdCommands, [.releaseActiveAcceptOther]) + transport.emit(.callSetup(.none)) + ok() + XCTAssertEqual(engine.call?.number, john) + XCTAssertEqual(engine.phase, .active) + XCTAssertNil(engine.heldCall) + XCTAssertEqual(finished.map(\.number), [jane]) + XCTAssertEqual(finished.first?.endReason, .hungUp) + } + + func testRejectTheWaitingCall() throws { + activeCallWithJane() + johnWaits() + try engine.rejectWaitingCall() + XCTAssertEqual(holdCommands, [.releaseHeldOrRejectWaiting]) + ok() + transport.emit(.callSetup(.none)) + XCTAssertNil(engine.waitingCall) + XCTAssertEqual(engine.call?.number, jane) + XCTAssertEqual(engine.phase, .active) + XCTAssertEqual(engine.callHistory.entries(.missed).map(\.number), [john], "a rejected waiting call is in the missed list") + } + + func testRejectedCommandLeavesTheCallsAlone() throws { + activeCallWithJane() + johnWaits() + try engine.acceptWaitingCall(endingCurrent: false) + transport.emit(.commandCompleted(.callHold, .error)) + XCTAssertEqual(engine.lastCommandFailure?.command, .callHold) + XCTAssertEqual(engine.call?.number, jane) + XCTAssertEqual(engine.waitingCall?.number, john) + XCTAssertNil(engine.heldCall) + } + + // MARK: Two calls + + private func janeHeldJohnActive() throws { + activeCallWithJane() + johnWaits() + try engine.acceptWaitingCall(endingCurrent: false) + ok() + transport.emit(.callSetup(.none)) + transport.emit(.callHeld(.activeAndHeld)) + transport.clearCommands() + } + + func testSwapCalls() throws { + try janeHeldJohnActive() + try engine.swapCalls() + XCTAssertEqual(holdCommands, [.holdActiveAcceptOther]) + ok() + XCTAssertEqual(engine.call?.number, jane) + XCTAssertEqual(engine.phase, .active) + XCTAssertEqual(engine.heldCall?.number, john) + XCTAssertEqual(engine.heldCall?.phase, .held) + } + + func testHangingUpTheActiveCallResumesTheHeldOne() throws { + try janeHeldJohnActive() + try engine.hangUp() + XCTAssertEqual(holdCommands, [.releaseActiveAcceptOther], "not AT+CHUP: the held call comes back") + transport.emit(.callHeld(.none)) + ok() + XCTAssertEqual(engine.call?.number, jane) + XCTAssertEqual(engine.phase, .active) + XCTAssertNil(engine.heldCall) + XCTAssertEqual(finished.last?.number, john) + XCTAssertEqual(finished.last?.endReason, .hungUp) + } + + /// Found by the fuzz test: a phone without three-way calling may still report a second call; hanging + /// up must not be blocked by the missing CHLD support. + func testHangUpWorksWithASecondCallButNoThreeWayCalling() throws { + transport.supportsCallHold = false + activeCallWithJane() + transport.emit(.callHeld(.activeAndHeld)) + XCTAssertNotNil(engine.heldCall) + transport.clearCommands() + XCTAssertNoThrow(try engine.hangUp()) + XCTAssertEqual(transport.commands, [.endCall]) + } + + func testEndTheHeldCall() throws { + try janeHeldJohnActive() + try engine.endHeldCall() + XCTAssertEqual(holdCommands, [.releaseHeldOrRejectWaiting]) + ok() + transport.emit(.callHeld(.none)) + XCTAssertEqual(engine.call?.number, john) + XCTAssertEqual(engine.phase, .active) + XCTAssertNil(engine.heldCall) + XCTAssertEqual(finished.last?.number, jane) + } + + func testOtherPartyOfTheActiveCallHangsUp() throws { + try janeHeldJohnActive() + transport.emit(.callHeld(.heldOnly)) + XCTAssertEqual(engine.call?.number, jane) + XCTAssertEqual(engine.phase, .held, "the held call stays on hold until the user resumes it") + XCTAssertNil(engine.heldCall) + XCTAssertEqual(finished.last?.endReason, .remoteEnded) + } + + func testOtherPartyOfTheHeldCallHangsUp() throws { + try janeHeldJohnActive() + transport.emit(.callHeld(.none)) + XCTAssertEqual(engine.call?.number, john) + XCTAssertNil(engine.heldCall) + XCTAssertEqual(finished.last?.number, jane) + XCTAssertEqual(finished.last?.endReason, .remoteEnded) + } + + func testAllCallsEnd() throws { + try janeHeldJohnActive() + transport.emit(.call(false)) + XCTAssertEqual(engine.phase, .ended) + XCTAssertNil(engine.heldCall) + XCTAssertEqual(Set(finished.compactMap(\.number)), [jane, john]) + } + + func testLinkLossEndsBothCalls() throws { + try janeHeldJohnActive() + transport.emit(.linkStateChanged(.disconnected)) + XCTAssertNil(engine.heldCall) + XCTAssertEqual(finished.filter { $0.endReason == .connectionLost }.count, 2) + } + + func testCallListFillsInTheHeldNumber() { + activeCallWithJane() + transport.emit(.callHeld(.activeAndHeld)) // a second call dialed on the phone + XCTAssertEqual(engine.heldCall?.number, jane) + transport.emit(.currentCalls([ + HFPCall(index: 1, direction: .incoming, status: .held, number: jane), + HFPCall(index: 2, direction: .outgoing, status: .active, number: john), + ])) + XCTAssertEqual(engine.call?.number, john) + XCTAssertEqual(engine.call?.direction, .outgoing) + } + + func testCallHoldCommandIsRecognised() { + XCTAssertEqual(HFPCommand(atCommand: "AT+CHLD=2"), .callHold) + XCTAssertEqual(HFPCommand(atCommand: "AT+CHLD=?"), .other("AT+CHLD=?")) + XCTAssertNil(HFPCommand.responsePrefix(of: "AT+CHLD=1")) + } + + // MARK: Review fixes (PR #7) + + func testSwapIsRefusedWhileAThirdCallerWaits() throws { + try janeHeldJohnActive() + transport.emit(.callSetup(.incoming)) + transport.emit(.callWaiting("+49300000789")) + XCTAssertThrowsError(try engine.swapCalls(), "CHLD=2 would take the waiting caller instead") + XCTAssertEqual(holdCommands, []) + } + + func testEndAndAcceptIsRefusedWhileTheCallIsOnHold() throws { + activeCallWithJane() + try engine.toggleHold() + ok() + transport.emit(.callHeld(.heldOnly)) + johnWaits() + transport.clearCommands() + XCTAssertThrowsError(try engine.acceptWaitingCall(endingCurrent: true), "CHLD=1 would leave the held call") + XCTAssertNoThrow(try engine.acceptWaitingCall(endingCurrent: false)) + } + + func testOneHoldCommandAtATimeAndAnUnansweredOneIsGivenUp() throws { + try janeHeldJohnActive() + try engine.swapCalls() + XCTAssertTrue(engine.isCallHoldPending) + XCTAssertThrowsError(try engine.endHeldCall(), "the swap's reply must not be taken for this command") + scheduler.advance(by: engine.configuration.callHoldReplyTimeout) + XCTAssertFalse(engine.isCallHoldPending) + XCTAssertNoThrow(try engine.endHeldCall()) + } + + func testAWaitingCallerWhoGivesUpIsAMissedCall() { + activeCallWithJane() + johnWaits() + transport.emit(.callSetup(.none)) // John hangs up before anyone answers + XCTAssertTrue(finished.isEmpty) + scheduler.advance(by: engine.configuration.waitingCallTakenWindow) + XCTAssertEqual(finished.last?.number, john) + XCTAssertEqual(finished.last?.endReason, .missed) + XCTAssertEqual(engine.callHistory.entries(.missed).map(\.number), [john]) + } + + func testCallsSwappedOnThePhoneAreFollowed() throws { + try janeHeldJohnActive() // John active, Jane held + transport.emit(.callHeld(.activeAndHeld)) // the phone repeats callheld=1 after a swap + XCTAssertTrue(transport.commands.contains(.requestCurrentCalls)) + let janeID = engine.heldCall?.id + transport.emit(.currentCalls([ + HFPCall(index: 1, direction: .incoming, status: .active, number: jane), + HFPCall(index: 2, direction: .incoming, status: .held, number: john), + ])) + XCTAssertEqual(engine.call?.number, jane) + XCTAssertEqual(engine.call?.id, janeID, "the call keeps its identity and times") + XCTAssertEqual(engine.heldCall?.number, john) + } +} diff --git a/Tests/PickupCoreTests/Support/TestDoubles.swift b/Tests/PickupCoreTests/Support/TestDoubles.swift index ad7590b..84f530c 100644 --- a/Tests/PickupCoreTests/Support/TestDoubles.swift +++ b/Tests/PickupCoreTests/Support/TestDoubles.swift @@ -60,6 +60,8 @@ final class MockHFPTransport: HFPTransport { enum Command: Equatable { case connect, disconnect, reconnect, prepareForSleep, resumeAfterSleep, invalidate case acceptCallOnPhone, endCall, dial(String), sendDTMF(String), transferAudioToPhone, requestCurrentCalls + case readCallHistory(CallHistoryList) + case callHold(CallHoldAction) } var supportsAudio = false @@ -134,6 +136,21 @@ final class MockHFPTransport: HFPTransport { try record(.transferAudioToPhone) } func requestCurrentCalls() throws { try record(.requestCurrentCalls) } + /// Off by default (like a phone that does not share its call history), so other tests see no extra commands. + var supportsCallHistory = false + /// Off by default, like a phone without three-way calling. + var supportsCallHold = false + func callHold(_ action: CallHoldAction) throws { + guard supportsCallHold else { throw TransportError.unsupported } + try record(.callHold(action)) + } + /// Answers a read before `readCallHistory` returns, like a transport whose write fails at once. + var answerHistorySynchronously: [CallHistoryEntry]?? = .none + func readCallHistory(_ list: CallHistoryList, count: Int) throws { + guard supportsCallHistory else { throw TransportError.unsupported } + try record(.readCallHistory(list)) + if case .some(let entries) = answerHistorySynchronously { emit(.callHistory(list, entries)) } + } } /// Collects log lines in memory. diff --git a/docs/BLUETOOTH.md b/docs/BLUETOOTH.md index 0894f74..24ae640 100644 --- a/docs/BLUETOOTH.md +++ b/docs/BLUETOOTH.md @@ -16,6 +16,9 @@ Rules for `AppleHFPTransport`: - **Outgoing AT lines are routed by channel.** The process-wide write hook hands each line to the transport that owns the channel's hands-free object; lines of parked objects are only logged. - **Diagnostics:** every AT line carries the object tag (`[hf#1]`), and connect lines show how many hands-free objects exist. An `AT+BRSF` on a channel the transport does not own is logged as an error, because that is the signature of this crash. Log lines are written with one `write(2)` each, before IOBluetooth processes the data, so the last line before a crash shows what triggered it. +- **Call list reads (`AT+CPBS`/`AT+CPBR`) never overlap IOBluetooth's own commands.** On 2026-10-29 a read started 2 s after the SLC, while IOBluetooth's follow-up `AT+VGS`/`AT+VGM` were still due; they went out 6 s late and IOBluetooth crashed in `-[IOBluetoothHandsFreeDevice processResultCode:]` (`objc_retain` on a freed object) on the next incoming data. Reads that started after those commands never failed. A read therefore starts only once IOBluetooth's `AT+VGS` and `AT+VGM` of the current SLC went out and no command waits for its reply (usually 0.1 to 8 s after the SLC; once 17 s), or 10 s after the SLC at the latest (`Timeouts.historyQuietPeriod`). The two commands of one list are sent strictly one after the other, and a command the phone does not answer within 8 s (`Timeouts.historyCommand`) is given up: on 2026-10-30 an `AT+CPBS` was never written after a failed write and the read waited forever. +- **Three-way calling (`AT+CHLD=0/1/2`) is offered only when the phone announces it** (`+BRSF` bit 0 and `+CHLD` modes 0, 1 and 2). The commands go out through `sendATCommand` like the call list reads, but only during a call, long after the SLC set-up. The engine applies a command's effect on the phone's `OK` or on the matching `callheld`/`callsetup` indicator, whichever comes first; the call list (`AT+CLCC`) then confirms numbers and states. Not yet verified on hardware. + ### Known edge cases to revisit after hardware tests - Third reconnect review, item L3: deferred on purpose until hardware tests with the first signed build confirm the open IOBluetooth behaviours. - A `connected(status≠0)` callback (aborted connect of a parked object, or in the transport itself) does not confirm the pending close; the next owner then waits the full 10 s again. diff --git a/docs/images/README.md b/docs/images/README.md index 5f158d8..6b46166 100644 --- a/docs/images/README.md +++ b/docs/images/README.md @@ -1,7 +1,7 @@ # README images -- `hero.png` / `hero-dark.png`: the top-right corner of a Mac: menu bar with the Pickup glyph, the incoming-call banner and (when a capture in that appearance exists) the open popover. `light-dark.png`: banner and call HUD in both appearances. -- `banner-{light,dark}.png` (German: `banner-de-*`): incoming call. `call-hud-{light,dark}.png`: active call with timer and the ended state. `call-waiting-*`: a second caller during a call. -- `popover-search-dark.png` (German: `popover-search-de-dark.png`): the empty popover and a name search with the keypad open. `settings-{light,dark}.png`: Settings. -- Everything is invented test data (scripted "Test Phone", Jane Doe, John Roe); the wallpaper and menu bar are synthetic. +- `hero-dark.png`: the top-right corner of a Mac, cropped to the content: menu bar with the Pickup glyph, the open popover with the recent calls, the incoming-call banner and the call window with a second caller waiting. `light-dark.png`: banner and call HUD in both appearances. +- `banner-{light,dark}.png` (German: `banner-de-*`): incoming call. `call-hud-{light,dark}.png`: active call with timer and the ended state. `call-waiting-*`: a second caller during a call (three-way calling) and the call on hold with Swap. +- `popover-search-dark.png` (German: `popover-search-de-dark.png`): the popover with the recent calls and a name search with the keypad open. `settings-{light,dark}.png`: Settings. +- Everything is invented test data (scripted "Test Phone", Jane Doe, John Roe, Max Mustermann); the colourful wallpaper and the menu bar are synthetic. - Regenerate: `PICKUP_SKIP_VERIFY=1 PICKUP_NO_SANDBOX=1 scripts/build-app.sh`, then `scripts/capture-screenshots.sh` (takes over the screen for about a minute, hands off), then `scripts/generate-screenshots.sh`. -- The popover follows the menu bar's appearance, not the app's: the light popover images (and the popover in `hero.png`) need a capture run with the Mac in Light Mode. +- Capture each appearance with the Mac in that mode (`PICKUP_SHOT_APPEARANCES=Light` or `Dark`). Known limit on macOS 26: a capture of the popover window alone renders its Liquid Glass without a backdrop, which looks right in dark mode but flat grey in light mode, so the hero and popover images are dark only. diff --git a/docs/images/banner-dark.png b/docs/images/banner-dark.png index 2e3f580..59bade8 100644 Binary files a/docs/images/banner-dark.png and b/docs/images/banner-dark.png differ diff --git a/docs/images/banner-de-dark.png b/docs/images/banner-de-dark.png index f2ed842..3d38cda 100644 Binary files a/docs/images/banner-de-dark.png and b/docs/images/banner-de-dark.png differ diff --git a/docs/images/banner-de-light.png b/docs/images/banner-de-light.png index 93ad5ff..e27c896 100644 Binary files a/docs/images/banner-de-light.png and b/docs/images/banner-de-light.png differ diff --git a/docs/images/banner-light.png b/docs/images/banner-light.png index ee3707b..b28b230 100644 Binary files a/docs/images/banner-light.png and b/docs/images/banner-light.png differ diff --git a/docs/images/call-hud-dark.png b/docs/images/call-hud-dark.png index 22efa08..e8c2257 100644 Binary files a/docs/images/call-hud-dark.png and b/docs/images/call-hud-dark.png differ diff --git a/docs/images/call-hud-light.png b/docs/images/call-hud-light.png index a79da85..b39c763 100644 Binary files a/docs/images/call-hud-light.png and b/docs/images/call-hud-light.png differ diff --git a/docs/images/call-waiting-dark.png b/docs/images/call-waiting-dark.png index 632988b..568a484 100644 Binary files a/docs/images/call-waiting-dark.png and b/docs/images/call-waiting-dark.png differ diff --git a/docs/images/call-waiting-light.png b/docs/images/call-waiting-light.png index 06534ae..9924e55 100644 Binary files a/docs/images/call-waiting-light.png and b/docs/images/call-waiting-light.png differ diff --git a/docs/images/hero-dark.png b/docs/images/hero-dark.png index 0cb6aef..bb6742f 100644 Binary files a/docs/images/hero-dark.png and b/docs/images/hero-dark.png differ diff --git a/docs/images/hero.png b/docs/images/hero.png index bb0f2b2..43e24a4 100644 Binary files a/docs/images/hero.png and b/docs/images/hero.png differ diff --git a/docs/images/light-dark.png b/docs/images/light-dark.png index 49f333d..eff3dbe 100644 Binary files a/docs/images/light-dark.png and b/docs/images/light-dark.png differ diff --git a/docs/images/popover-search-dark.png b/docs/images/popover-search-dark.png index 3c2482c..210f1d9 100644 Binary files a/docs/images/popover-search-dark.png and b/docs/images/popover-search-dark.png differ diff --git a/docs/images/popover-search-de-dark.png b/docs/images/popover-search-de-dark.png index 48b5f04..40ec624 100644 Binary files a/docs/images/popover-search-de-dark.png and b/docs/images/popover-search-de-dark.png differ diff --git a/docs/images/settings-dark.png b/docs/images/settings-dark.png index b9c911f..e948e82 100644 Binary files a/docs/images/settings-dark.png and b/docs/images/settings-dark.png differ diff --git a/docs/images/settings-light.png b/docs/images/settings-light.png index e2de343..7d0ab9a 100644 Binary files a/docs/images/settings-light.png and b/docs/images/settings-light.png differ diff --git a/scripts/capture-screenshots.sh b/scripts/capture-screenshots.sh index 1a8797b..34cf2c4 100755 --- a/scripts/capture-screenshots.sh +++ b/scripts/capture-screenshots.sh @@ -25,11 +25,7 @@ mkdir -p "$OUT" "$(dirname "$HELPER")" for appearance in ${PICKUP_SHOT_APPEARANCES:-Light Dark}; do rm -f "$OUT"/*-"$(echo "$appearance" | tr '[:upper:]' '[:lower:]')"-*.png done -# The popover follows the menu bar's (system) appearance, not the app's launch arguments. -if [ "$(defaults read -g AppleInterfaceStyle 2>/dev/null || true)" = Dark ]; then - echo "capture: note: the Mac is in Dark Mode, so the 'light' popover captures will be dark and are skipped by" \ - "generate-screenshots (switch to Light Mode yourself for light popover images)." >&2 -fi +# The popover follows the menu bar's appearance: capture each appearance with the Mac in that mode. swiftc -O "$ROOT/scripts/capture-screenshots-helper.swift" -o "$HELPER" PID="" @@ -66,11 +62,12 @@ for appearance in ${PICKUP_SHOT_APPEARANCES:-Light Dark}; do for lang in en de; do echo "== $appearance $lang" # Popover: keypad open, search for a name. - launch "$appearance" "$lang" -PickupTestScenario idle -PickupOpenPopover YES -PickupDemoQuery ja -PickupDemoKeypad YES + launch "$appearance" "$lang" -PickupTestScenario idle -PickupOpenPopover YES \ + -PickupDemoQuery ja -PickupDemoKeypad YES sleep 2.2; shoot "popover-search-$a-$lang"; stop - # Popover: collapsed, empty search. + # Popover: empty search field, the phone's recent calls (read about 2 s after the link came up). launch "$appearance" "$lang" -PickupTestScenario idle -PickupOpenPopover YES - sleep 2.2; shoot "popover-empty-$a-$lang"; stop + sleep 4; shoot "popover-empty-$a-$lang"; stop # Incoming banner (Jane Doe), then the HUD with a running timer, then the ended state. launch "$appearance" "$lang" -PickupTestScenario incoming-accept sleep 1.8; shoot "banner-$a-$lang" @@ -82,11 +79,14 @@ for appearance in ${PICKUP_SHOT_APPEARANCES:-Light Dark}; do fi stop [ "$lang" = en ] || continue - # Call waiting: Jane is active, John Roe is waiting. - launch "$appearance" "$lang" -PickupTestScenario call-waiting -PickupTestFastClock YES + # Call waiting: Jane is active, John Roe is waiting (a phone with three-way calling), then John is + # taken and Jane is on hold. + launch "$appearance" "$lang" -PickupTestScenario call-waiting -PickupTestFastClock YES -PickupTestCallHold YES sleep 1.8; "$HELPER" press "$PID" banner-accept sleep 3.2; "$HELPER" advance - sleep 1.2; shoot "waiting-$a-$lang"; stop + sleep 1.2; shoot "waiting-$a-$lang" + "$HELPER" press "$PID" hud-waiting-hold-accept + sleep 2.5; shoot "held-$a-$lang"; stop # Settings. launch "$appearance" "$lang" -PickupTestScenario idle -PickupOpenSettings YES # Activate Pickup so the window is captured active (coloured traffic lights, accent colours). diff --git a/scripts/generate-screenshots.swift b/scripts/generate-screenshots.swift index dca4280..6c47a7f 100644 --- a/scripts/generate-screenshots.swift +++ b/scripts/generate-screenshots.swift @@ -94,17 +94,18 @@ final class Canvas { let small = CGContext(data: nil, width: w, height: h, bitsPerComponent: 8, bytesPerRow: 0, space: sRGB, bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue)! let full = CGRect(x: 0, y: 0, width: w, height: h) - let base: [CGColor] = look == .light ? [rgb(0xFBF9F6), rgb(0xF0EBE5)] : [rgb(0x1E1A17), rgb(0x0E0C0B)] + let base: [CGColor] = look == .light ? [rgb(0xE9EEFF), rgb(0xFFEDE3)] : [rgb(0x0F1433), rgb(0x1C0E22)] let linear = CGGradient(colorsSpace: sRGB, colors: base as CFArray, locations: [0, 1])! small.drawLinearGradient(linear, start: CGPoint(x: 0, y: full.maxY), end: CGPoint(x: full.maxX, y: 0), options: [.drawsBeforeStartLocation, .drawsAfterEndLocation]) let size = max(full.width, full.height) - // Soft glows: brand orange low right, warm peach top left, a lighter centre. + // Colourful glows, like a modern macOS wallpaper: brand orange low right, violet top left, sky blue + // top right, rose low left (y = 0 is the bottom). let glows: [(CGFloat, CGFloat, CGFloat, CGColor)] = look == .light - ? [(0.86, 0.02, 0.75, rgb(0xF2571A, 0.18)), (0.08, 0.95, 0.65, rgb(0xFFB48C, 0.26)), - (0.45, 0.45, 0.55, rgb(0xFFFFFF, 0.35))] - : [(0.90, 0.0, 0.70, rgb(0xFF6A2B, 0.22)), (0.05, 1.0, 0.60, rgb(0x5A3220, 0.20)), - (0.45, 0.55, 0.50, rgb(0x2B2622, 0.30))] + ? [(0.92, 0.05, 0.70, rgb(0xFF7A3D, 0.42)), (0.05, 0.95, 0.70, rgb(0x8B7CFF, 0.40)), + (0.85, 1.00, 0.55, rgb(0x5BC8FF, 0.34)), (0.10, 0.05, 0.55, rgb(0xFF8FB1, 0.30))] + : [(0.92, 0.05, 0.70, rgb(0xFF6A2B, 0.50)), (0.05, 0.95, 0.70, rgb(0x6A4CFF, 0.50)), + (0.85, 1.00, 0.55, rgb(0x1FA3C9, 0.34)), (0.10, 0.05, 0.55, rgb(0xC23A8A, 0.36))] for (fx, fy, fr, color) in glows { let center = CGPoint(x: fx * full.width, y: fy * full.height) let radial = CGGradient(colorsSpace: sRGB, colors: [color, color.copy(alpha: 0)!] as CFArray, @@ -224,25 +225,29 @@ func feature(_ images: [CGImage], width: Int, look: Look, name: String, gap: CGF c.write(name) } -/// Hero: the top-right corner of a Mac. Menu bar with the Pickup glyph, the incoming-call banner where macOS -/// shows it, and (when a capture in this appearance exists) the popover open below the glyph. +/// Hero: the top-right corner of a Mac, cropped to the content. Menu bar with the Pickup glyph, the popover with +/// the recent calls open below it, and the incoming-call banner where macOS shows it, with the call window of +/// a second caller below the banner. func hero(look: Look, name: String) { let suffix = look == .light ? "light" : "dark" let banner = require("banner-\(suffix)-en-1") - let pop = popover("popover-search-\(suffix)-en-1", look: look) - let width = 2400, bar: CGFloat = 56, margin: CGFloat = 64 - let height = pop.map { Int(bar + 12 + CGFloat($0.height) + 110) } ?? 600 + let pop = popover("popover-empty-\(suffix)-en-1", look: look) ?? require("popover-empty-\(suffix)-en-1") + let call = load("waiting-\(suffix)-en-1") + let bar: CGFloat = 56, margin: CGFloat = 120, gap: CGFloat = 110 + let columnW = max(CGFloat(banner.width), CGFloat(call?.width ?? 0)) + let width = Int(margin + CGFloat(pop.width) + gap + columnW + margin) + let columnH = CGFloat(banner.height) + (call.map { 70 + CGFloat($0.height) } ?? 0) + let height = Int(bar + 6 + max(CGFloat(pop.height), 24 + columnH) + 120) let c = Canvas(width: width, height: height, look: look) c.wallpaper() - let bannerX = CGFloat(width) - margin - CGFloat(banner.width) - if let pop { - let glyphX = bannerX - 96 - CGFloat(pop.width) / 2 - menuBar(c, height: bar, glyphX: glyphX, highlighted: true) - c.window(pop, x: (glyphX - CGFloat(pop.width) / 2).rounded(), y: bar + 6) - } else { - menuBar(c, height: bar, glyphX: bannerX - 140, highlighted: false) + let popX = margin + menuBar(c, height: bar, glyphX: popX + CGFloat(pop.width) / 2, highlighted: true) + c.window(pop, x: popX, y: bar + 6) + let columnX = popX + CGFloat(pop.width) + gap + c.window(banner, x: (columnX + (columnW - CGFloat(banner.width)) / 2).rounded(), y: bar + 30) + if let call { + c.window(call, x: (columnX + (columnW - CGFloat(call.width)) / 2).rounded(), y: bar + 30 + CGFloat(banner.height) + 70) } - c.window(banner, x: bannerX, y: bar + 30) c.write(name) } @@ -266,7 +271,8 @@ func lightAndDark(name: String) { } try? FileManager.default.createDirectory(at: outDir, withIntermediateDirectories: true) -hero(look: .light, name: "hero.png") +// The README uses the dark images: on macOS 26 a capture of the popover window alone renders its Liquid Glass +// without a backdrop, which is flat grey in light mode. Banner, call window and Settings exist in both. hero(look: .dark, name: "hero-dark.png") lightAndDark(name: "light-dark.png") for look in [Look.light, .dark] { @@ -276,9 +282,10 @@ for look in [Look.light, .dark] { feature([require("hud-\(s)-en-1"), require("hud-ended-\(s)-en-1")], width: 1600, look: look, name: "call-hud-\(s).png") feature([require("settings-\(s)-en-1")], width: 1600, look: look, name: "settings-\(s).png") if let waiting = load("waiting-\(s)-en-1") { - feature([waiting], width: 1600, look: look, name: "call-waiting-\(s).png", padY: 170) + feature([waiting] + [load("held-\(s)-en-1")].compactMap { $0 }, width: 1600, look: look, + name: "call-waiting-\(s).png", padY: 170) } - for lang in ["en", "de"] { + for lang in ["en", "de"] where look == .dark { let tag = lang == "en" ? "" : "-de" if let empty = popover("popover-empty-\(s)-\(lang)-1", look: look), let search = popover("popover-search-\(s)-\(lang)-1", look: look) { diff --git a/scripts/verify.sh b/scripts/verify.sh index 3ba0238..c9be5d9 100755 --- a/scripts/verify.sh +++ b/scripts/verify.sh @@ -138,7 +138,8 @@ tsan_root() { swift test --sanitize=thread --scratch-path .build/verify-tsan; security() { if [ "$STRICT" = 1 ]; then scripts/security-checks.sh --strict; else scripts/security-checks.sh; fi; } lint() { - if [ "$STRICT" = 1 ]; then swiftlint lint --config .swiftlint.yml --quiet --strict; else swiftlint lint --config .swiftlint.yml --quiet; fi + # Always --strict, like CI: a warning that passes here but fails the pull request helps nobody. + swiftlint lint --config .swiftlint.yml --quiet --strict } # UI tests: the XCUITest target does not exist yet (round 2). Detected by its directory.