# Data handoff, notifications and location

Moving state to the full app, signing people in once, notifying them within
the clip's window, and proving they are where the code is. Summary in
`../SKILL.md`.

## Handing data to the full app

When someone installs the full app it replaces the clip. Anything non-secret
the full app should pick up goes into a shared App Group container. Add the
App Groups capability to both targets with the same group identifier.

```swift
import Foundation

struct PendingTicket: Codable {
    let seat: String
    let showTime: Date
}

enum SharedDrop {
    static let group = "group.org.example.tickets"

    static func file() -> URL? {
        FileManager.default
            .containerURL(forSecurityApplicationGroupIdentifier: group)?
            .appending(path: "pending-ticket.json")
    }

    static func save(_ ticket: PendingTicket) throws {
        guard let target = file() else { return }
        try JSONEncoder().encode(ticket).write(to: target, options: .atomic)
    }

    static func load() -> PendingTicket? {
        guard let source = file(), FileManager.default.fileExists(atPath: source.path()) else {
            return nil
        }
        guard let bytes = try? Data(contentsOf: source) else { return nil }
        return try? JSONDecoder().decode(PendingTicket.self, from: bytes)
    }
}
```

The clip calls `save`; the full app calls `load` on first launch. Both return
early when the container is unavailable (for example, a misconfigured
entitlement).

For small values (identifiers, timestamps, choices, cart metadata), a shared
defaults suite is enough:

```swift
import Foundation

func rememberPendingOrder(_ orderID: String) {
    UserDefaults(suiteName: "group.org.example.tickets")?.set(orderID, forKey: "pendingOrderID")
}
```

Any target that carries the same App Group entitlement can read and write this
data. Treat it as a convenience channel, not a trust boundary, and keep
secrets out of it.

## Keychain

- From iOS 15.4 the full app can read keychain items the clip created. The
  system uses the association entitlements to make sure only the matching
  full app gets them: `com.apple.developer.parent-application-identifiers` on
  the clip and `com.apple.developer.associated-appclip-app-identifiers` on the
  full app (Xcode adds the latter when you archive).
- It only works in that direction. The clip cannot read items the full app
  stored.
- On iOS 15.3 and earlier the full app cannot see the clip's items at all;
  use a shared container value or Sign in with Apple instead.
- Because people install, delete and reinstall, both targets may end up
  writing the same logical item. Give clip-created items a distinct label,
  service or account name (for example a `kSecAttrLabel` of `tickets-clip`),
  and when migrating, detect duplicates, decide which copy wins and delete the
  rest.

## Sign in with Apple

Store the stable user identifier from `ASAuthorizationAppleIDCredential.user`
in the shared container. The full app checks that it is still valid instead
of asking the person to sign in again:

```swift
import AuthenticationServices

func adoptClipAccount(userID: String, onValid: @escaping @Sendable () -> Void) {
    ASAuthorizationAppleIDProvider().getCredentialState(forUserID: userID) { state, _ in
        if state == .authorized {
            onValid()
        }
    }
}
```

Anything other than `.authorized` (revoked, not found, transferred) means the
full app runs its normal sign-in.

## Ephemeral notifications

- Add `NSAppClip` (a dictionary) to the clip's Info.plist and set
  `NSAppClipRequestEphemeralUserNotification` to `true` inside it.
- The clip may then schedule or receive notifications for up to 8 hours after
  each launch.
- The App Clip card mentions this and people can turn it off there, so check
  the current status first:

```swift
import UserNotifications

func scheduleIfAllowed(_ request: UNNotificationRequest) async throws {
    let center = UNUserNotificationCenter.current()
    guard await center.notificationSettings().authorizationStatus == .ephemeral else { return }
    try await center.add(request)
}
```

- A clip whose job lasts longer than a day can ask for regular notification
  permission, but a refusal also removes the 8-hour ephemeral window.
- Tapping a notification relaunches the clip without an invocation URL.
  Handle that in the clip and in the full app.
- A clip that serves several places or businesses must tell the system which
  experience a notification belongs to. Remote pushes set `target-content-id`
  in the APNs payload; local notifications set
  `UNNotificationContent.targetContentIdentifier`. The value is a URL that
  matches the relevant advanced experience, as specific as possible, not an
  arbitrary token.

## Location confirmation

`APActivationPayload` (iOS 14+) confirms that a physical invocation (App Clip
Code, NFC tag or QR code) happened inside a region you name, without giving
the clip ongoing location access. The region's radius may be at most 500 m.

```swift
import AppClip
import CoreLocation

enum BoothCheck: Sendable {
    case confirmed, elsewhere, linkMismatch, notAllowed, unknown
}

func confirmAtBooth(_ activity: NSUserActivity, booth: CLLocationCoordinate2D,
                    done: @escaping @Sendable (BoothCheck) -> Void) {
    guard let payload = activity.appClipActivationPayload else {
        done(.unknown)
        return
    }
    let area = CLCircularRegion(center: booth, radius: 80, identifier: "booth")
    payload.confirmAcquired(in: area) { inside, failure in
        guard let failure else {
            done(inside ? .confirmed : .elsewhere)
            return
        }
        guard let reason = failure as? APActivationPayloadError else {
            done(.unknown)
            return
        }
        switch reason.code {
        case .doesNotMatch: done(.linkMismatch)
        case .disallowed: done(.notAllowed)
        @unknown default: done(.unknown)
        }
    }
}
```

Route into the location-specific flow only on `.confirmed`.

- `.doesNotMatch`: the invocation URL does not match the registered App Clip URL.
- `.disallowed`: the person denied location, or the invocation did not come
  from an NFC tag or visual code.

Enable it in the clip target's Info.plist only:

```xml
<key>NSAppClip</key>
<dict>
    <key>NSAppClipRequestLocationConfirmation</key>
    <true/>
</dict>
```

The system answers whether the invocation happened in the region; the App
Clip card tells the person the clip can verify their location.

## Lifecycle and short-lived state

- No Home Screen icon. Clips show up in the App Library and in recently used
  apps.
- The system may delete an unused clip, with its data, after a period it
  decides.
- Nothing guarantees local state survives. Move important non-secret data to
  the shared container or a server.
- Relaunching from the App Library reuses the last invocation URL; relaunching
  from the app switcher has none, so restore saved state.
- Location is limited to When In Use. Authorization is short-lived and resets
  automatically (the next day at 4:00 a.m.); continuous access cannot be
  requested.
