{"id":28435136,"url":"https://github.com/skiptools/skip-kit","last_synced_at":"2026-04-02T20:18:02.597Z","repository":{"id":240932590,"uuid":"803828371","full_name":"skiptools/skip-kit","owner":"skiptools","description":"Common iOS and Android feature abstractions for Skip apps","archived":false,"fork":false,"pushed_at":"2026-03-28T21:27:14.000Z","size":131,"stargazers_count":9,"open_issues_count":1,"forks_count":4,"subscribers_count":4,"default_branch":"main","last_synced_at":"2026-03-29T00:27:51.055Z","etag":null,"topics":[],"latest_commit_sha":null,"homepage":"https://skip.dev/docs/modules/skip-kit/","language":"Swift","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":"other","status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/skiptools.png","metadata":{"files":{"readme":"README.md","changelog":"CHANGELOG.md","contributing":null,"funding":null,"license":"LICENSE.LGPL","code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":null,"support":null,"governance":null,"roadmap":null,"authors":null,"dei":null,"publiccode":null,"codemeta":null,"zenodo":null,"notice":null,"maintainers":null,"copyright":null,"agents":null,"dco":null,"cla":null},"funding":{"custom":["https://skip.dev/sponsor"]}},"created_at":"2024-05-21T13:05:06.000Z","updated_at":"2026-03-28T21:27:17.000Z","dependencies_parsed_at":"2025-05-20T21:57:27.297Z","dependency_job_id":"a3ca243e-7b1a-4eb4-9eed-9351a61e60ce","html_url":"https://github.com/skiptools/skip-kit","commit_stats":null,"previous_names":["skiptools/skip-kit"],"tags_count":23,"template":false,"template_full_name":null,"purl":"pkg:github/skiptools/skip-kit","repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/skiptools%2Fskip-kit","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/skiptools%2Fskip-kit/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/skiptools%2Fskip-kit/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/skiptools%2Fskip-kit/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/skiptools","download_url":"https://codeload.github.com/skiptools/skip-kit/tar.gz/refs/heads/main","sbom_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/skiptools%2Fskip-kit/sbom","scorecard":null,"host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":286080680,"owners_count":31315403,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2026-04-02T12:59:32.332Z","status":"ssl_error","status_checked_at":"2026-04-02T12:54:48.875Z","response_time":89,"last_error":"SSL_connect returned=1 errno=0 peeraddr=140.82.121.5:443 state=error: unexpected eof while reading","robots_txt_status":"success","robots_txt_updated_at":"2025-07-24T06:49:26.215Z","robots_txt_url":"https://github.com/robots.txt","online":false,"can_crawl_api":true,"host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":[],"created_at":"2025-06-05T20:06:41.765Z","updated_at":"2026-04-02T20:18:02.590Z","avatar_url":"https://github.com/skiptools.png","language":"Swift","funding_links":["https://skip.dev/sponsor"],"categories":[],"sub_categories":[],"readme":"# SkipKit\n\nThis [Skip Lite](https://skip.dev) module enhances the `SkipUI` package with commonly-used features,\nsuch as a permission checker and a picker for photos and other media.\n\n## Setup\n\nTo include this framework in your project, add the following\ndependency to your `Package.swift` file:\n\n```swift\nlet package = Package(\n    name: \"my-package\",\n    products: [\n        .library(name: \"MyProduct\", targets: [\"MyTarget\"]),\n    ],\n    dependencies: [\n        .package(url: \"https://source.skip.dev/skip-kit.git\", from: \"1.0.0\"),\n    ],\n    targets: [\n        .target(name: \"MyTarget\", dependencies: [\n            .product(name: \"SkipKit\", package: \"skip-kit\")\n        ])\n    ]\n)\n```\n\n## Cache\n\nThe `Cache\u003cKey, Value\u003e` class manages a memory-pressure-aware cache that can be\nused for storing temporary values.\n\nExample usage:\n\n```swift\n// Create a cache that can store up to 100 bytes of Data instances\n// and will evict everything when the app is put in the background\nlet cache = Cache\u003cUUID, Data\u003e(evictOnBackground: true, limit: 100, cost: \\.count)\n\ncache.putValue(Data(count: 1), for: UUID()) // total cost = 1\ncache.putValue(Data(count: 99), for: UUID()) // total cost = 100\ncache.putValue(Data(count: 1), for: UUID()) // total cost = 101, so cache will evict older entries\n```\n\n\n## AppInfo\n\n`AppInfo.current` provides read-only access to information about the currently running application, including its version, name, bundle identifier, build configuration, and platform details. It works consistently on both iOS and Android.\n\n### Basic Usage\n\n```swift\nimport SkipKit\n\nlet info = AppInfo.current\n\n// Version and identity\nprint(\"App Name: \\(info.displayName ?? \"Unknown\")\")\nprint(\"App ID: \\(info.appIdentifier ?? \"Unknown\")\")\nprint(\"Version: \\(info.version ?? \"Unknown\")\")\nprint(\"Build: \\(info.buildNumber ?? \"Unknown\")\")\nprint(\"Full: \\(info.versionWithBuild)\")  // e.g. \"1.2.3 (42)\"\n\n// Build configuration\nif info.isDebug {\n    print(\"Running in debug mode\")\n}\n\nif info.isTestFlight {\n    print(\"Installed from TestFlight\")\n}\n\n// Platform\nprint(\"OS: \\(info.osName) \\(info.osVersion)\")  // e.g. \"iOS 17.4.1\" or \"Android 34\"\nprint(\"Device: \\(info.deviceModel)\")             // e.g. \"iPhone15,2\" or \"Pixel 7\"\n```\n\n### User-Facing Version String\n\n```swift\nText(\"Version \\(AppInfo.current.versionWithBuild)\")\n// Displays: \"Version 1.2.3 (42)\"\n```\n\n### Debug vs Release\n\n```swift\nif AppInfo.current.isDebug {\n    // Show debug tools, logging, etc.\n} else {\n    // Production behavior\n}\n```\n\n### iOS Info.plist Access\n\nOn iOS, you can query raw Info.plist values:\n\n```swift\nlet keys = AppInfo.current.infoDictionaryKeys\nif let minOS = AppInfo.current.infoDictionaryValue(forKey: \"MinimumOSVersion\") as? String {\n    print(\"Requires iOS \\(minOS)\")\n}\n```\n\n### URL Schemes\n\n```swift\nlet schemes = AppInfo.current.urlSchemes\n// e.g. [\"myapp\"] from CFBundleURLTypes\n```\n\n### API Reference\n\n| Property | Type | Description |\n|---|---|---|\n| `bundleIdentifier` | `String?` | Bundle ID (iOS) or package name (Android) |\n| `displayName` | `String?` | User-visible app name |\n| `version` | `String?` | Version string (e.g. `\"1.2.3\"`) |\n| `buildNumber` | `String?` | Build number as string |\n| `buildNumberInt` | `Int?` | Build number as integer |\n| `versionWithBuild` | `String` | Combined `\"version (build)\"` string |\n| `isDebug` | `Bool` | Debug build (`DEBUG` flag on iOS, `FLAG_DEBUGGABLE` on Android) |\n| `isRelease` | `Bool` | Release build (inverse of `isDebug`) |\n| `isTestFlight` | `Bool` | TestFlight install (iOS only, always `false` on Android) |\n| `osName` | `String` | Platform name (`\"iOS\"`, `\"Android\"`, `\"macOS\"`) |\n| `osVersion` | `String` | OS version (e.g. `\"17.4.1\"` or `\"34\"` for API level) |\n| `deviceModel` | `String` | Device model (e.g. `\"iPhone15,2\"`, `\"Pixel 7\"`) |\n| `minimumOSVersion` | `String?` | Minimum required OS version |\n| `urlSchemes` | `[String]` | Registered URL schemes (iOS only) |\n| `infoDictionaryKeys` | `[String]` | All Info.plist keys (iOS only) |\n| `infoDictionaryValue(forKey:)` | `Any?` | Raw Info.plist value lookup (iOS only) |\n\n\u003e [!NOTE]\n\u003e **iOS implementation**: Reads from `Bundle.main.infoDictionary`, `ProcessInfo`, and `utsname` for device model. TestFlight detection uses the sandbox receipt URL.\n\u003e\n\u003e **Android implementation**: Reads from `PackageManager.getPackageInfo()`, `ApplicationInfo`, and `android.os.Build`. The `osVersion` returns the SDK API level (e.g. `\"34\"` for Android 14). The `isDebug` flag checks `ApplicationInfo.FLAG_DEBUGGABLE`.\n\n## DeviceInfo\n\n`DeviceInfo.current` provides information about the physical device, including screen dimensions, device type, battery status, network connectivity, and locale.\n\n### Screen Information\n\n```swift\nimport SkipKit\n\nlet device = DeviceInfo.current\n\nprint(\"Screen: \\(device.screenWidth) x \\(device.screenHeight) points\")\nprint(\"Scale: \\(device.screenScale)x\")\n```\n\n### Device Type\n\nDetermine whether the app is running on a phone, tablet, desktop, TV, or watch:\n\n```swift\nswitch DeviceInfo.current.deviceType {\ncase .phone:  print(\"Phone\")\ncase .tablet: print(\"Tablet\")\ncase .desktop: print(\"Desktop\")\ncase .tv:     print(\"TV\")\ncase .watch:  print(\"Watch\")\ncase .unknown: print(\"Unknown\")\n}\n\n// Convenience checks\nif DeviceInfo.current.isTablet {\n    // Use tablet layout\n}\n```\n\nOn iOS, this uses `UIDevice.current.userInterfaceIdiom`. On Android, it uses the screen layout configuration (large/xlarge = tablet).\n\n### Device Model\n\n```swift\nprint(\"Manufacturer: \\(DeviceInfo.current.manufacturer)\")  // \"Apple\" or \"Google\", \"Samsung\", etc.\nprint(\"Model: \\(DeviceInfo.current.modelName)\")              // \"iPhone15,2\" or \"Pixel 7\"\n```\n\n### Battery\n\n```swift\nif let level = DeviceInfo.current.batteryLevel {\n    print(\"Battery: \\(Int(level * 100))%\")\n}\n\nswitch DeviceInfo.current.batteryState {\ncase .charging: print(\"Charging\")\ncase .full:     print(\"Full\")\ncase .unplugged: print(\"On battery\")\ncase .unknown:  print(\"Unknown\")\n}\n```\n\nOn iOS, uses `UIDevice.current.batteryLevel` and `.batteryState`. On Android, uses `BatteryManager`.\n\n### Network Connectivity\n\n#### One-Shot Check\n\nFor a single point-in-time check, use the synchronous properties:\n\n```swift\nlet status = DeviceInfo.current.networkStatus\nswitch status {\ncase .wifi:     print(\"Connected via Wi-Fi\")\ncase .cellular: print(\"Connected via cellular\")\ncase .ethernet: print(\"Connected via Ethernet\")\ncase .other:    print(\"Connected (other)\")\ncase .offline:  print(\"No connection\")\n}\n\n// Convenience checks\nif DeviceInfo.current.isOnline {\n    // Proceed with network request\n}\nif DeviceInfo.current.isOnWifi {\n    // Safe for large downloads\n}\n```\n\n#### Live Monitoring with AsyncStream\n\nFor live updates whenever connectivity changes, use `monitorNetwork()` which returns an `AsyncStream\u003cNetworkStatus\u003e`. The stream emits an initial value immediately and then a new value each time the network status changes:\n\n```swift\nstruct ConnectivityView: View {\n    @State var status: NetworkStatus = .offline\n\n    var body: some View {\n        VStack {\n            Text(\"Network: \\(status.rawValue)\")\n            Circle()\n                .fill(status == .offline ? Color.red : Color.green)\n                .frame(width: 20, height: 20)\n        }\n        .task {\n            for await newStatus in DeviceInfo.current.monitorNetwork() {\n                status = newStatus\n            }\n        }\n    }\n}\n```\n\nYou can also use it in non-UI code:\n\n```swift\nfunc waitForConnectivity() async -\u003e NetworkStatus {\n    for await status in DeviceInfo.current.monitorNetwork() {\n        if status != .offline {\n            return status\n        }\n    }\n    return .offline\n}\n```\n\nCancel the monitoring by cancelling the enclosing `Task`:\n\n```swift\nlet monitorTask = Task {\n    for await status in DeviceInfo.current.monitorNetwork() {\n        print(\"Status changed: \\(status.rawValue)\")\n    }\n}\n\n// Later, stop monitoring:\nmonitorTask.cancel()\n```\n\nOn iOS, `monitorNetwork()` uses `NWPathMonitor` for live path updates. On Android, it uses `ConnectivityManager.registerDefaultNetworkCallback` which receives `onAvailable`, `onLost`, and `onCapabilitiesChanged` callbacks. The callback is automatically unregistered when the stream is cancelled.\n\n\u003e [!NOTE]\n\u003e **Android**: To query or monitor network status, your app needs the `android.permission.ACCESS_NETWORK_STATE` permission in `AndroidManifest.xml`:\n\u003e ```xml\n\u003e \u003cuses-permission android:name=\"android.permission.ACCESS_NETWORK_STATE\" /\u003e\n\u003e ```\n\n### Locale\n\n```swift\nprint(\"Locale: \\(DeviceInfo.current.localeIdentifier)\")      // e.g. \"en_US\"\nprint(\"Language: \\(DeviceInfo.current.languageCode ?? \"\")\")   // e.g. \"en\"\nprint(\"Time zone: \\(DeviceInfo.current.timeZoneIdentifier)\")  // e.g. \"America/New_York\"\n```\n\n### API Reference\n\n**Screen:**\n\n| Property | Type | Description |\n|---|---|---|\n| `screenWidth` | `Double` | Screen width in points |\n| `screenHeight` | `Double` | Screen height in points |\n| `screenScale` | `Double` | Pixels per point |\n\n**Device:**\n\n| Property | Type | Description |\n|---|---|---|\n| `deviceType` | `DeviceType` | `.phone`, `.tablet`, `.desktop`, `.tv`, `.watch`, `.unknown` |\n| `isTablet` | `Bool` | Whether the device is a tablet |\n| `isPhone` | `Bool` | Whether the device is a phone |\n| `manufacturer` | `String` | Device manufacturer (`\"Apple\"` on iOS) |\n| `modelName` | `String` | Model identifier (e.g. `\"iPhone15,2\"`, `\"Pixel 7\"`) |\n\n**Battery:**\n\n| Property | Type | Description |\n|---|---|---|\n| `batteryLevel` | `Double?` | Battery level 0.0–1.0, or `nil` if unavailable |\n| `batteryState` | `BatteryState` | `.unplugged`, `.charging`, `.full`, `.unknown` |\n\n**Network:**\n\n| Property | Type | Description |\n|---|---|---|\n| `networkStatus` | `NetworkStatus` | One-shot connectivity check |\n| `isOnline` | `Bool` | Has any network connectivity (one-shot) |\n| `isOnWifi` | `Bool` | Connected via Wi-Fi (one-shot) |\n| `isOnCellular` | `Bool` | Connected via cellular (one-shot) |\n| `monitorNetwork()` | `AsyncStream\u003cNetworkStatus\u003e` | Live connectivity updates |\n\n**Locale:**\n\n| Property | Type | Description |\n|---|---|---|\n| `localeIdentifier` | `String` | Current locale (e.g. `\"en_US\"`) |\n| `languageCode` | `String?` | Language code (e.g. `\"en\"`) |\n| `timeZoneIdentifier` | `String` | Time zone (e.g. `\"America/New_York\"`) |\n\n## PermissionManager\n\nThe `PermissionManager` provides the ability to request device permissions.\n\nFor example:\n\n```swift\nimport SkipKit\nimport SkipDevice\n\nlet locationProvider = LocationProvider()\n\nif await PermissionManager.requestPermission(.ACCESS_FINE_LOCATION) == true {\n    let location = try await locationProvider.fetchCurrentLocation()\n}\n```\n\nIn addition to symbolic constants, there are also functions for requesting\nspecific permissions with various parameters:\n\n```swift\nstatic func queryLocationPermission(precise: Bool, always: Bool) -\u003e PermissionAuthorization\nstatic func requestLocationPermission(precise: Bool, always: Bool) async -\u003e PermissionAuthorization\n\nstatic func queryPostNotificationPermission() async -\u003e PermissionAuthorization\nstatic func requestPostNotificationPermission(alert: Bool = true, sound: Bool = true, badge: Bool = true) async throws -\u003e PermissionAuthorization\n\nstatic func queryCameraPermission() -\u003e PermissionAuthorization\nstatic func requestCameraPermission() async -\u003e PermissionAuthorization\n\nstatic func queryRecordAudioPermission() -\u003e PermissionAuthorization\nstatic func requestRecordAudioPermission() async -\u003e PermissionAuthorization\n\nstatic func queryContactsPermission(readWrite: Bool) -\u003e PermissionAuthorization\nstatic func requestContactsPermission(readWrite: Bool) async throws -\u003e PermissionAuthorization\n\nstatic func queryCalendarPermission(readWrite: Bool) -\u003e PermissionAuthorization\nstatic func requestCalendarPermission(readWrite: Bool) async throws -\u003e PermissionAuthorization\n\nstatic func queryReminderPermission(readWrite: Bool) -\u003e PermissionAuthorization\nstatic func requestReminderPermission(readWrite: Bool) async throws -\u003e PermissionAuthorization\n\nstatic func queryPhotoLibraryPermission(readWrite: Bool) -\u003e PermissionAuthorization\nstatic func requestPhotoLibraryPermission(readWrite: Bool) async -\u003e PermissionAuthorization\n```\n\nTo request an arbitrary Android permission for which there may be no\niOS equivalent, you can pass the string literal. For a list of common permission literals, see\n[https://developer.android.com/reference/android/Manifest.permission](https://developer.android.com/reference/android/Manifest.permission).\n\nFor example, to request the SMS sending permission:\n\n```swift\nlet granted = await PermissionManager.requestPermission(\"android.permission.SEND_SMS\")\n```\n\n## Camera and Media selection\n\nThe `View.withMediaPicker(type:isPresented:selectedImageURL:)` extension function\ncan be used to enable the acquisition of an image from either the system camera \nor the user's media library. \n\nOn iOS, this camera selector will be presented in a `fullScreenCover` view, \nwhereas the media library browser will be presented in a `sheet`. In both cases,\na standad `UIImagePickerController` will be used to acquire the media.\n\nOn Android, the camera and library browser will be activated through \nan Intent after querying for the necessary permissions.\n\nFollowing is an example of implementing a media selection button that \nwill bring up the system user interface.\n\n```swift\nimport SkipKit\n\n/// A button that enables the selection of media from the library or the taking of a photo.\n///\n/// The selected/captured image will be communicated through the `selectedImageURL` binding,\n/// which can be observed with `onChange` to perform an action when the media URL is acquired.\nstruct MediaButton : View {\n    let type: MediaPickerType // either .camera or .library\n    @Binding var selectedImageURL: URL?\n    @State private var showPicker = false\n\n    var body: some View {\n        Button(type == .camera ? \"Take Photo\" : \"Select Media\") {\n            showPicker = true // activate the media picker\n        }\n        .withMediaPicker(type: .camera, isPresented: $showPicker, selectedImageURL: $selectedImageURL)\n    }\n}\n```\n\n### Camera and Media Permissions\n\nIn order to access the device's photos or media library, you will need to \ndeclare the permissions in the app's metadata.\n\nOn iOS this can be done by editing the `Darwin/AppName.xcconfig` file and adding the lines:\n\n```\nINFOPLIST_KEY_NSCameraUsageDescription = \"This app needs to access the camera\";\nINFOPLIST_KEY_NSPhotoLibraryUsageDescription = \"This app needs to access the photo library.\";\n```\n\nOn Android, the `app/src/main/AndroidManifest.xml` file will need to be edited to include \ncamera permissions as well as a FileProvider implementation so the camera can share a Uri with the app. For example:\n\n```xml\n\u003c?xml version=\"1.0\" encoding=\"utf-8\"?\u003e\n\u003cmanifest xmlns:android=\"http://schemas.android.com/apk/res/android\" xmlns:tools=\"http://schemas.android.com/tools\"\u003e\n    \u003c!-- features and permissions needed in order to use the camera and read/write photos --\u003e\n    \u003cuses-feature\n        android:name=\"android.hardware.camera\"\n        android:required=\"false\" /\u003e\n    \u003cuses-feature\n        android:name=\"android.hardware.camera.autofocus\"\n        android:required=\"false\" /\u003e\n    \u003cuses-permission android:name=\"android.permission.CAMERA\" /\u003e\n    \u003cuses-permission android:name=\"android.permission.READ_EXTERNAL_STORAGE\" /\u003e\n    \u003cuses-permission android:name=\"android.permission.WRITE_EXTERNAL_STORAGE\" /\u003e\n    \u003capplication\n        android:label=\"${PRODUCT_NAME}\"\n        android:name=\".AndroidAppMain\"\n        android:supportsRtl=\"true\"\n        android:allowBackup=\"true\"\n        android:icon=\"@mipmap/ic_launcher\"\u003e\n        \u003cactivity\n            android:name=\".MainActivity\"\n            android:exported=\"true\"\n            android:configChanges=\"orientation|screenSize|screenLayout|keyboardHidden|mnc|colorMode|density|fontScale|fontWeightAdjustment|keyboard|layoutDirection|locale|mcc|navigation|smallestScreenSize|touchscreen|uiMode\"\n            android:theme=\"@style/Theme.AppCompat.DayNight.NoActionBar\"\n            android:windowSoftInputMode=\"adjustResize\"\u003e\n            \u003cintent-filter\u003e\n                \u003caction android:name=\"android.intent.action.MAIN\" /\u003e\n                \u003ccategory android:name=\"android.intent.category.LAUNCHER\" /\u003e\n            \u003c/intent-filter\u003e\n        \u003c/activity\u003e\n        \u003c!-- needed in order for the camera to be able to share the photo with the app --\u003e\n        \u003cprovider\n            android:name=\"androidx.core.content.FileProvider\"\n            android:authorities=\"${applicationId}.fileprovider\"\n            android:exported=\"false\"\n            android:grantUriPermissions=\"true\"\u003e\n            \u003cmeta-data\n                android:name=\"android.support.FILE_PROVIDER_PATHS\"\n                android:resource=\"@xml/file_paths\" /\u003e\n        \u003c/provider\u003e\n    \u003c/application\u003e\n\u003c/manifest\u003e\n```\n\nIn addition to editing the manifest, you must also manually create the `xml/file_paths` reference from the manifest's provider. This is done by creating the folder `Android/app/src/main/res/xml` in your Skip project and adding a file `file_paths.xml` with the following contents:\n\n```xml\n\u003c?xml version=\"1.0\" encoding=\"utf-8\"?\u003e\n\u003cpaths\u003e\n    \u003cexternal-path name=\"my_images\" path=\".\" /\u003e\n    \u003ccache-path name=\"*\" path=\".\" /\u003e\n\u003c/paths\u003e\n```\n\nFor an example of a properly configured project, see the Photo Chat sample application.\n\n## Document Picker\n\nThe `View.withDocumentPicker(isPresented: Binding\u003cBool\u003e, allowedContentTypes: [UTType], selectedDocumentURL: Binding\u003cURL?\u003e, selectedFilename: Binding\u003cString?\u003e, selectedFileMimeType: Binding\u003cString?\u003e)` extension function can be used to select a document of the specified UTType from the device to use in the App. \n\nOn iOS it will use an instance of `FileImporter` to display the system file picker, essentially allowing to select a file from the Files application, while on Android it relies on the the system document picker via the Activity result for the `ACTION_OPEN_DOCUMENT`. Once the user selects a file it will receive an `uri`, that need to be parsed to be used outside the scope of the caller. For doing so it will copy the file inside the App cache folder and expose the cached url instead of the original picked file url. \n\nFor example:\n\n```swift\nButton(\"Pick Document\") {\n    presentPreview = true\n}\n.buttonStyle(.borderedProminent)\n.withDocumentPicker(isPresented: $presentPreview, allowedContentTypes: [.image, .pdf], selectedDocumentURL: $selectedDocument, selectedFilename: $filename, selectedFileMimeType: $mimeType)\n```\n\n### Security-Scoped URLs on iOS\n\nOn iOS, files selected through the system document picker are *security-scoped* — the app is only granted temporary access to read them. The `withDocumentPicker` modifier acquires and releases this access internally during the picker callback, but your `onChange` handler runs **after** the access has already been released. If you need to read or copy the file contents (e.g., importing it into your app's documents directory), you must re-acquire access in your handler:\n\n```swift\n.withDocumentPicker(\n    isPresented: $showPicker,\n    allowedContentTypes: [.epub],\n    selectedDocumentURL: $pickedURL,\n    selectedFilename: $pickedName,\n    selectedFileMimeType: $pickedType\n)\n.onChange(of: pickedURL) { oldURL, newURL in\n    guard let url = newURL else { return }\n    pickedURL = nil\n    Task {\n        #if !SKIP\n        // Re-acquire security-scoped access for the picked file.\n        // Without this, file operations like copy or read will fail\n        // with a \"you don't have permission to view it\" error.\n        let accessing = url.startAccessingSecurityScopedResource()\n        defer { if accessing { url.stopAccessingSecurityScopedResource() } }\n        #endif\n        await importFile(from: url)\n    }\n}\n```\n\nThis is only needed on iOS — Android handles file access differently by copying the selected file to the app's cache directory before returning the URL, so the `#if !SKIP` guard ensures the security-scoped calls are skipped on Android.\n\nIf you only need to display the file (e.g., pass it to a `DocumentPreview`) without reading its contents, re-acquiring access may not be necessary.\n\n## Mail Composer\n\nThe `View.withMailComposer()` modifier presents a system email composition interface, allowing users to compose and send emails from within your app.\n\nOn iOS, this uses `MFMailComposeViewController` for in-app email composition with full support for recipients, subject, body (plain text or HTML), and file attachments. On Android, this launches an `ACTION_SENDTO` intent (or `ACTION_SEND`/`ACTION_SEND_MULTIPLE` when attachments are present), which opens the user's preferred email app.\n\n### Checking Availability\n\nBefore presenting the composer, check if the device can send email:\n\n```swift\nimport SkipKit\n\nif MailComposer.canSendMail() {\n    // Show compose button\n} else {\n    // Email not available\n}\n```\n\n### Basic Usage\n\n```swift\nstruct EmailView: View {\n    @State var showComposer = false\n\n    var body: some View {\n        Button(\"Send Feedback\") {\n            showComposer = true\n        }\n        .withMailComposer(\n            isPresented: $showComposer,\n            options: MailComposerOptions(\n                recipients: [\"support@example.com\"],\n                subject: \"App Feedback\",\n                body: \"I'd like to share the following feedback...\"\n            ),\n            onComplete: { result in\n                switch result {\n                case .sent: print(\"Email sent!\")\n                case .saved: print(\"Draft saved\")\n                case .cancelled: print(\"Cancelled\")\n                case .failed: print(\"Failed to send\")\n                case .unknown: print(\"Unknown result\")\n                }\n            }\n        )\n    }\n}\n```\n\n### HTML Body\n\n```swift\nMailComposerOptions(\n    recipients: [\"user@example.com\"],\n    subject: \"Welcome!\",\n    body: \"\u003ch1\u003eWelcome\u003c/h1\u003e\u003cp\u003eThank you for signing up.\u003c/p\u003e\",\n    isHTML: true\n)\n```\n\n### Attachments\n\n```swift\nlet pdfURL = Bundle.main.url(forResource: \"report\", withExtension: \"pdf\")!\n\nMailComposerOptions(\n    recipients: [\"team@example.com\"],\n    subject: \"Monthly Report\",\n    body: \"Please find the report attached.\",\n    attachments: [\n        MailAttachment(url: pdfURL, mimeType: \"application/pdf\", filename: \"report.pdf\")\n    ]\n)\n```\n\nMultiple attachments are supported:\n\n```swift\nattachments: [\n    MailAttachment(url: pdfURL, mimeType: \"application/pdf\", filename: \"report.pdf\"),\n    MailAttachment(url: imageURL, mimeType: \"image/png\", filename: \"chart.png\")\n]\n```\n\n### CC and BCC\n\n```swift\nMailComposerOptions(\n    recipients: [\"primary@example.com\"],\n    ccRecipients: [\"manager@example.com\", \"team@example.com\"],\n    bccRecipients: [\"archive@example.com\"],\n    subject: \"Project Update\"\n)\n```\n\n### API Reference\n\n**MailComposer** (static methods):\n\n| Method | Description |\n|---|---|\n| `canSendMail() -\u003e Bool` | Whether the device can compose email |\n\n**MailComposerOptions**:\n\n| Property | Type | Default | Description |\n|---|---|---|---|\n| `recipients` | `[String]` | `[]` | Primary recipients |\n| `ccRecipients` | `[String]` | `[]` | CC recipients |\n| `bccRecipients` | `[String]` | `[]` | BCC recipients |\n| `subject` | `String?` | `nil` | Subject line |\n| `body` | `String?` | `nil` | Body text |\n| `isHTML` | `Bool` | `false` | Whether body is HTML |\n| `attachments` | `[MailAttachment]` | `[]` | File attachments |\n\n**MailAttachment**:\n\n| Property | Type | Description |\n|---|---|---|\n| `url` | `URL` | File URL of the attachment |\n| `mimeType` | `String` | MIME type (e.g. `\"application/pdf\"`) |\n| `filename` | `String` | Display filename |\n\n**MailComposerResult** (enum):\n`sent`, `saved`, `cancelled`, `failed`, `unknown`\n\n### Platform Notes\n\n\u003e [!NOTE]\n\u003e **iOS**: The `MFMailComposeViewController` requires a configured Mail account on the device. On the simulator, `canSendMail()` typically returns `false`. The `onComplete` callback receives a specific result (`.sent`, `.saved`, `.cancelled`, `.failed`).\n\n\u003e [!NOTE]\n\u003e **Android**: The intent-based approach opens the user's default email app. The `onComplete` callback always receives `.unknown` because Android intents do not report back the send status. When there are no attachments, a `mailto:` URI is used with `ACTION_SENDTO` to target only email apps. When attachments are present, `ACTION_SEND` or `ACTION_SEND_MULTIPLE` is used, and the `FLAG_GRANT_READ_URI_PERMISSION` flag is set for file access. You may need to declare the `android.intent.action.SENDTO` intent filter in your `AndroidManifest.xml` for Android 11+ package visibility.\n\n## Document Preview\n\nThe `View.withDocumentPreview(isPresented: Binding\u003cBool\u003e, documentURL: URL?, filename: String?, type: String?)` extension function can be used to preview a document available to the app (either selected with the provided `Document Picker` or downloaded locally by the App). \nOn iOS it will use an instance of `QLPreviewController` to display the file at the provided url while on Android it will open an Intent chooser for selecting the appropriate app for the provided file mime type. \nOn iOS there's no need to provide a filename or a mime type, but sometimes on Android is necessary (for example when selecting a document using the document picker). On Android if no mime type is supplied it will try to guess it by the file url. If no mime type can be found the application chooser will be empty. \nA file provider (like the one used for using the `MediaPicker`) is necessary for the Intent to correctly pass reading permission to the receiving app. As long as your Skip already implements the FileProvider and the `file_paths.xml` as described in the `Camera and Media Permission` section there's nothing else needed, otherwise you need to follow the instructions in the mentioned section. \n\n## WebBrowser\n\nFor cases where you want to display a web page without the full power and complexity of an embedded `WebView` (from [SkipWeb](https://github.com/skiptools/skip-web)), SkipKit provides the `View.openWebBrowser()` modifier. This opens a URL in the platform's native in-app browser:\n\n- **iOS**: [SFSafariViewController](https://developer.apple.com/documentation/safariservices/sfsafariviewcontroller) — a full-featured Safari experience presented within your app, complete with the address bar, share sheet, and reader mode.\n- **Android**: [Chrome Custom Tabs](https://developer.android.com/develop/ui/views/layout/webapps/in-app-browsing-embedded-web) — a Chrome-powered browsing experience that shares cookies, autofill, and saved passwords with the user's browser.\n\n### Basic Usage\n\nOpen a URL in the platform's native in-app browser:\n\n```swift\nimport SwiftUI\nimport SkipKit\n\nstruct MyView: View {\n    @State var showPage = false\n\n    var body: some View {\n        Button(\"Open Documentation\") {\n            showPage = true\n        }\n        .openWebBrowser(\n            isPresented: $showPage,\n            url: \"https://skip.dev/docs\",\n            mode: .embeddedBrowser(params: nil)\n        )\n    }\n}\n```\n\n### Launch in System Browser\n\nTo open the URL in the user's default browser app instead of an in-app browser:\n\n```swift\nButton(\"Open in Safari / Chrome\") {\n    showPage = true\n}\n.openWebBrowser(\n    isPresented: $showPage,\n    url: \"https://skip.dev\",\n    mode: .launchBrowser\n)\n```\n\n### Presentation Mode\n\nBy default the embedded browser slides up vertically as a modal sheet. Set `presentationMode` to `.navigation` for a horizontal slide transition that feels like a navigation push:\n\n```swift\nButton(\"Open with Navigation Style\") {\n    showPage = true\n}\n.openWebBrowser(\n    isPresented: $showPage,\n    url: \"https://skip.dev\",\n    mode: .embeddedBrowser(params: EmbeddedParams(\n        presentationMode: .navigation\n    ))\n)\n```\n\n| Mode | iOS | Android |\n| --- | --- | --- |\n| `.sheet` (default) | Full-screen cover (slides up vertically) | [Partial Custom Tabs](https://developer.chrome.com/docs/android/custom-tabs/guide-partial-custom-tabs/) bottom sheet (resizable, initially half-screen height). Falls back to full-screen if the browser does not support partial tabs. |\n| `.navigation` | Navigation push (slides in horizontally) | Standard full-screen Chrome Custom Tabs launch |\n\n**Limitations:**\n- **iOS:** The `.navigation` presentation mode requires the calling view to be inside a `NavigationStack` (or `NavigationView`). If the view is not hosted in a navigation container, the modifier will have no effect.\n- **Android:** In `.sheet` mode, if the user's browser does not support the Partial Custom Tabs API, the tab launches full-screen as a fallback.\n\n### Custom Actions\n\nAdd custom actions that appear in the share sheet (iOS) or as menu items (Android):\n\n```swift\nButton(\"Open with Actions\") {\n    showPage = true\n}\n.openWebBrowser(\n    isPresented: $showPage,\n    url: \"https://skip.dev\",\n    mode: .embeddedBrowser(params: EmbeddedParams(\n        customActions: [\n            WebBrowserAction(label: \"Copy Link\") { url in\n                // handle the action with the current page URL\n            },\n            WebBrowserAction(label: \"Bookmark\") { url in\n                // save the URL\n            }\n        ]\n    ))\n)\n```\n\nOn iOS, custom actions appear as `UIActivity` items in the Safari share sheet. On Android, they appear as menu items in Chrome Custom Tabs (maximum 5 items).\n\n### API Reference\n\n```swift\n/// Controls how the embedded browser is presented.\npublic enum WebBrowserPresentationMode {\n    /// Present as a vertically-sliding modal sheet (default).\n    case sheet\n    /// Present as a horizontally-sliding navigation push.\n    case navigation\n}\n\n/// The mode for opening a web page.\npublic enum WebBrowserMode {\n    /// Open the URL in the system's default browser application.\n    case launchBrowser\n    /// Open the URL in an embedded browser within the app.\n    case embeddedBrowser(params: EmbeddedParams?)\n}\n\n/// Configuration for the embedded browser.\npublic struct EmbeddedParams {\n    public var presentationMode: WebBrowserPresentationMode\n    public var customActions: [WebBrowserAction]\n}\n\n/// A custom action available on a web page.\npublic struct WebBrowserAction {\n    public let label: String\n    public let handler: (URL) -\u003e Void\n}\n\n/// View modifier to open a web page.\nextension View {\n    public func openWebBrowser(\n        isPresented: Binding\u003cBool\u003e,\n        url: String,\n        mode: WebBrowserMode\n    ) -\u003e some View\n}\n```\n\n## HapticFeedback\n\nSkipKit provides a cross-platform haptic feedback API that works identically on iOS and Android. You define patterns using simple, platform-independent types, and SkipKit handles playback using [CoreHaptics](https://developer.apple.com/documentation/corehaptics) on iOS and [VibrationEffect.Composition](https://developer.android.com/reference/android/os/VibrationEffect.Composition) on Android.\n\n### Playing Predefined Patterns\n\nSkipKit includes a set of predefined patterns for common feedback scenarios:\n\n```swift\nimport SkipKit\n\n// Light tap for picking up a draggable element\nHapticFeedback.play(.pick)\n\n// Quick double-tick when snapping to a grid\nHapticFeedback.play(.snap)\n\n// Solid tap when placing an element\nHapticFeedback.play(.place)\n\n// Confirmation feedback\nHapticFeedback.play(.success)\n\n// Bouncy celebration for clearing a line or completing a task\nHapticFeedback.play(.celebrate)\n\n// Bigger celebration with escalating intensity\nHapticFeedback.play(.bigCelebrate)\n\n// Warning for an invalid action\nHapticFeedback.play(.warning)\n\n// Error feedback with three descending taps\nHapticFeedback.play(.error)\n\n// Heavy impact for collisions\nHapticFeedback.play(.impact)\n```\n\n### Dynamic Patterns\n\nSome patterns adjust based on parameters. The `combo` pattern escalates in intensity and length with higher streak counts, and finishes with a proportionally heavy thud:\n\n```swift\n// A 2x combo: two quick taps + light thud\nHapticFeedback.play(.combo(streak: 2))\n\n// A 5x combo: five escalating taps + heavy thud\nHapticFeedback.play(.combo(streak: 5))\n\n// Bouncing pattern with 4 taps of decreasing intensity\nHapticFeedback.play(.bounce(count: 4, startIntensity: 0.9))\n```\n\n### Custom Patterns\n\nYou can define your own patterns using `HapticEvent` and `HapticPattern`. Each event has a type, intensity (0.0 to 1.0), and delay in seconds relative to the previous event:\n\n```swift\n// A custom \"power-up\" pattern: rising buzz, pause, two sharp taps\nlet powerUp = HapticPattern([\n    HapticEvent(.rise, intensity: 0.6),\n    HapticEvent(.rise, intensity: 1.0, delay: 0.1),\n    HapticEvent(.tap, intensity: 1.0, delay: 0.15),\n    HapticEvent(.tap, intensity: 0.8, delay: 0.06),\n])\n\nHapticFeedback.play(powerUp)\n```\n\nThe available event types are:\n\n| Type | Description | iOS | Android |\n|------|-------------|-----|---------|\n| `.tap` | Short, sharp tap | `CHHapticEvent` transient, sharpness 0.7 | `PRIMITIVE_CLICK` |\n| `.tick` | Subtle, light tick | `CHHapticEvent` transient, sharpness 1.0 | `PRIMITIVE_TICK` |\n| `.thud` | Heavy, deep impact | `CHHapticEvent` transient, sharpness 0.1 | `PRIMITIVE_THUD` |\n| `.rise` | Increasing intensity | `CHHapticEvent` continuous | `PRIMITIVE_QUICK_RISE` |\n| `.fall` | Decreasing intensity | `CHHapticEvent` continuous | `PRIMITIVE_QUICK_FALL` |\n| `.lowTick` | Deep, low-frequency tick | `CHHapticEvent` transient, sharpness 0.3 | `PRIMITIVE_LOW_TICK` |\n\n### Android Permissions\n\nTo use haptic feedback on Android, your `AndroidManifest.xml` must include the vibrate permission:\n\n```xml\n\u003cuses-permission android:name=\"android.permission.VIBRATE\"/\u003e\n```\n\nThe `VibrationEffect.Composition` API requires Android API level 31 (Android 12) or higher. On older devices, haptic calls are silently ignored.\n\n### Platform Details\n\nOn iOS, `HapticFeedback` uses a `CHHapticEngine` instance that is created on first use and restarted automatically if the system reclaims it. Each `HapticPattern` is converted to a `CHHapticPattern` with appropriate `CHHapticEvent` types and parameters. For more details, see Apple's [CoreHaptics documentation](https://developer.apple.com/documentation/corehaptics).\n\nOn Android, patterns are converted to a `VibrationEffect.Composition` with the corresponding `PRIMITIVE_*` constants and played through the system `Vibrator` obtained from `VibratorManager`. For more details, see Android's [VibrationEffect documentation](https://developer.android.com/reference/android/os/VibrationEffect) and the guide on [custom haptic effects](https://developer.android.com/develop/ui/views/haptics/custom-haptic-effects).\n\n## Building\n\nThis project is a Swift Package Manager module that uses the\n[Skip](https://skip.dev) plugin to transpile Swift into Kotlin.\n\nBuilding the module requires that Skip be installed using \n[Homebrew](https://brew.sh) with `brew install skiptools/skip/skip`.\nThis will also install the necessary build prerequisites:\nKotlin, Gradle, and the Android build tools.\n\n## Testing\n\nThe module can be tested using the standard `swift test` command\nor by running the test target for the macOS destination in Xcode,\nwhich will run the Swift tests as well as the transpiled\nKotlin JUnit tests in the Robolectric Android simulation environment.\n\nParity testing can be performed with `skip test`,\nwhich will output a table of the test results for both platforms.\n\n## Contributing\n\nWe welcome contributions to this package in the form of enhancements and bug fixes.\n\nThe general flow for contributing to this and any other Skip package is:\n\n1. Fork this repository and enable actions from the \"Actions\" tab\n2. Check out your fork locally\n3. When developing alongside a Skip app, add the package to a [shared workspace](https://skip.dev/docs/contributing) to see your changes incorporated in the app\n4. Push your changes to your fork and ensure the CI checks all pass in the Actions tab\n5. Add your name to the Skip [Contributor Agreement](https://github.com/skiptools/clabot-config)\n6. Open a Pull Request from your fork with a description of your changes\n\n## License\n\nThis software is licensed under the \n[Mozilla Public License 2.0](https://www.mozilla.org/MPL/).\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fskiptools%2Fskip-kit","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Fskiptools%2Fskip-kit","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fskiptools%2Fskip-kit/lists"}