Skip to content

USB: stop the permission dialog that comes back on every connect, let a plug-in leave the phone alone, and recover a lost link - #1207

Open
o-jcardenass wants to merge 7 commits into
andreknieriem:mainfrom
o-jcardenass:fix/usb-permission-dialog
Open

o-jcardenass wants to merge 7 commits into
andreknieriem:mainfrom
o-jcardenass:fix/usb-permission-dialog

Conversation

@o-jcardenass

@o-jcardenass o-jcardenass commented Oct 27, 2026 •

Copy link
Copy Markdown
Collaborator

Seven commits on main (a4f80ff1), 4435 unit tests. Tested on four devices over eight hardware rounds, the first on main: an HP Slate 7 Plus (Android 4.2) and a POCO X3 NFC (Android 15) as USB hosts, and a Motorola edge 30 neo (Android 14) and a Samsung Galaxy S24+ (Android 16) as phones, with an RP2040 board presenting Carlinkit and AOA USB identities.

  • One dialog per identity (d61ae460): the service stacked its own permission dialog on Android's confirm dialog and asked twice for the accessory identity, so a first connect showed 3 or 4 dialogs and an unticked one brought them back on the next plug. The service now leaves a plug to Android's dialog wherever Android shows one, for as long as the device stays plugged in, so a cancel stays cancelled and the USB button still asks. Where Android shows none it asks once per bus path through one process-wide record, and logs each decision (UsbLauncher: permission for ... action=). With detection off it no longer asks on attach, which was 2 dialogs on every plug with no way to keep a choice. "Reopen on reconnection" no longer raises the home screen over Android's dialog, which hid it and left no way to connect, and a second USB press within 3 s opens no second dialog. Each ask carries its own token and PendingIntent, and a manifest receiver hands the answer to the record, so a service restart in the same process or a late answer at a reused bus path opens no second dialog. Only a grant ends an entry, because below Android 12 the system dialog also answers "denied" when a rotation or night mode only rebuilds it. A first connect now shows Android's 2 dialogs, and none once they are ticked.
  • Home and the USB list (64648b8a): the USB button and the USB list ask through the same record as the service, and their request counts as the user's, so it still asks with detection off. A blacklisted device in accessory mode now shows as blacklisted and can be removed, where the row used to read "Allowed" with its button disabled. Below Android 5 the API gives no device name, so the key now reads the same strings from sysfs and holds a phone in every USB mode, as on Android 5 and up. A device with no names falls back to its USB ID, and the list says so when the user adds one. Android's own dialog follows the manifest filter, which the blacklist cannot change, so blacklisting a device the filter matches tells the user to choose that device's app and tick the box once; a device the filter misses, such as a stock Carlinkit, gets no dialog and no note.
  • Connect when plugged in, and settings that say what they do (7b19dc79, 5b562903): a new setting (usb-connect-on-plug-in, default on) decides whether a plug-in switches the phone to Android Auto. With it off, a plug-in opens the app and leaves the phone in its normal mode, and the USB button connects (UsbLauncher: not switching ... Connect when plugged in is off). Every USB check now carries its trigger, so the refusal covers the attach screen, the listener's fallback, a wake and a settings-screen replay, while a user's press is never refused. A replug within 30 s of a session that ended without an exit still switches, so a loose cable reconnects. The boot start needs both this setting and "Open the app when plugged in". In settings, the Listen for USB Devices description told users to turn it off to stop the prompt, which is what made the prompt appear on every connect. The switch is now "Detect plugged-in phones/AA dongles", sits in General after "Use Native USB Driver", and says what it does. In Auto-start settings, "Connect when plugged in" is shown disabled with a note while detection is off, "Auto-start on USB" becomes "Open the app when plugged in", and both it and "Reopen on reconnection" hide when a plug-in cannot connect. Onboarding shows the same rows. New keys are translated in every locale.
  • A lost USB link (0115da20, dcf17b71, fd110551): one failed USB read loses its data in the kernel, and the reader carried on and decoded body bytes as frame headers until TLS failed about a second later. It now ends the session at the first header with a flag bit above 0x0F, which Android Auto never sets (AapRead: frame header on channel ... carries flags). A session that reached SSL and was lost to a link failure with the phone still plugged in now reconnects without a replug with both auto-connect settings off, which is their default (AapService: USB link lost with the phone still attached); with either on it behaves as before. Every session end now records who ended it, so the projection screen shows one notice per lost session instead of the WiFi tip on every end: the WiFi tip on wireless, a new USB line on USB, and nothing for a cable pull, an exit, the app's own teardowns or a failed retry, which used to repeat it every few seconds. Each notice is logged (AapProjectionActivity: showing the USB_LOST notice ...). A hidden one-shot pref armed through the automation receiver (debug-usb-read-fault) makes the next USB read fail, for rig rounds.

@o-jcardenass
o-jcardenass marked this pull request as draft October 28, 2026 00:22
@o-jcardenass
o-jcardenass force-pushed the fix/usb-permission-dialog branch 2 times, most recently from 67bc82d to 3dcbf76 Compare October 28, 2026 02:51
@o-jcardenass o-jcardenass changed the title USB: stop the permission dialog that comes back on every connect USB: stop the permission dialog that comes back on every connect, and let a plug-in leave the phone alone Oct 28, 2026
@o-jcardenass
o-jcardenass force-pushed the fix/usb-permission-dialog branch from 3dcbf76 to e2e560e Compare October 28, 2026 04:28
@emotionbug

Copy link
Copy Markdown
Contributor

@o-jcardenass Thanks for working on this. I was investigating the duplicate permission requests in #1194: the USB button and a settings-close recheck issued requests about 46 ms apart. The shared record in this PR covers that overlap, so I won't open a duplicate implementation.

Reviewing e2e560eb, I found two remaining request-lifetime edge cases:

1. Service recreation can forget an unanswered dialog

UsbLauncherManager.unregister() calls UsbPermissionAsks.clearAll(), but doesn't cancel or complete the corresponding Android permission request. If the service unregisters while the dialog remains unanswered, a recreated service can claim the same path again once its policy permits asking. This seems to work against the process-wide record's intended protection across service restarts.

One possible direction would be to keep request ownership independent of the service receiver's lifetime, including a way to retire the request if its result arrives while that receiver is absent. Explicit cancellation and device detach could remain separate invalidation paths rather than treating every service teardown as completion.

2. An old callback can clear a newer request at the same path

Both permission callbacks clear by deviceName before checking request ownership. A possible sequence is:

  1. Request A is outstanding.
  2. The device detaches; its record is removed.
  3. A new attachment reuses that path and request B is recorded.
  4. A delayed result from A clears B's record.
  5. Another automatic scan can issue a further request while B is still unanswered.

The recovery callback has the same issue because it clears the ordinary shared record before validating its recovery token. A request-specific token, with matching-only completion, would let an old result leave a newer request untouched. For ordinary requests, that would also need a distinct PendingIntent identity; putting a token only in extras on the shared FLAG_UPDATE_CURRENT intent wouldn't isolate requests.

I exercised the PR's production policy and synchronized record methods in a small JVM replay: the 46 ms automatic/Save overlaps were rejected, while the two lifecycle sequences above allowed another claim. This was not an Android-device or dialog-UI reproduction; the lifecycle ordering came from inspecting the callers. The three-second manual retry is explicitly intentional and covered by your tests, so I'm not suggesting silently removing that behavior.

@o-jcardenass
o-jcardenass force-pushed the fix/usb-permission-dialog branch 5 times, most recently from 3679d69 to 5b56290 Compare October 29, 2026 15:12
@o-jcardenass o-jcardenass changed the title USB: stop the permission dialog that comes back on every connect, and let a plug-in leave the phone alone USB: stop the permission dialog that comes back on every connect, let a plug-in leave the phone alone, and recover a lost link Oct 29, 2026
@o-jcardenass
o-jcardenass force-pushed the fix/usb-permission-dialog branch from 15a13bf to fd11055 Compare October 29, 2026 19:06
@o-jcardenass
o-jcardenass marked this pull request as ready for review October 29, 2026 20:05
@o-jcardenass

Copy link
Copy Markdown
Collaborator Author

@andrecuellar @andreknieriem @emotionbug Please test this. I changed when the USB permission dialogs appear.

Before this PR, a first connect could show up to 4 dialogs: 2 from Android and 2 from OHU on top of them. Android shows one dialog before the phone switches to Android Auto and one after, because the phone then looks like a new device. If a dialog was left unticked, all of them came back on the next plug.

Now OHU leaves a plug to Android's dialog when Android shows one, and asks by itself only when Android shows none. So a first connect shows Android's 2 dialogs, and none once both are ticked. The USB button still asks every time.

@andrecuellar OHU's own dialog came from your fix for #173 (#185), and the wake check from #292. I kept both, so they should still work, but please check them on your CMZX65-U1:

  1. A normal plug-in auto-connects, as in USB autoconnect and auto launch feature not working #173.
  2. After a quick-boot wake with the phone already plugged in, the app still asks by itself and connects, as in Auto-start on boot fails on MediaTek head units with Quick Boot / IPO (Instant Power On) #292.

Two changes to know about:

  • "Listen for USB Devices" is now "Detect plugged-in phones/AA dongles", in General. With it off, the wake check from Auto-start on boot fails on MediaTek head units with Quick Boot / IPO (Instant Power On) #292 no longer runs, so a unit with it off does not auto-connect after a wake. Before, it did, with our dialog on every wake. The old description told users to turn it off to stop the prompt, so some users may have it off.
  • If the user cancels Android's dialog, OHU stays quiet until the phone is unplugged. Before, it asked again. The USB button still asks.

One case I could not test: if another app holds "Use by default" for the phone, Android opens that app with no dialog, and OHU waits for a dialog that never comes. If you have a unit with ZLink or Autokit as the default, please try it.

https://github.com/andreknieriem/open-headunit/actions/runs/40937055895/artifacts/12925487501

@emotionbug

emotionbug commented Oct 29, 2026 •

Copy link
Copy Markdown
Contributor

@o-jcardenass I reviewed the updated fd110551. The two request-lifetime issues from my earlier comment are addressed: the record now outlives the service, and token-specific completion/PendingIntent identities protect a newer entry from an older answer.

I found two further lifecycle defects, plus the default-app limitation you called out. Here are concrete implementation directions, including where the public Android APIs limit what can be guaranteed.

1. Connect-on-plug off is bypassed when the plug starts a fresh service

Location: UsbAttachedActivity.kt:174–176.

With Connect when plugged in off, either single-USB auto-connect or last-session auto-connect on, and the service not running:

normal-mode phone attaches
  -> OPEN_HOME, without switching here
  -> Main starts AapService without the plug-in trigger
  -> onCreate runs checkAlreadyConnected() as APP_START
  -> the existing single-device/last-session setting switches the phone anyway

I exercised the current production checkAlreadyConnected, refusesPlugIn and performSingleConnect bodies with recording Android/native boundaries. Both auto-connect modes produced:

explicit PLUG_IN scan:                         0 switches
OPEN_HOME followed by the startup APP_START:  1 switch

Suggested correction:

  • Carry the existing USB trigger contract through UsbAttachedActivity -> MainActivity -> AapService. Use an explicit extra, not the human-readable "USB plug-in" launch-source string as a control flag.
  • Do not rely only on adding that extra: AapService.onCreate() runs before onStartCommand() can inspect it. Move initial automatic connection activation to the first non-Stop onStartCommand(), after foreground promotion and after reading its actual request. Resource initialization and receiver registration can remain in onCreate().
  • Preserve the current USB scan/claim before wireless activation ordering. Moving only USB later, while leaving wireless bring-up in onCreate(), would change arbitration.
  • Process the first USB request once, rather than performing a generic startup scan followed by the same command's scan. Later commands still dispatch normally.
  • Preserve manual USB-button admission, ordinary app-start auto-connect settings, boot/wake rules and already-accessory-mode handling. A blanket if (!usbConnectOnPlugIn) return would break those paths.

2. Another USB default app cannot be distinguished reliably from an unanswered/cancelled Android chooser

Location: UsbPermissionAskPolicy.kt:83–85.

This matches the untested case in your comment. For example, with an accessory identity whose default handler is another app, Android grants that app permission and launches it directly; no chooser needs to appear. OHU still sees a matching filter, an observed attach and no permission, so the automatic policy returns WAIT indefinitely. The same assumption also suppresses the reopen UI. I replayed the policy at 2 seconds, 1 minute and 1 hour after attach; all returned WAIT.

AOSP's default-handler branch grants the selected UID and starts its activity directly. USB defaults are resolved using the USB service's own preference map, not simply PackageManager.resolveActivity().

Important implementation constraint: the public UsbManager API exposes the caller's permission state and its own permission-request result, but not the selected USB-default package or the visibility/dismissal of Android's attach chooser. These cases can therefore look identical to OHU:

no permission + matching filter + observed attach
  - Android's chooser is still open
  - the user cancelled that chooser
  - another default app was launched without a chooser

Consequently, “never ask again after cancellation” and “automatically recover another app's default without user action” cannot both be guaranteed from those observations.

Feasible conservative direction:

  • Treat androidShowsDialog() as a defer-to-Android policy, not proof that a dialog is currently visible. Name the state accordingly; do not claim a confirmed pending user response.
  • When OHU is visible, expose the permission-needed state and the existing explicit USB-connect action instead of leaving the user with an unexplained wait. That action can use the existing tokenized askFromUi() path and repeat suppression.
  • Make clear that this requests temporary access for OHU; it does not change another app's default or guarantee that another app releases the device.
  • For unattended automatic launches, explain that the user may need to clear the existing USB-default selection in the platform/OEM settings and choose OHU on reattachment when Android offers the choice. Do not use hidden APIs to change another app's defaults.
  • Do not add a timeout that automatically calls requestPermission() after a few seconds: it would also fire while the user is reading the chooser or after they cancelled it, reintroducing the duplicate/recurring-dialog problem this PR fixes.

This is a product/API limitation to state explicitly, not a fully automatic recovery that can be implemented just by querying a default-app API. The conservative path preserves cancellation and provides a clear manual escape; it does not claim automatic recovery in every competing-default configuration.

3. The permission record outlives detach observation

Location: UsbPermissionAsks.kt:36–37.

A separate service-restart sequence remains:

  1. An app permission request is denied, so its entry for bus path P remains.
  2. The service stops but the application process and record survive.
  3. The device detaches while the service's detach receiver is absent. No device-list snapshot observes P missing.
  4. A later enumeration reuses P, and the service starts again.
  5. prune() retains the old entry because P is present. A newly observed attach only updates attachedAtMs; it does not retire the old entry.

For an API 24+ non-automotive MTP case where the policy would otherwise request permission itself, automatic admission now returns ASKED instead. Executing the production synchronized record and policy yielded:

denial -> service gap -> missed detach/reused path -> new attach: ASKED
same conditions with an observed detach:                         ASK

Suggested correction:

  • Register a small process-lifetime detach receiver from App.onCreate() using the application context. Its only job is ACTION_USB_DEVICE_DETACHED -> UsbPermissionAsks.noteDetach(path). It should not start the service, attempt a connection or access credential-protected settings, so it can also be registered before unlock.
  • Do not unregister it when AapService stops. The observation and the in-memory record then have matching lifetimes; process death drops both, so no persistence is needed.
  • Give permission-record invalidation one owner. Leave service-owned recovery-target observation in noteRecoveryDeviceDetached(), but let the process receiver own the shared record's detach cleanup.
  • Keep token-specific result handling. A late result for the previous enumeration must not clear a new request.
  • Do not fix this by clearing all records on service recreation—that restores the original unanswered-dialog bug. Do not blindly clear on every stampAttach() either: the service and attach Activity can both observe the same attachment. VID/PID alone also cannot distinguish unplug/replug of the same phone.

This addresses the service-lifetime observation gap. It is not a claim to infer every re-enumeration on firmware that omits the underlying system events as well.

Acceptance scenarios

  • Connect-on-plug off + single-USB on + no service: opens home, zero AOA switches.
  • Repeat with last-session auto-connect on; same result. An explicit USB-button request still connects.
  • Restart the service while a permission dialog remains unanswered: no second request.
  • Deny, stop the service without killing the process, detach/reuse the path, then restart: the old entry does not suppress the new attachment.
  • Deliver the old token after a new request: the newer entry survives.
  • With another USB default handler: no misleading claim that a chooser is known to be open, and explicit permission request remains available.
  • Cancel Android's chooser: no timer-driven automatic re-prompt.

The service leaves a plug to Android's dialog while it stays plugged in and asks once per bus path.
Each ask has its own token and PendingIntent, and only a grant ends an entry.
…ion ask

Both ask as the user through the service's record. A blacklisted accessory device shows as
blacklisted, and below API 21 the key reads the device names from sysfs.
…e phone

A new key, default on, so every install keeps plug-and-connect. Off, a plug-in opens the app and the
USB button connects; accessory identities, reconnects and a re-attach after a glitch stay ungated.
Connect when plugged in is the new plug-in key, shown disabled when detection is off. The old
auto-start key becomes Open the app when plugged in; onboarding shows the same rows.
A frame header with a flag bit above 0x0F means bytes were lost from the stream.
Disconnect at once instead of decoding body bytes as headers until the socket dies.
Each session end records who ended it. A formed USB session lost to a link failure with the phone attached
re-checks the bus even with auto-connect off, and only such a loss shows a notice, once per episode.
The next bulk read is discarded as failed, as a bad transfer does. Armed only through the automation receiver's settings import, with no visible setting.
@o-jcardenass
o-jcardenass force-pushed the fix/usb-permission-dialog branch from fd11055 to bb09069 Compare October 29, 2026 22:30
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants