permissions, manganis::ffi, full Info.plist/AndroidManifest.xml customization (#4842)
* initial version * make custom permission work without const-vec change * wiring up with cli * yey symbol found * wip * holy moly * proper ui * cleanup * okay * wip cleanup * reduce boilerplate code into mobile-core * Delete copilot-instructions.md * revert hello-world example for low diff * fix android build in mobile-core * rename permission to static_permission * small success with popup location on android * android popup working * Refactor permission checks for readability Improves code formatting in has_location_permission and check_permission functions by splitting long lines and updating function signatures for better readability. No functional changes were made. * wip consolidate all java files and build from cli instead of build.rs * few cli cleanups * wip java_plugin macro * cleanup cleanup cleanup * Update java_plugin macro to use full relative file paths The java_plugin macro now accepts full relative paths for Java files instead of assuming a fixed directory structure. Documentation and usage examples have been updated to reflect this change, improving flexibility and clarity for specifying file locations. * Improve iOS geolocation permission and retrieval logic Added checks for authorization status before requesting location permissions and retrieving location data. Now only requests permission if not determined, and attempts to retrieve cached location before starting location updates, improving efficiency and user experience. * Add ios_plugin macro for iOS framework metadata Introduces the ios_plugin! macro in mobile-core-macro for declarative iOS framework metadata embedding. Updates mobile-core to re-export the macro and refactors mobile-geolocation to use ios_plugin! instead of manual linker attributes. * not needed * simplify the plugin * rename mobile-core to platform-bridge and mobile-core-macro to platform-bridge-macro * Refactor metadata serialization buffer initialization Replaces inline creation of ConstVec with a named EMPTY constant for buffer initialization in metadata serialization. This improves code clarity and maintainability. * mobile-geolocation out of dioxus * take the example also out of dioxus * remove example from cargo toml * remove demo css * revert hello-world example * revert mac.plist * revert gitignore * Remove basic usage example for permissions crate Deleted the basic_usage.rs example file from the permissions crate. This change may be part of a cleanup or refactoring to remove outdated or unnecessary example code. * Delete plan.md * remove dups * cleanup java build * cleanup permission symbol extraction * update todo * small slop of tested permissions in enums for minimum diff * Refactor symbol extraction into linker_symbols module Moved generic symbol extraction logic from android_java.rs and permissions.rs into a new linker_symbols.rs module. Updated both files to use the shared utilities for finding symbol offsets, reducing code duplication and improving maintainability. * wip consolidate ios and macos as darwin * Add docs and re-export for MainThreadCell and MainThreadMarker Expanded documentation for MainThreadCell, clarifying usage and safety. Changed visibility of MainThreadCell to public, improved method docs, and re-exported MainThreadMarker in darwin/mod.rs for easier access. * Update Darwin framework symbol and section names Changed the framework symbol prefix from '__DARWIN_FRAMEWORK__' to '__DARWIN_FW__' and updated the Mach-O section name from '__darwin_framework' to '__darwin_fw' for compliance with section name length limits and consistency. * Fix Info.plist path handling for iOS and macOS Adds comments and corrects the path to Info.plist for iOS and macOS app bundles. For iOS, Info.plist is at the root of the .app bundle, while for macOS it is inside Contents/. Also improves warning message to include the missing path. * Handle missing permission symbols and web bundle case Improves permission extraction by returning an empty manifest when no permission symbols are found or when building a web bundle, as permissions are runtime-only in that case. Adds debug logging for missing symbols and ensures robust handling of these scenarios. * objc2 over framework * windows, linux, web all permissions are runtime so cleanup * only include permissions for build time * cleanup unnecessary test * more cleanup * extract shared into dx-macro-helpers * rc.3 to 0.7.0 for const-serialize * Add CustomPermissionBuilder and enhance PermissionBuilder for better permission management - Introduced `CustomPermissionBuilder` for creating custom permissions with platform-specific identifiers. - Enhanced `PermissionBuilder` to support custom permissions and location permissions with a clear builder pattern. - Updated documentation and examples in the macro README to reflect new usage patterns for custom and location permissions. - Improved parsing logic in the macro to handle both builder and direct construction patterns for permissions. * clippy fixes * implement const cbor encoding + decoding for basic types * Add #[used] attribute to linker sections for optimization protection - Added #[used] attribute to linker sections in both permission and Android plugin macros to prevent the linker from optimizing away symbols, ensuring they are preserved even if unused. - Created static references to linker sections to enhance defense-in-depth against optimization issues. * implement map encoding and decoding * fix map test * wip unified linker symbol * cleanup permissions * pipe java sources with manganis as well * all tests passing * dynamically sized arrays * smaller str encoding * swap array and list and add more documentation * move serialization and deserialization for each struct together * support both legacy and new assets * bump const serialize version * fix asset conversion code * pull out manganis symbol struct from tuble * test reading old asset versions * more list/array cleanup and bump rust version * fix clippy * Revert "pipe java sources with manganis as well" This reverts commit ddea9a459d9ac26f3f037a74d9ae0de3edfced67. * Revert "cleanup permissions" This reverts commit 7b18500155237403e179f0783cb8903a4f158b80. * Revert "wip unified linker symbol" This reverts commit 813e33feddb30d26868f6ac6e6da318402ac138f. * Revert "Add #[used] attribute to linker sections for optimization protection" This reverts commit a94a7a3a5e8eee80060de9d66a53aabfcf554f96. * update cargo lock * wip permission through assets * abit cleanup * fix reexports * fix assets * Fix the permissions piping * some small cleanups * checkout const-serialize as per #4932 and fix build * invoking swift as a package * geolocation package & example dumping for easy testing * keep geolocation simple for testing * cleanups * pipe everything through __ASSETS__ and cleanup * remove linker_symbols helper file * example * re example * copy build methos into mobile-plugin-build crate * Add conditional import for dioxus_07 feature Introduces a conditional import to use the dioxus_07 crate when the corresponding feature is enabled, improving compatibility with different dioxus versions. * Improve error handling and JNI reference usage Adds a null check for Objective-C instance initialization in the iOS geolocation plugin to prevent errors on failed allocation. Refactors Android callback to use local JNI references instead of creating unnecessary global references, reducing potential memory leaks. * revert changes on playwright * Update dioxus dependency to 0.7.2 in Cargo.lock Removed dioxus 0.7.1 from dependencies, retaining only dioxus 0.7.2 for the dioxus-cli-optimization-test package. * small cleanup * Add legacy const-serialize 0.7 support to dx-macro-helpers Introduces support for the const-serialize 0.7 format in dx-macro-helpers by adding a new dependency and export, as well as a helper function for serialization with layout padding. Updates manganis to use the new helper for serializing assets in the legacy format, simplifying code and improving maintainability. * Add helper for legacy and current linker sections Introduces generate_link_sections_with_legacy in dx-macro-helpers to emit both legacy and current linker sections for compatibility. Refactors manganis-macro to use this new helper, reducing duplication and improving maintainability. * Fix lints * Fix clippy and doctest * Refactor asset bundling and allow large enum variant Refactored Asset::bundled to use Vec<u8> instead of ConstVec for reading volatile slices, simplifying the code and improving compatibility. Added #[allow(clippy::large_enum_variant)] to SymbolData enum to suppress Clippy warnings about large enum variants. * Update license URLs in README to use angle brackets Changed license URLs in the README from parentheses to angle brackets for improved formatting and clarity. * Fix asset deserialization with correct buffer type Replaces the use of `bytes.read()` with `ConstReadBuffer::new(bytes.as_ref())` for asset deserialization, ensuring compatibility with the expected deserialization API. * Refactor geolocation and asset handling for clarity Removed unnecessary clones in geolocation example event handlers to simplify state management. Updated SymbolDataOrAsset to store SymbolData in a Box for improved memory handling and adjusted related logic. Fixed documentation example in geolocation lib to include PermissionState in the import. * Update geolocation example and permissions usage docs Removes unused variables in the geolocation example's App function. Updates the permissions crate documentation to reflect new API usage with PermissionBuilder and LocationPrecision. * Update permissions-macro to use permissions crate Switched imports and dev-dependency from permissions_core to permissions in permissions-macro. Updated documentation and examples to reflect the new crate usage. * rollback const-serialize changes * move items around (doesn't compile) * convert gelocation plugin to example * remove dx-macro-helpers * we're just gonna full-send linker everything * rollback desktop changes * remove gradle * fix most compile? * fix dioxus vers * small cleanups * add claude design docs * add ffi macro * remove * loads but not linking yet * fix output leak * a little bit closer but not quite * improve android code gen * sheeeeee works!!! * small tweaks to demo * replace ios/android plugin with ffi macro entirely * simplify ffi module * simplify again * condense / rip out some stuff from manganis * remove types form public api * wip, permissions * oops I broke it * I unbroke it * move arch doc * remove extra manifest types * drop bindgen file * remove permission * small cleanup * remove unsafecall mtm thing * yoink perms * wire up manifest * Add deeplinks * add schema generation * update schema * wip.... * fix recurse limit schema generation * wip, muck around with schema * add inline dioxus.toml support * merge bundle config schemas * claude my man * Remove cdecl, annotate ffi * rename design docs * wip: live activities * wip: apple plugin bundling * wip * get live activities linking properly * small cleanups * remove sidecars * remove widget * yoink legacy * cleanup * more fixups * wire up more things to dioxus.toml * fix workspace join * final cleanups * fmt * final bit of clippy * ci tweak * update schema * fmt, doc * update schema docs --------- Co-authored-by: Evan Almloff <evanalmloff@gmail.com> Co-authored-by: Jonathan Kelley <jkelleyrtp@gmail.com>
This commit is contained in:
73 files changed
+13767
-336
No files matched your search
@@ -0,0 +1,21 @@
|
||||
[package]
|
||||
name = "geolocation-native-plugin"
|
||||
version = "0.1.0"
|
||||
authors = ["Sabin Regmi <get2sabin@gmail.com>"]
|
||||
edition = "2021"
|
||||
publish = false
|
||||
|
||||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||
|
||||
[dependencies]
|
||||
dioxus = { workspace = true, features = [] }
|
||||
manganis = { workspace = true }
|
||||
serde = { workspace = true, features = ["derive"] }
|
||||
serde_json = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
|
||||
[features]
|
||||
default = ["mobile"]
|
||||
web = ["dioxus/web"]
|
||||
desktop = ["dioxus/desktop"]
|
||||
mobile = ["dioxus/mobile"]
|
||||
@@ -0,0 +1,27 @@
|
||||
#:schema ../../../packages/cli/schema.json
|
||||
|
||||
[bundle]
|
||||
identifier = "com.dioxuslabs.geolocation"
|
||||
publisher = "Dioxus Labs"
|
||||
|
||||
[ios]
|
||||
deployment_target = "16.2"
|
||||
background_modes = ["location"]
|
||||
|
||||
[ios.plist]
|
||||
NSSupportsLiveActivities = true
|
||||
|
||||
[[ios.widget_extensions]]
|
||||
source = "src/ios/widget"
|
||||
display_name = "Location Widget"
|
||||
bundle_id_suffix = "location-widget"
|
||||
deployment_target = "16.2"
|
||||
module_name = "GeolocationPlugin"
|
||||
|
||||
[android]
|
||||
min_sdk = 24
|
||||
target_sdk = 34
|
||||
features = ["android.hardware.location.gps"]
|
||||
|
||||
[permissions]
|
||||
location = { precision = "fine", description = "Access your precise location to provide location-based services" }
|
||||
@@ -0,0 +1,32 @@
|
||||
# Geolocation demo
|
||||
|
||||
A minimal Dioxus application that implements a native plugin.
|
||||
|
||||
The plugin demonstrated here makes it possible to access the user's geolocation. It does a few things:
|
||||
|
||||
- Inspect and request location permissions using the native Android/iOS dialogs.
|
||||
- Configure one-shot position requests (high-accuracy toggle + maximum cached age).
|
||||
- Inspect the last reported coordinates, accuracy, altitude, heading, and speed.
|
||||
|
||||
The example shares the same metadata pipeline as any plugin crate: the native Gradle/Swift
|
||||
artifacts are embedded via linker symbols and bundled automatically by `dx`.
|
||||
|
||||
## Running the example
|
||||
|
||||
```bash
|
||||
# Inside the repository root
|
||||
dx serve --project examples/01-app-demos/geolocation --platform mobile
|
||||
```
|
||||
|
||||
For Android/iOS you’ll need the respective toolchains installed (Android SDK/NDK, Xcode) so the
|
||||
geolocation crate’s `build.rs` can build the native modules. The UI also works on desktop/web,
|
||||
but location calls will return an error because the plugin only supports mobile targets—those
|
||||
errors are shown inline in the demo.
|
||||
|
||||
## Things to try
|
||||
|
||||
1. Tap **Check permissions** to see the current OS state (granted/denied/prompt).
|
||||
2. Tap **Request permissions** to trigger the native dialog from within the app.
|
||||
3. Toggle *High accuracy* and set a *Max cached age* before requesting the current position.
|
||||
4. Observe the coordinate grid update whenever a new reading arrives, or the error banner if the
|
||||
operation fails (e.g., permissions denied or running on an unsupported platform).
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 130 KiB |
@@ -0,0 +1,20 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 1007 197"><style>
|
||||
@keyframes a0_t { 0% { transform: translate(225px,339.9px) scale(0,1) translate(-89.5px,-24px); } 35% { transform: translate(225px,339.9px) scale(0,1) translate(-89.5px,-24px); animation-timing-function: cubic-bezier(.6,0,.4,1); } 45% { transform: translate(225px,339.9px) scale(1,1) translate(-89.5px,-24px); } 50% { transform: translate(225px,339.9px) scale(1,1) translate(-89.5px,-24px); animation-timing-function: cubic-bezier(.6,0,.4,1); } 60% { transform: translate(225px,339.9px) scale(0,1) translate(-89.5px,-24px); } 100% { transform: translate(225px,339.9px) scale(0,1) translate(-89.5px,-24px); } }
|
||||
@keyframes a1_t { 0% { transform: translate(225px,348.9px) scale(0,1) translate(-41.5px,-11px); } 20% { transform: translate(225px,348.9px) scale(0,1) translate(-41.5px,-11px); animation-timing-function: cubic-bezier(.6,0,.4,1); } 30% { transform: translate(225px,348.9px) scale(1,1) translate(-41.5px,-11px); } 35% { transform: translate(225px,348.9px) scale(1,1) translate(-41.5px,-11px); } 45% { transform: translate(225px,348.9px) scale(0,1) translate(-41.5px,-11px); } 100% { transform: translate(225px,348.9px) scale(0,1) translate(-41.5px,-11px); } }
|
||||
@keyframes a3_t { 0% { transform: translate(225px,198.9px) rotate(-90deg); } 10% { transform: translate(225px,198.9px) rotate(-90deg); } 20% { transform: translate(225px,198.9px) rotate(-90deg); animation-timing-function: cubic-bezier(.6,0,.4,1); } 30% { transform: translate(225px,198.9px) rotate(0deg); } 35% { transform: translate(225px,198.9px) rotate(0deg); } 45% { transform: translate(225px,198.9px) rotate(0deg); } 50% { transform: translate(225px,198.9px) rotate(0deg); animation-timing-function: cubic-bezier(.6,0,.4,1); } 60% { transform: translate(225px,198.9px) rotate(-90deg); } 65% { transform: translate(225px,198.9px) rotate(-90deg); } 75% { transform: translate(225px,198.9px) rotate(-90deg); } 100% { transform: translate(225px,198.9px) rotate(-90deg); } }
|
||||
@keyframes a2_t { 0% { transform: translate(-200px,-167px); animation-timing-function: cubic-bezier(.6,0,.4,1); } 10% { transform: translate(-150px,-80px); } 20% { transform: translate(-150px,-80px); animation-timing-function: cubic-bezier(.6,0,.4,1); } 30% { transform: translate(-200px,-123px); } 35% { transform: translate(-200px,-123px); animation-timing-function: cubic-bezier(.6,0,.4,1); } 45% { transform: translate(-150px,-100px); } 50% { transform: translate(-150px,-100px); animation-timing-function: cubic-bezier(.6,0,.4,1); } 60% { transform: translate(-150px,-80px); } 65% { transform: translate(-150px,-80px); animation-timing-function: cubic-bezier(.6,0,.4,1); } 75% { transform: translate(-217px,-200px); } 100% { transform: translate(-217px,-200px); } }
|
||||
@keyframes a2_o { 0% { opacity: 0; animation-timing-function: cubic-bezier(.6,0,.4,1); } 10% { opacity: 1; } 65% { opacity: 1; animation-timing-function: cubic-bezier(.6,0,.4,1); } 75% { opacity: 0; } 100% { opacity: 0; } }
|
||||
@keyframes a2_sw { 0% { stroke-width: 30px; animation-timing-function: cubic-bezier(.6,0,.4,1); } 10% { stroke-width: 8px; } 65% { stroke-width: 8px; animation-timing-function: cubic-bezier(.6,0,.4,1); } 75% { stroke-width: 30px; } 100% { stroke-width: 30px; } }
|
||||
@keyframes a2_w { 0% { width: 400px; animation-timing-function: cubic-bezier(.6,0,.4,1); } 10% { width: 300px; } 20% { width: 300px; animation-timing-function: cubic-bezier(.6,0,.4,1); } 30% { width: 400px; } 35% { width: 400px; animation-timing-function: cubic-bezier(.6,0,.4,1); } 45% { width: 300px; } 50% { width: 300px; } 60% { width: 300px; } 65% { width: 300px; animation-timing-function: cubic-bezier(.6,0,.4,1); } 75% { width: 434px; } 100% { width: 434px; } }
|
||||
@keyframes a2_h { 0% { height: 334px; animation-timing-function: cubic-bezier(.6,0,.4,1); } 10% { height: 160px; } 20% { height: 160px; animation-timing-function: cubic-bezier(.6,0,.4,1); } 30% { height: 246px; } 35% { height: 246px; animation-timing-function: cubic-bezier(.6,0,.4,1); } 45% { height: 200px; } 50% { height: 200px; animation-timing-function: cubic-bezier(.6,0,.4,1); } 60% { height: 160px; } 65% { height: 160px; animation-timing-function: cubic-bezier(.6,0,.4,1); } 75% { height: 400px; } 100% { height: 400px; } }
|
||||
@keyframes a4_t { 0% { transform: translate(143.5px,48.9px); } 10% { transform: translate(180.5px,98.9px); } 100% { transform: translate(180.5px,98.9px); } }
|
||||
@keyframes a4_w { 0% { width: 168.5px; } 10% { width: 94.5px; } 100% { width: 94.5px; } }
|
||||
@keyframes a4_h { 0% { height: 302px; } 10% { height: 202px; } 100% { height: 202px; } }
|
||||
@keyframes a5_t { 0% { transform: translate(217px,324.9px); animation-timing-function: cubic-bezier(.6,0,.4,1); } 10% { transform: translate(190px,324.9px); } 20% { transform: translate(190px,324.9px); } 30% { transform: translate(190px,324.9px); } 100% { transform: translate(190px,324.9px); } }
|
||||
@keyframes a5_o { 0% { opacity: 0; animation-timing-function: cubic-bezier(.6,0,.4,1); } 10% { opacity: 1; } 20% { opacity: 1; } 30% { opacity: 0; } 100% { opacity: 0; } }
|
||||
@keyframes a5_w { 0% { width: 16px; animation-timing-function: cubic-bezier(.6,0,.4,1); } 10% { width: 70px; } 20% { width: 70px; } 30% { width: 70px; } 100% { width: 70px; } }
|
||||
@keyframes a6_t { 0% { transform: translate(217px,324.9px); } 50% { transform: translate(217px,324.9px); animation-timing-function: cubic-bezier(.6,0,.4,1); } 60% { transform: translate(190px,324.9px); } 65% { transform: translate(190px,324.9px); } 75% { transform: translate(190px,324.9px); } 100% { transform: translate(190px,324.9px); } }
|
||||
@keyframes a6_o { 0% { opacity: 0; } 50% { opacity: 0; animation-timing-function: cubic-bezier(.6,0,.4,1); } 60% { opacity: 1; } 65% { opacity: 1; animation-timing-function: cubic-bezier(.6,0,.4,1); } 75% { opacity: 0; } 100% { opacity: 0; } }
|
||||
@keyframes a6_w { 0% { width: 16px; } 50% { width: 16px; animation-timing-function: cubic-bezier(.6,0,.4,1); } 60% { width: 70px; } 65% { width: 70px; } 75% { width: 70px; } 100% { width: 70px; } }
|
||||
@keyframes a7_t { 0% { transform: translate(225px,198.9px) scale(1.5,1.5) translate(-42.3px,-93px); animation-timing-function: cubic-bezier(.6,0,.4,1); } 10% { transform: translate(225px,198.9px) scale(1,1) translate(-42.3px,-93px); } 20% { transform: translate(225px,198.9px) scale(1,1) translate(-42.3px,-93px); animation-timing-function: cubic-bezier(.6,0,.4,1); } 25% { transform: translate(225px,198.9px) scale(.8,.8) translate(-42.3px,-93px); animation-timing-function: cubic-bezier(.6,0,.4,1); } 30% { transform: translate(225px,198.9px) scale(1,1) translate(-42.3px,-93px); } 35% { transform: translate(225px,198.9px) scale(1,1) translate(-42.3px,-93px); animation-timing-function: cubic-bezier(.4,0,.6,1); } 45% { transform: translate(225px,198.9px) scale(.8,.8) translate(-42.3px,-93px); } 50% { transform: translate(225px,198.9px) scale(.8,.8) translate(-42.3px,-93px); animation-timing-function: cubic-bezier(.4,0,.6,1); } 55% { transform: translate(225px,198.9px) scale(.7,.7) translate(-42.3px,-93px); animation-timing-function: cubic-bezier(.4,0,.6,1); } 60% { transform: translate(225px,198.9px) scale(1,1) translate(-42.3px,-93px); } 65% { transform: translate(225px,198.9px) scale(1,1) translate(-42.3px,-93px); animation-timing-function: cubic-bezier(.4,0,.6,1); } 75% { transform: translate(225px,198.9px) scale(1.5,1.5) translate(-42.3px,-93px); } 100% { transform: translate(225px,198.9px) scale(1.5,1.5) translate(-42.3px,-93px); } }
|
||||
</style><defs><symbol id="Symbol-2" preserveAspectRatio="none" width="111.5" height="186" viewBox="0 0 111.5 186" overflow="visible"><g transform="translate(8.5,4.5)"><rect x="4" width="85" height="8" fill="#d9d9d9"/><rect x="4" y="26" width="85" height="8" fill="#d9d9d9"/><rect x="25" y="56" width="43" height="8" fill="#d9d9d9"/><rect width="85" height="8" fill="#d9d9d9" transform="translate(46.5,175) rotate(180) scale(-1,1) translate(-42.5,-4)"/><rect width="85" height="8" fill="#d9d9d9" transform="translate(46.5,149) rotate(180) scale(-1,1) translate(-42.5,-4)"/><rect width="43" height="8" fill="#d9d9d9" transform="translate(46.5,119) rotate(180) scale(-1,1) translate(-21.5,-4)"/><path d="M0 4c0 53 94.5 115 94.5 169" stroke="#3cc4dc" stroke-width="17" stroke-linecap="square" fill="none"/><path d="M94.5 4c0 53-94.5 115-94.5 169" stroke="#fb422d" stroke-width="17" stroke-linecap="square" fill="none"/></g></symbol><symbol id="Symbol-5" preserveAspectRatio="none" width="84.5" height="186" viewBox="0 0 84.5 186" overflow="visible"><g transform="translate(42.3,93) scale(5.352,6.9) translate(-16,60)"><g transform="translate(-34.9,-125.8)"><g transform="translate(24,5)"><path d="M33 46.5c0 3.1-0.8 5.5-2.2 7.4c-1.4 1.9-3.3 3.6-5.4 5.4c-2.2 1.7-4.5 3.6-6.3 6.2c-1.8 2.5-3.1 5.5-3.1 9.5h4.7c0-3.1 .9-5.4 2.2-7.3c1.4-1.9 3.3-3.5 5.5-5.3c2.1-1.8 4.4-3.7 6.2-6.2c1.8-2.6 3.1-5.6 3.1-9.7Z" fill="#e96020"/><path d="M20.4 70.7c-0.6 0-1 .5-1 1.1c0 .7 .4 1.2 1 1.2h12.9c.7 0 1.2-0.5 1.2-1.2c0-0.6-0.5-1.1-1.2-1.1Z" fill="#2d323b"/><path d="M21.8 66.6c-0.7 0-1 .6-1 1.2c0 .6 .3 1 .9 1h10.3c.6 0 1.1-0.5 1.1-1.1c0-0.6-0.5-1.1-1.1-1.1Z" fill="#2d323b"/><path d="M21.8 53c-0.7 0-1.2 .5-1.1 1.1c0 .6 .4 1.1 1.1 1.1h10.2c.6 0 .9-0.6 .9-1.2c0-0.6-0.3-1-0.9-1Z" fill="#2d323b"/><path d="M20.4 48.8c-0.6 0-1.2 .5-1.2 1.1c0 .7 .6 1.2 1.2 1.2h12.9c.7 0 1-0.5 1-1.1c0-0.7-0.3-1.2-1-1.2Z" fill="#2d323b"/><path d="M16 46.5c0 4.1 1.3 7.1 3.1 9.7c1.8 2.5 4.1 4.4 6.3 6.2c2.1 1.8 4 3.6 5.4 5.5c1.4 1.9 2.2 4 2.2 7.1h4.7c0-4-1.3-7.1-3.1-9.6c-1.8-2.5-4.1-4.4-6.2-6.2c-2.2-1.8-4.1-3.4-5.5-5.3c-1.3-1.9-2.2-4.3-2.2-7.4Z" fill="#00a8d6"/></g></g></g></symbol></defs><rect width="1007" height="197" stroke="#0f1116" fill="#0f1116" stroke-width="0"/><path fill="#fff" d="M83.3 106.1v-65.7h17c4.2 0 7.9 .8 11 2.4c3.1 1.6 5.5 3.9 7.2 6.9c1.7 2.9 2.6 6.4 2.6 10.4v26.2c0 4-0.9 7.5-2.6 10.5c-1.7 3-4.1 5.3-7.2 6.9c-3.1 1.6-6.8 2.4-11 2.4Zm8.1-7.2h8.9c4 0 7.1-1.1 9.3-3.3c2.3-2.3 3.4-5.3 3.4-9.3v-26.2c0-3.9-1.1-7-3.4-9.2c-2.2-2.2-5.3-3.3-9.3-3.3h-8.9Zm45.3 7.2v-7.4h17.5v-34.7h-15.7v-7.4h23.8v42.1h16.7v7.4Zm20.7-58.4c-2 0-3.6-0.5-4.7-1.5c-1.2-1.1-1.7-2.4-1.7-4.2c0-1.8 .5-3.2 1.7-4.3c1.1-1 2.7-1.5 4.7-1.5c1.9 0 3.5 .5 4.6 1.5c1.2 1.1 1.7 2.5 1.7 4.3c0 1.8-0.5 3.1-1.7 4.2c-1.1 1-2.7 1.5-4.6 1.5Zm52.6 59.1c-6 0-10.7-1.7-14.2-5.1c-3.4-3.4-5.1-8-5.1-13.8v-13.1c0-5.9 1.7-10.5 5.1-13.9c3.5-3.3 8.2-5 14.2-5c6 0 10.7 1.7 14.2 5c3.4 3.4 5.2 8 5.2 13.8v13.2c0 5.8-1.8 10.4-5.2 13.8c-3.5 3.4-8.2 5.1-14.2 5.1Zm0-7.2c3.5 0 6.3-1 8.3-3c2-1.9 3-4.8 3-8.7v-13.1c0-3.9-1-6.8-3-8.8c-2-1.9-4.8-2.9-8.3-2.9c-3.5 0-6.2 1-8.2 2.9c-2 2-3 4.9-3 8.8v13.1c0 3.9 1 6.8 3 8.7c2 2 4.7 3 8.2 3Zm31.5 6.5l17.6-25.5l-16.5-24h9.5l9.8 15.3c.5 .7 .9 1.4 1.2 2.2c.4 .7 .8 1.4 1 1.8c.2-0.4 .5-1.1 .8-1.8c.4-0.8 .8-1.5 1.3-2.2l9.9-15.3h9.4l-16.5 24.1l17.5 25.4h-9.5l-10.7-16.2c-0.4-0.7-0.8-1.4-1.2-2.3c-0.4-0.9-0.7-1.6-1-2.1c-0.2 .5-0.6 1.2-1 2.1c-0.5 .9-1 1.6-1.4 2.3l-10.7 16.2Zm76.4 .9c-5.7 0-10.2-1.7-13.7-5.1c-3.4-3.4-5.1-8-5.1-13.8v-31.5h8.1v31.5c0 3.7 1 6.6 2.9 8.7c1.9 2.1 4.5 3.1 7.8 3.1c3.4 0 6-1 8-3.1c1.9-2.1 2.9-5 2.9-8.7v-31.5h8.1v31.5c0 5.8-1.7 10.4-5.2 13.8c-3.5 3.4-8.1 5.1-13.8 5.1Zm52.6-0.2c-5.1 0-9.3-1.2-12.5-3.5c-3.3-2.3-4.9-5.5-4.9-9.6h8.3c0 1.8 .9 3.3 2.6 4.3c1.8 1.1 4 1.6 6.6 1.6h3.8c3.2 0 5.6-0.6 7.1-1.9c1.6-1.3 2.4-3 2.4-5.2c0-2.1-0.8-3.8-2.2-5c-1.5-1.2-3.6-2-6.4-2.5l-6.6-1c-5-0.9-8.6-2.4-10.9-4.7c-2.4-2.3-3.5-5.5-3.5-9.5c0-4.5 1.4-7.9 4.3-10.3c2.9-2.4 7.1-3.6 12.6-3.6h3.4c5 0 9.1 1.1 12.1 3.4c3 2.2 4.6 5.2 4.6 8.9h-8.3c0-1.6-0.8-2.8-2.3-3.7c-1.5-1-3.6-1.4-6.3-1.4h-3.4c-2.8 0-5 .6-6.5 1.7c-1.5 1.2-2.3 2.9-2.3 5c0 3.6 2.5 5.8 7.6 6.7l6.7 1.1c5.3 .8 9.2 2.4 11.7 4.6c2.4 2.3 3.6 5.6 3.6 9.9c0 4.6-1.5 8.2-4.4 10.8c-2.9 2.6-7.3 3.9-13.2 3.9Z"/><path fill="#fff" d="M91 164c-2 0-3.6-0.6-4.7-1.7c-1.1-1.1-1.6-2.7-1.6-4.6v-15.9h2.7v15.9c0 1.2 .3 2.2 .9 2.8c.6 .7 1.5 1.1 2.7 1.1c1.2 0 2.1-0.4 2.7-1.1c.6-0.7 .9-1.6 .9-2.8v-15.9h2.7v15.9c0 2-0.5 3.5-1.6 4.6c-1.1 1.1-2.6 1.7-4.7 1.7Zm17.5-0.1c-1.7 0-3.1-0.3-4.2-1.1c-1.1-0.8-1.6-1.9-1.6-3.2h2.8c0 .6 .3 1.1 .8 1.4c.6 .4 1.4 .5 2.2 .5h1.3c1.1 0 1.9-0.2 2.4-0.6c.5-0.4 .8-1 .8-1.7c0-0.7-0.3-1.3-0.8-1.7c-0.5-0.4-1.2-0.7-2.1-0.8l-2.2-0.4c-1.7-0.3-2.9-0.8-3.6-1.5c-0.8-0.8-1.2-1.9-1.2-3.2c0-1.5 .5-2.6 1.4-3.4c1-0.8 2.4-1.2 4.2-1.2h1.2c1.6 0 3 .3 4 1.1c1 .7 1.5 1.7 1.5 3h-2.7c0-0.6-0.3-1-0.8-1.3c-0.5-0.3-1.2-0.4-2.1-0.4h-1.1c-1 0-1.7 .2-2.2 .5c-0.5 .4-0.8 1-0.8 1.7c0 1.2 .9 1.9 2.6 2.2l2.2 .4c1.8 .3 3.1 .8 3.9 1.5c.8 .8 1.2 1.9 1.2 3.3c0 1.6-0.5 2.8-1.5 3.6c-0.9 .9-2.4 1.3-Line truncated
|
||||
|
After Width: | Height: | Size: 23 KiB |
@@ -0,0 +1,222 @@
|
||||
body {
|
||||
background-color: #05060a;
|
||||
color: #f4f4f5;
|
||||
font-family: 'Inter', 'Segoe UI', sans-serif;
|
||||
margin: 0;
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: calc(16px + env(safe-area-inset-top, 0px)) 0 40px;
|
||||
}
|
||||
|
||||
.app {
|
||||
width: min(960px, 100%);
|
||||
padding: 0 20px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.hero {
|
||||
display: flex;
|
||||
gap: 24px;
|
||||
align-items: center;
|
||||
margin-bottom: 32px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.hero img {
|
||||
width: 200px;
|
||||
max-width: 35%;
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
.hero__copy h1 {
|
||||
margin: 0 0 8px;
|
||||
font-size: clamp(28px, 6vw, 36px);
|
||||
}
|
||||
|
||||
.hero__copy p {
|
||||
margin: 0;
|
||||
line-height: 1.5;
|
||||
color: #c8cad7;
|
||||
}
|
||||
|
||||
.cards {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
|
||||
gap: 24px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: linear-gradient(165deg, rgba(17, 20, 32, 0.95), rgba(6, 7, 16, 0.98));
|
||||
border: 1px solid #222534;
|
||||
border-radius: 16px;
|
||||
padding: 24px;
|
||||
box-shadow: 0 25px 45px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
.card h2 {
|
||||
margin-top: 0;
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.muted {
|
||||
color: #a5a7b6;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.button-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
button {
|
||||
border: none;
|
||||
border-radius: 999px;
|
||||
padding: 10px 18px;
|
||||
font-size: 0.95rem;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s ease;
|
||||
}
|
||||
|
||||
button.primary,
|
||||
button {
|
||||
background: linear-gradient(135deg, #8f63ff, #4d8dff);
|
||||
color: white;
|
||||
box-shadow: 0 10px 25px rgba(77, 141, 255, 0.25);
|
||||
}
|
||||
|
||||
button.secondary {
|
||||
background: transparent;
|
||||
color: #b3b7cf;
|
||||
border: 1px solid #2f3244;
|
||||
}
|
||||
|
||||
button.full-width {
|
||||
width: 100%;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
button.toggle {
|
||||
width: fit-content;
|
||||
background: #1a1d29;
|
||||
border: 1px solid #2c2f40;
|
||||
color: #d8d9e5;
|
||||
}
|
||||
|
||||
button.toggle--active {
|
||||
background: #23304d;
|
||||
border-color: #4b6cff;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.settings {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.field input {
|
||||
background: #0b0d13;
|
||||
border: 1px solid #26293a;
|
||||
border-radius: 10px;
|
||||
padding: 10px 12px;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.status-grid {
|
||||
margin-top: 20px;
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.permission-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.badge {
|
||||
padding: 4px 10px;
|
||||
border-radius: 999px;
|
||||
font-size: 0.85rem;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.badge--granted {
|
||||
background: rgba(70, 221, 154, 0.15);
|
||||
color: #7efac6;
|
||||
border: 1px solid rgba(70, 221, 154, 0.4);
|
||||
}
|
||||
|
||||
.badge--denied {
|
||||
background: rgba(255, 98, 98, 0.16);
|
||||
color: #ff8ea0;
|
||||
border: 1px solid rgba(255, 98, 98, 0.4);
|
||||
}
|
||||
|
||||
.badge--prompt {
|
||||
background: rgba(255, 205, 112, 0.16);
|
||||
color: #ffd27e;
|
||||
border: 1px solid rgba(255, 205, 112, 0.35);
|
||||
}
|
||||
|
||||
.position {
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.position__grid {
|
||||
margin-top: 14px;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.coordinate-row {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
padding: 10px;
|
||||
background: #080a11;
|
||||
border-radius: 12px;
|
||||
border: 1px solid #1c1f2b;
|
||||
}
|
||||
|
||||
.error-banner {
|
||||
margin-top: 24px;
|
||||
padding: 14px 18px;
|
||||
background: #3c1017;
|
||||
border: 1px solid #a44856;
|
||||
border-radius: 12px;
|
||||
color: #ffe6ea;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.hero {
|
||||
flex-direction: column;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.hero img {
|
||||
max-width: 60%;
|
||||
}
|
||||
|
||||
.button-row {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
button {
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import org.gradle.api.tasks.bundling.AbstractArchiveTask
|
||||
|
||||
plugins {
|
||||
id("com.android.library") version "8.4.2"
|
||||
kotlin("android") version "1.9.24"
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "com.dioxus.geolocation"
|
||||
compileSdk = 34
|
||||
|
||||
defaultConfig {
|
||||
minSdk = 24
|
||||
targetSdk = 34
|
||||
consumerProguardFiles("consumer-rules.pro")
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
getByName("release") {
|
||||
isMinifyEnabled = false
|
||||
}
|
||||
getByName("debug") {
|
||||
isMinifyEnabled = false
|
||||
}
|
||||
}
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
}
|
||||
|
||||
kotlinOptions {
|
||||
jvmTarget = "17"
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation("androidx.core:core-ktx:1.12.0")
|
||||
implementation("com.google.android.gms:play-services-location:21.3.0")
|
||||
}
|
||||
|
||||
tasks.withType<AbstractArchiveTask>().configureEach {
|
||||
archiveBaseName.set("geolocation-plugin")
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
# Intentionally empty; no consumer Proguard rules required for the geolocation plugin.
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
|
||||
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
|
||||
<!-- Background location is optional; uncomment if needed by host app -->
|
||||
<!-- <uses-permission android:name="android.permission.ACCESS_BACKGROUND_LOCATION" /> -->
|
||||
|
||||
<application />
|
||||
|
||||
</manifest>
|
||||
+87
@@ -0,0 +1,87 @@
|
||||
// Copyright 2019-2023 Tauri Programme within The Commons Conservancy
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package com.dioxus.geolocation
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Context
|
||||
import android.location.Location
|
||||
import android.location.LocationManager
|
||||
import android.os.SystemClock
|
||||
import androidx.core.location.LocationManagerCompat
|
||||
import android.util.Log
|
||||
import com.google.android.gms.common.ConnectionResult
|
||||
import com.google.android.gms.common.GoogleApiAvailability
|
||||
import com.google.android.gms.location.LocationServices
|
||||
import com.google.android.gms.location.Priority
|
||||
|
||||
class Geolocation(private val context: Context) {
|
||||
fun isLocationServicesEnabled(): Boolean {
|
||||
val lm = context.getSystemService(Context.LOCATION_SERVICE) as LocationManager
|
||||
return LocationManagerCompat.isLocationEnabled(lm)
|
||||
}
|
||||
|
||||
@SuppressWarnings("MissingPermission")
|
||||
fun sendLocation(
|
||||
enableHighAccuracy: Boolean,
|
||||
successCallback: (location: Location) -> Unit,
|
||||
errorCallback: (error: String) -> Unit,
|
||||
) {
|
||||
val resultCode = GoogleApiAvailability.getInstance().isGooglePlayServicesAvailable(context)
|
||||
if (resultCode == ConnectionResult.SUCCESS) {
|
||||
val lm = context.getSystemService(Context.LOCATION_SERVICE) as LocationManager
|
||||
|
||||
if (this.isLocationServicesEnabled()) {
|
||||
var networkEnabled = false
|
||||
|
||||
try {
|
||||
networkEnabled = lm.isProviderEnabled(LocationManager.NETWORK_PROVIDER)
|
||||
} catch (_: Exception) {
|
||||
Log.e("Geolocation", "isProviderEnabled failed")
|
||||
}
|
||||
|
||||
val lowPrio =
|
||||
if (networkEnabled) Priority.PRIORITY_BALANCED_POWER_ACCURACY else Priority.PRIORITY_LOW_POWER
|
||||
val prio = if (enableHighAccuracy) Priority.PRIORITY_HIGH_ACCURACY else lowPrio
|
||||
|
||||
Log.d("Geolocation", "Using priority $prio")
|
||||
|
||||
LocationServices
|
||||
.getFusedLocationProviderClient(context)
|
||||
.getCurrentLocation(prio, null)
|
||||
.addOnFailureListener { e -> e.message?.let { errorCallback(it) } }
|
||||
.addOnSuccessListener { location ->
|
||||
if (location == null) {
|
||||
errorCallback("Location unavailable.")
|
||||
} else {
|
||||
successCallback(location)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
errorCallback("Location disabled.")
|
||||
}
|
||||
} else {
|
||||
errorCallback("Google Play Services unavailable.")
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressLint("MissingPermission")
|
||||
fun getLastLocation(maximumAge: Long): Location? {
|
||||
var lastLoc: Location? = null
|
||||
val lm = context.getSystemService(Context.LOCATION_SERVICE) as LocationManager
|
||||
|
||||
for (provider in lm.allProviders) {
|
||||
val tmpLoc = lm.getLastKnownLocation(provider)
|
||||
if (tmpLoc != null) {
|
||||
val locationAge = SystemClock.elapsedRealtimeNanos() - tmpLoc.elapsedRealtimeNanos
|
||||
val maxAgeNano = maximumAge * 1_000_000L
|
||||
if (locationAge <= maxAgeNano && (lastLoc == null || lastLoc.elapsedRealtimeNanos > tmpLoc.elapsedRealtimeNanos)) {
|
||||
lastLoc = tmpLoc
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return lastLoc
|
||||
}
|
||||
}
|
||||
+179
@@ -0,0 +1,179 @@
|
||||
// Copyright 2019-2023 Tauri Programme within The Commons Conservancy
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package com.dioxus.geolocation
|
||||
|
||||
import android.Manifest
|
||||
import android.app.Activity
|
||||
import android.content.pm.PackageManager
|
||||
import android.location.Location
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.webkit.WebView
|
||||
import androidx.core.app.ActivityCompat
|
||||
import androidx.core.content.ContextCompat
|
||||
import org.json.JSONObject
|
||||
import java.util.concurrent.CountDownLatch
|
||||
import java.util.concurrent.TimeUnit
|
||||
import java.util.Timer
|
||||
import kotlin.concurrent.schedule
|
||||
|
||||
class GeolocationPlugin(private val activity: Activity) {
|
||||
private val geolocation = Geolocation(activity)
|
||||
|
||||
fun checkPermissions(): Map<String, String> {
|
||||
val response = mutableMapOf<String, String>()
|
||||
val coarseStatus = ContextCompat.checkSelfPermission(activity, Manifest.permission.ACCESS_COARSE_LOCATION)
|
||||
val fineStatus = ContextCompat.checkSelfPermission(activity, Manifest.permission.ACCESS_FINE_LOCATION)
|
||||
|
||||
response["location"] = permissionToStatus(fineStatus)
|
||||
response["coarseLocation"] = permissionToStatus(coarseStatus)
|
||||
|
||||
return response
|
||||
}
|
||||
|
||||
fun requestPermissions(callback: (Map<String, String>) -> Unit) {
|
||||
val permissionsToRequest = mutableListOf<String>()
|
||||
|
||||
if (ContextCompat.checkSelfPermission(activity, Manifest.permission.ACCESS_FINE_LOCATION) != PackageManager.PERMISSION_GRANTED) {
|
||||
permissionsToRequest.add(Manifest.permission.ACCESS_FINE_LOCATION)
|
||||
}
|
||||
|
||||
if (ContextCompat.checkSelfPermission(activity, Manifest.permission.ACCESS_COARSE_LOCATION) != PackageManager.PERMISSION_GRANTED) {
|
||||
permissionsToRequest.add(Manifest.permission.ACCESS_COARSE_LOCATION)
|
||||
}
|
||||
|
||||
if (permissionsToRequest.isEmpty()) {
|
||||
callback(checkPermissions())
|
||||
} else {
|
||||
ActivityCompat.requestPermissions(activity, permissionsToRequest.toTypedArray(), 1001)
|
||||
Handler(Looper.getMainLooper()).postDelayed({ callback(checkPermissions()) }, 1000)
|
||||
}
|
||||
}
|
||||
|
||||
fun getCurrentPosition(
|
||||
enableHighAccuracy: Boolean,
|
||||
timeout: Long,
|
||||
maximumAge: Long,
|
||||
successCallback: (Location) -> Unit,
|
||||
errorCallback: (String) -> Unit,
|
||||
) {
|
||||
val lastLocation = geolocation.getLastLocation(maximumAge)
|
||||
if (lastLocation != null) {
|
||||
successCallback(lastLocation)
|
||||
return
|
||||
}
|
||||
|
||||
val timer = Timer()
|
||||
timer.schedule(timeout) {
|
||||
activity.runOnUiThread { errorCallback("Timeout waiting for location.") }
|
||||
}
|
||||
|
||||
geolocation.sendLocation(
|
||||
enableHighAccuracy,
|
||||
{ location ->
|
||||
timer.cancel()
|
||||
successCallback(location)
|
||||
},
|
||||
{ error ->
|
||||
timer.cancel()
|
||||
errorCallback(error)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
private fun permissionToStatus(value: Int): String =
|
||||
when (value) {
|
||||
PackageManager.PERMISSION_GRANTED -> "granted"
|
||||
PackageManager.PERMISSION_DENIED -> "denied"
|
||||
else -> "prompt"
|
||||
}
|
||||
|
||||
// ---- Platform bridge helpers expected by Rust JNI layer ----
|
||||
|
||||
// Called by Rust after constructing the plugin. No-op placeholder to match signature.
|
||||
fun load(webView: WebView?) { /* no-op */ }
|
||||
|
||||
// Serialize current permission status as JSON string
|
||||
fun checkPermissionsJson(): String {
|
||||
val status = checkPermissions()
|
||||
val json = JSONObject()
|
||||
json.put("location", status["location"]) // granted|denied|prompt
|
||||
json.put("coarseLocation", status["coarseLocation"]) // granted|denied|prompt
|
||||
return json.toString()
|
||||
}
|
||||
|
||||
// Request permissions and return resulting status JSON (waits briefly for result)
|
||||
fun requestPermissionsJson(permissionsJson: String?): String {
|
||||
val latch = CountDownLatch(1)
|
||||
var result: String = checkPermissionsJson()
|
||||
|
||||
requestPermissions { status ->
|
||||
val json = JSONObject()
|
||||
json.put("location", status["location"])
|
||||
json.put("coarseLocation", status["coarseLocation"])
|
||||
result = json.toString()
|
||||
latch.countDown()
|
||||
}
|
||||
|
||||
// Wait up to 5 seconds for the permission result, then return whatever we have
|
||||
latch.await(5, TimeUnit.SECONDS)
|
||||
return result
|
||||
}
|
||||
|
||||
// Convert a Location to the Position JSON expected by Rust side
|
||||
private fun locationToPositionJson(location: Location): String {
|
||||
val coords = JSONObject()
|
||||
coords.put("latitude", location.latitude)
|
||||
coords.put("longitude", location.longitude)
|
||||
coords.put("accuracy", location.accuracy.toDouble())
|
||||
if (location.hasAltitude()) coords.put("altitude", location.altitude)
|
||||
if (android.os.Build.VERSION.SDK_INT >= 26) {
|
||||
val vAcc = try { location.verticalAccuracyMeters } catch (_: Exception) { null }
|
||||
if (vAcc != null) coords.put("altitudeAccuracy", vAcc.toDouble())
|
||||
}
|
||||
if (location.hasSpeed()) coords.put("speed", location.speed.toDouble())
|
||||
if (location.hasBearing()) coords.put("heading", location.bearing.toDouble())
|
||||
|
||||
val obj = JSONObject()
|
||||
obj.put("timestamp", System.currentTimeMillis())
|
||||
obj.put("coords", coords)
|
||||
return obj.toString()
|
||||
}
|
||||
|
||||
// Synchronous wrapper returning JSON for getCurrentPosition
|
||||
// Accepts a JSON string with options: {"enableHighAccuracy": bool, "timeout": number, "maximumAge": number}
|
||||
fun getCurrentPositionJson(optionsJson: String?): String {
|
||||
val options = try {
|
||||
if (optionsJson.isNullOrEmpty()) JSONObject() else JSONObject(optionsJson)
|
||||
} catch (e: Exception) {
|
||||
JSONObject()
|
||||
}
|
||||
val enableHighAccuracy = options.optBoolean("enableHighAccuracy", false)
|
||||
val timeout = options.optLong("timeout", 10000L)
|
||||
val maximumAge = options.optLong("maximumAge", 0L)
|
||||
|
||||
var output: String? = null
|
||||
val latch = CountDownLatch(1)
|
||||
|
||||
getCurrentPosition(
|
||||
enableHighAccuracy,
|
||||
timeout,
|
||||
maximumAge,
|
||||
{ location ->
|
||||
output = locationToPositionJson(location)
|
||||
latch.countDown()
|
||||
},
|
||||
{ error ->
|
||||
output = JSONObject(mapOf("error" to error)).toString()
|
||||
latch.countDown()
|
||||
},
|
||||
)
|
||||
|
||||
// Wait up to the timeout + 2s buffer
|
||||
latch.await(timeout + 2000, TimeUnit.MILLISECONDS)
|
||||
return output ?: JSONObject(mapOf("error" to "Timeout waiting for location.")).toString()
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
.DS_Store
|
||||
/.build
|
||||
/Packages
|
||||
/*.xcodeproj
|
||||
xcuserdata/
|
||||
DerivedData/
|
||||
.swiftpm/config/registries.json
|
||||
.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata
|
||||
.netrc
|
||||
Package.resolved
|
||||
@@ -0,0 +1,33 @@
|
||||
// swift-tools-version:5.9
|
||||
// Copyright 2019-2023 Tauri Programme within The Commons Conservancy
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
import PackageDescription
|
||||
|
||||
let package = Package(
|
||||
name: "GeolocationPlugin",
|
||||
platforms: [
|
||||
.iOS(.v17), // iOS 17+ for latest ActivityKit APIs
|
||||
.macOS(.v14),
|
||||
],
|
||||
products: [
|
||||
.library(
|
||||
name: "GeolocationPlugin",
|
||||
type: .static,
|
||||
targets: ["GeolocationPlugin"]
|
||||
)
|
||||
],
|
||||
dependencies: [],
|
||||
targets: [
|
||||
.target(
|
||||
name: "GeolocationPlugin",
|
||||
path: "Sources",
|
||||
linkerSettings: [
|
||||
.linkedFramework("CoreLocation"),
|
||||
.linkedFramework("Foundation"),
|
||||
.linkedFramework("ActivityKit", .when(platforms: [.iOS])),
|
||||
]
|
||||
)
|
||||
]
|
||||
)
|
||||
+322
@@ -0,0 +1,322 @@
|
||||
// Copyright 2019-2023 Tauri Programme within The Commons Conservancy
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
import CoreLocation
|
||||
import Foundation
|
||||
import Dispatch
|
||||
import ActivityKit
|
||||
|
||||
/**
|
||||
* Simplified GeolocationPlugin for Dioxus that works without Tauri dependencies.
|
||||
* This can be shared with Tauri plugins with minimal changes.
|
||||
*/
|
||||
@objc(GeolocationPlugin)
|
||||
public class GeolocationPlugin: NSObject, CLLocationManagerDelegate {
|
||||
private let locationManager = CLLocationManager()
|
||||
private var positionCallbacks: [String: (String) -> Void] = [:]
|
||||
|
||||
override init() {
|
||||
super.init()
|
||||
locationManager.delegate = self
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current position as JSON string (called from ObjC/Rust)
|
||||
*/
|
||||
@objc public func getCurrentPositionJson(_ optionsJson: String) -> String {
|
||||
// Parse options from JSON
|
||||
guard let optionsData = optionsJson.data(using: .utf8),
|
||||
let optionsDict = try? JSONSerialization.jsonObject(with: optionsData) as? [String: Any] else {
|
||||
let error = ["error": "Invalid options JSON"]
|
||||
return (try? JSONSerialization.data(withJSONObject: error))?.base64EncodedString() ?? ""
|
||||
}
|
||||
|
||||
let enableHighAccuracy = optionsDict["enableHighAccuracy"] as? Bool ?? false
|
||||
let timeoutMs = optionsDict["timeout"] as? Double ?? 10000
|
||||
let maximumAgeMs = optionsDict["maximumAge"] as? Double ?? 0
|
||||
|
||||
// If we have a recent cached location, return it immediately
|
||||
if let lastLocation = self.locationManager.location {
|
||||
let ageMs = Date().timeIntervalSince(lastLocation.timestamp) * 1000
|
||||
if maximumAgeMs <= 0 || ageMs <= maximumAgeMs {
|
||||
return self.convertLocationToJson(lastLocation)
|
||||
}
|
||||
}
|
||||
|
||||
let callbackId = UUID().uuidString
|
||||
let semaphore = DispatchSemaphore(value: 0)
|
||||
var responseJson: String?
|
||||
|
||||
self.positionCallbacks[callbackId] = { result in
|
||||
responseJson = result
|
||||
semaphore.signal()
|
||||
}
|
||||
|
||||
if enableHighAccuracy {
|
||||
self.locationManager.desiredAccuracy = kCLLocationAccuracyBest
|
||||
} else {
|
||||
self.locationManager.desiredAccuracy = kCLLocationAccuracyKilometer
|
||||
}
|
||||
|
||||
if CLLocationManager.authorizationStatus() == .notDetermined {
|
||||
self.locationManager.requestWhenInUseAuthorization()
|
||||
} else {
|
||||
self.locationManager.requestLocation()
|
||||
}
|
||||
|
||||
let timeoutSeconds = max(timeoutMs / 1000.0, 0.1)
|
||||
let deadline = Date().addingTimeInterval(timeoutSeconds)
|
||||
while responseJson == nil && Date() < deadline {
|
||||
let _ = RunLoop.current.run(mode: .default, before: Date().addingTimeInterval(0.05))
|
||||
if semaphore.wait(timeout: .now()) == .success {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if let json = responseJson {
|
||||
return json
|
||||
} else {
|
||||
// Timed out waiting for location
|
||||
self.positionCallbacks.removeValue(forKey: callbackId)
|
||||
let error = ["error": "Timeout waiting for location"]
|
||||
return (try? JSONSerialization.data(withJSONObject: error)).flatMap {
|
||||
String(data: $0, encoding: .utf8)
|
||||
} ?? "{\"error\":\"Timeout waiting for location\"}"
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check permissions and return JSON string (called from ObjC/Rust)
|
||||
*/
|
||||
@objc public func checkPermissionsJson() -> String {
|
||||
var status: String = ""
|
||||
|
||||
if CLLocationManager.locationServicesEnabled() {
|
||||
switch CLLocationManager.authorizationStatus() {
|
||||
case .notDetermined:
|
||||
status = "prompt"
|
||||
case .restricted, .denied:
|
||||
status = "denied"
|
||||
case .authorizedAlways, .authorizedWhenInUse:
|
||||
status = "granted"
|
||||
@unknown default:
|
||||
status = "prompt"
|
||||
}
|
||||
} else {
|
||||
let error = ["error": "Location services are not enabled"]
|
||||
return (try? JSONSerialization.data(withJSONObject: error))?.base64EncodedString() ?? ""
|
||||
}
|
||||
|
||||
let result: [String: String] = ["location": status, "coarseLocation": status]
|
||||
|
||||
if let jsonData = try? JSONSerialization.data(withJSONObject: result),
|
||||
let jsonString = String(data: jsonData, encoding: .utf8) {
|
||||
return jsonString
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
/**
|
||||
* Request permissions and return JSON string (called from ObjC/Rust)
|
||||
*/
|
||||
@objc public func requestPermissionsJson(_ permissionsJson: String) -> String {
|
||||
if CLLocationManager.locationServicesEnabled() {
|
||||
if CLLocationManager.authorizationStatus() == .notDetermined {
|
||||
DispatchQueue.main.async {
|
||||
self.locationManager.requestWhenInUseAuthorization()
|
||||
}
|
||||
// Return current status - actual result comes via delegate
|
||||
return self.checkPermissionsJson()
|
||||
} else {
|
||||
return self.checkPermissionsJson()
|
||||
}
|
||||
} else {
|
||||
let error = ["error": "Location services are not enabled"]
|
||||
if let jsonData = try? JSONSerialization.data(withJSONObject: error),
|
||||
let jsonString = String(data: jsonData, encoding: .utf8) {
|
||||
return jsonString
|
||||
}
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
//
|
||||
// CLLocationManagerDelegate methods
|
||||
//
|
||||
|
||||
public func locationManager(_ manager: CLLocationManager, didFailWithError error: Error) {
|
||||
let errorMessage = error.localizedDescription
|
||||
|
||||
// Notify all position callbacks
|
||||
for (_, callback) in self.positionCallbacks {
|
||||
let errorJson = "{\"error\":\"\(errorMessage)\"}"
|
||||
callback(errorJson)
|
||||
}
|
||||
self.positionCallbacks.removeAll()
|
||||
|
||||
}
|
||||
|
||||
public func locationManager(
|
||||
_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]
|
||||
) {
|
||||
guard let location = locations.last else {
|
||||
return
|
||||
}
|
||||
|
||||
let resultJson = self.convertLocationToJson(location)
|
||||
|
||||
// Notify all position callbacks
|
||||
for (_, callback) in self.positionCallbacks {
|
||||
callback(resultJson)
|
||||
}
|
||||
self.positionCallbacks.removeAll()
|
||||
|
||||
}
|
||||
|
||||
public func locationManager(
|
||||
_ manager: CLLocationManager, didChangeAuthorization status: CLAuthorizationStatus
|
||||
) {
|
||||
if !self.positionCallbacks.isEmpty {
|
||||
self.locationManager.requestLocation()
|
||||
}
|
||||
}
|
||||
|
||||
//
|
||||
// Internal/Helper methods
|
||||
//
|
||||
|
||||
private func convertLocationToJson(_ location: CLLocation) -> String {
|
||||
var ret: [String: Any] = [:]
|
||||
var coords: [String: Any] = [:]
|
||||
|
||||
coords["latitude"] = location.coordinate.latitude
|
||||
coords["longitude"] = location.coordinate.longitude
|
||||
coords["accuracy"] = location.horizontalAccuracy
|
||||
coords["altitude"] = location.altitude
|
||||
coords["altitudeAccuracy"] = location.verticalAccuracy
|
||||
coords["speed"] = location.speed
|
||||
coords["heading"] = location.course
|
||||
ret["timestamp"] = Int((location.timestamp.timeIntervalSince1970 * 1000))
|
||||
ret["coords"] = coords
|
||||
|
||||
if let jsonData = try? JSONSerialization.data(withJSONObject: ret),
|
||||
let jsonString = String(data: jsonData, encoding: .utf8) {
|
||||
return jsonString
|
||||
}
|
||||
|
||||
return "{\"error\":\"Failed to serialize location\"}"
|
||||
}
|
||||
|
||||
//
|
||||
// Live Activity methods
|
||||
//
|
||||
|
||||
/// Start a Live Activity showing current location
|
||||
/// Returns JSON with activity ID or error
|
||||
@objc public func startLiveActivityJson() -> String {
|
||||
if #available(iOS 16.2, *) {
|
||||
// Check if Live Activities are enabled
|
||||
guard ActivityAuthorizationInfo().areActivitiesEnabled else {
|
||||
return "{\"error\":\"Live Activities are not enabled\"}"
|
||||
}
|
||||
|
||||
// Get current location
|
||||
guard let location = self.locationManager.location else {
|
||||
return "{\"error\":\"No location available. Request location first.\"}"
|
||||
}
|
||||
|
||||
let attributes = LocationPermissionAttributes(appName: "Geolocation Demo")
|
||||
let contentState = LocationPermissionAttributes.ContentState(
|
||||
latitude: location.coordinate.latitude,
|
||||
longitude: location.coordinate.longitude,
|
||||
accuracy: location.horizontalAccuracy,
|
||||
speed: location.speed >= 0 ? location.speed : nil,
|
||||
heading: location.course >= 0 ? location.course : nil,
|
||||
lastUpdated: Date()
|
||||
)
|
||||
|
||||
do {
|
||||
let activity = try Activity.request(
|
||||
attributes: attributes,
|
||||
content: .init(state: contentState, staleDate: nil),
|
||||
pushType: nil
|
||||
)
|
||||
|
||||
let result: [String: Any] = [
|
||||
"activityId": activity.id,
|
||||
"latitude": location.coordinate.latitude,
|
||||
"longitude": location.coordinate.longitude,
|
||||
"accuracy": location.horizontalAccuracy
|
||||
]
|
||||
|
||||
if let jsonData = try? JSONSerialization.data(withJSONObject: result),
|
||||
let jsonString = String(data: jsonData, encoding: .utf8) {
|
||||
return jsonString
|
||||
}
|
||||
return "{\"error\":\"Failed to serialize result\"}"
|
||||
} catch {
|
||||
return "{\"error\":\"Failed to start Live Activity: \(error.localizedDescription)\"}"
|
||||
}
|
||||
} else {
|
||||
return "{\"error\":\"Live Activities require iOS 16.2+\"}"
|
||||
}
|
||||
}
|
||||
|
||||
/// Update the Live Activity with current location
|
||||
@objc public func updateLiveActivityJson(_ statusJson: String) -> String {
|
||||
if #available(iOS 16.2, *) {
|
||||
// Get current location
|
||||
guard let location = self.locationManager.location else {
|
||||
return "{\"error\":\"No location available\"}"
|
||||
}
|
||||
|
||||
let contentState = LocationPermissionAttributes.ContentState(
|
||||
latitude: location.coordinate.latitude,
|
||||
longitude: location.coordinate.longitude,
|
||||
accuracy: location.horizontalAccuracy,
|
||||
speed: location.speed >= 0 ? location.speed : nil,
|
||||
heading: location.course >= 0 ? location.course : nil,
|
||||
lastUpdated: Date()
|
||||
)
|
||||
|
||||
// Update all running activities of this type
|
||||
Task {
|
||||
for activity in Activity<LocationPermissionAttributes>.activities {
|
||||
await activity.update(
|
||||
ActivityContent(state: contentState, staleDate: nil)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
let result: [String: Any] = [
|
||||
"latitude": location.coordinate.latitude,
|
||||
"longitude": location.coordinate.longitude,
|
||||
"accuracy": location.horizontalAccuracy
|
||||
]
|
||||
if let jsonData = try? JSONSerialization.data(withJSONObject: result),
|
||||
let jsonString = String(data: jsonData, encoding: .utf8) {
|
||||
return jsonString
|
||||
}
|
||||
return "{\"error\":\"Failed to serialize result\"}"
|
||||
} else {
|
||||
return "{\"error\":\"Live Activities require iOS 16.2+\"}"
|
||||
}
|
||||
}
|
||||
|
||||
/// End all Live Activities
|
||||
@objc public func endLiveActivityJson() -> String {
|
||||
if #available(iOS 16.2, *) {
|
||||
Task {
|
||||
for activity in Activity<LocationPermissionAttributes>.activities {
|
||||
await activity.end(nil, dismissalPolicy: .immediate)
|
||||
}
|
||||
}
|
||||
return "{\"success\":true}"
|
||||
} else {
|
||||
return "{\"error\":\"Live Activities require iOS 16.2+\"}"
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
+50
@@ -0,0 +1,50 @@
|
||||
// Shared ActivityAttributes for Live Activities
|
||||
// This MUST be the single source of truth for both the main app and widget extension
|
||||
|
||||
import Foundation
|
||||
import ActivityKit
|
||||
|
||||
/// Live Activity attributes for displaying current location.
|
||||
///
|
||||
/// This struct is shared between the main app (which starts/updates activities)
|
||||
/// and the widget extension (which renders them on the lock screen).
|
||||
public struct LocationPermissionAttributes: ActivityAttributes {
|
||||
/// Dynamic content that can be updated while the activity is running
|
||||
public struct ContentState: Codable, Hashable {
|
||||
/// Current latitude
|
||||
public var latitude: Double
|
||||
/// Current longitude
|
||||
public var longitude: Double
|
||||
/// Horizontal accuracy in meters
|
||||
public var accuracy: Double
|
||||
/// Current speed in m/s (nil if not available)
|
||||
public var speed: Double?
|
||||
/// Current heading in degrees (nil if not available)
|
||||
public var heading: Double?
|
||||
/// Last update timestamp
|
||||
public var lastUpdated: Date
|
||||
|
||||
public init(
|
||||
latitude: Double,
|
||||
longitude: Double,
|
||||
accuracy: Double,
|
||||
speed: Double? = nil,
|
||||
heading: Double? = nil,
|
||||
lastUpdated: Date
|
||||
) {
|
||||
self.latitude = latitude
|
||||
self.longitude = longitude
|
||||
self.accuracy = accuracy
|
||||
self.speed = speed
|
||||
self.heading = heading
|
||||
self.lastUpdated = lastUpdated
|
||||
}
|
||||
}
|
||||
|
||||
/// Static data set when the activity is started (cannot change)
|
||||
public var appName: String
|
||||
|
||||
public init(appName: String) {
|
||||
self.appName = appName
|
||||
}
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
// Copyright 2019-2023 Tauri Programme within The Commons Conservancy
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
import XCTest
|
||||
@testable import ExamplePlugin
|
||||
|
||||
final class ExamplePluginTests: XCTestCase {
|
||||
func testExample() throws {
|
||||
let plugin = ExamplePlugin()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
// swift-tools-version:5.9
|
||||
// Widget Extension for displaying location permission status on lock screen
|
||||
//
|
||||
// IMPORTANT: The target name MUST match the main app's Swift module name.
|
||||
// The plugin uses "GeolocationPlugin" as its package/target name, so the
|
||||
// widget must also use "GeolocationPlugin" for ActivityKit type matching.
|
||||
|
||||
import PackageDescription
|
||||
|
||||
let package = Package(
|
||||
name: "GeolocationPlugin",
|
||||
platforms: [
|
||||
.iOS(.v17), // iOS 17+ for latest ActivityKit APIs
|
||||
],
|
||||
products: [
|
||||
// Executable name must be "widget" for the build system to find it
|
||||
// But the TARGET name determines the Swift module name
|
||||
.executable(
|
||||
name: "widget",
|
||||
targets: ["GeolocationPlugin"]
|
||||
)
|
||||
],
|
||||
dependencies: [],
|
||||
targets: [
|
||||
// Target name = Swift module name = "GeolocationPlugin"
|
||||
// This MUST match the main app's Swift plugin module name!
|
||||
.executableTarget(
|
||||
name: "GeolocationPlugin",
|
||||
path: "Sources",
|
||||
linkerSettings: [
|
||||
.linkedFramework("WidgetKit"),
|
||||
.linkedFramework("SwiftUI"),
|
||||
.linkedFramework("ActivityKit"),
|
||||
]
|
||||
)
|
||||
]
|
||||
)
|
||||
+50
@@ -0,0 +1,50 @@
|
||||
// Shared ActivityAttributes for Live Activities
|
||||
// This MUST be the single source of truth for both the main app and widget extension
|
||||
|
||||
import Foundation
|
||||
import ActivityKit
|
||||
|
||||
/// Live Activity attributes for displaying current location.
|
||||
///
|
||||
/// This struct is shared between the main app (which starts/updates activities)
|
||||
/// and the widget extension (which renders them on the lock screen).
|
||||
public struct LocationPermissionAttributes: ActivityAttributes {
|
||||
/// Dynamic content that can be updated while the activity is running
|
||||
public struct ContentState: Codable, Hashable {
|
||||
/// Current latitude
|
||||
public var latitude: Double
|
||||
/// Current longitude
|
||||
public var longitude: Double
|
||||
/// Horizontal accuracy in meters
|
||||
public var accuracy: Double
|
||||
/// Current speed in m/s (nil if not available)
|
||||
public var speed: Double?
|
||||
/// Current heading in degrees (nil if not available)
|
||||
public var heading: Double?
|
||||
/// Last update timestamp
|
||||
public var lastUpdated: Date
|
||||
|
||||
public init(
|
||||
latitude: Double,
|
||||
longitude: Double,
|
||||
accuracy: Double,
|
||||
speed: Double? = nil,
|
||||
heading: Double? = nil,
|
||||
lastUpdated: Date
|
||||
) {
|
||||
self.latitude = latitude
|
||||
self.longitude = longitude
|
||||
self.accuracy = accuracy
|
||||
self.speed = speed
|
||||
self.heading = heading
|
||||
self.lastUpdated = lastUpdated
|
||||
}
|
||||
}
|
||||
|
||||
/// Static data set when the activity is started (cannot change)
|
||||
public var appName: String
|
||||
|
||||
public init(appName: String) {
|
||||
self.appName = appName
|
||||
}
|
||||
}
|
||||
+316
@@ -0,0 +1,316 @@
|
||||
// Widget Extension for Live Activity only
|
||||
// Note: Having multiple widgets in the same bundle can cause Live Activities to show black
|
||||
// See: https://developer.apple.com/forums/thread/807726
|
||||
import ActivityKit
|
||||
import SwiftUI
|
||||
import WidgetKit
|
||||
|
||||
@main
|
||||
struct LocationWidgetBundle: WidgetBundle {
|
||||
var body: some Widget {
|
||||
// Only include the Live Activity - other widgets can cause rendering issues
|
||||
LocationPermissionLiveActivity()
|
||||
}
|
||||
}
|
||||
|
||||
// Helper to get accuracy color
|
||||
func accuracyColor(_ accuracy: Double) -> Color {
|
||||
if accuracy < 10 {
|
||||
return .green
|
||||
} else if accuracy < 50 {
|
||||
return .yellow
|
||||
} else if accuracy < 100 {
|
||||
return .orange
|
||||
} else {
|
||||
return .red
|
||||
}
|
||||
}
|
||||
|
||||
// Helper to format coordinates with degree symbol
|
||||
func formatCoord(_ value: Double, isLat: Bool) -> String {
|
||||
let direction = isLat ? (value >= 0 ? "N" : "S") : (value >= 0 ? "E" : "W")
|
||||
return String(format: "%.5f° %@", abs(value), direction)
|
||||
}
|
||||
|
||||
// Live Activity widget
|
||||
struct LocationPermissionLiveActivity: Widget {
|
||||
var body: some WidgetConfiguration {
|
||||
ActivityConfiguration(for: LocationPermissionAttributes.self) { context in
|
||||
// Lock screen view - flashy gradient design
|
||||
ZStack {
|
||||
// Gradient background
|
||||
LinearGradient(
|
||||
colors: [
|
||||
Color(red: 0.1, green: 0.1, blue: 0.2),
|
||||
Color(red: 0.05, green: 0.15, blue: 0.25)
|
||||
],
|
||||
startPoint: .topLeading,
|
||||
endPoint: .bottomTrailing
|
||||
)
|
||||
|
||||
VStack(spacing: 12) {
|
||||
// Header with pulsing indicator
|
||||
HStack(spacing: 12) {
|
||||
// Animated location icon with glow
|
||||
ZStack {
|
||||
Circle()
|
||||
.fill(accuracyColor(context.state.accuracy).opacity(0.3))
|
||||
.frame(width: 44, height: 44)
|
||||
Circle()
|
||||
.fill(accuracyColor(context.state.accuracy).opacity(0.6))
|
||||
.frame(width: 32, height: 32)
|
||||
Image(systemName: "location.fill")
|
||||
.font(.system(size: 18, weight: .bold))
|
||||
.foregroundColor(.white)
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(context.attributes.appName)
|
||||
.font(.headline)
|
||||
.fontWeight(.bold)
|
||||
.foregroundColor(.white)
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: "antenna.radiowaves.left.and.right")
|
||||
.font(.caption2)
|
||||
Text("LIVE")
|
||||
.font(.caption2)
|
||||
.fontWeight(.bold)
|
||||
}
|
||||
.foregroundColor(accuracyColor(context.state.accuracy))
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
// Accuracy with animated ring
|
||||
VStack(spacing: 2) {
|
||||
Text("\(Int(context.state.accuracy))")
|
||||
.font(.system(size: 24, weight: .bold, design: .rounded))
|
||||
.foregroundColor(accuracyColor(context.state.accuracy))
|
||||
.contentTransition(.numericText())
|
||||
Text("meters")
|
||||
.font(.caption2)
|
||||
.foregroundColor(.gray)
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 8)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 12)
|
||||
.fill(accuracyColor(context.state.accuracy).opacity(0.15))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 12)
|
||||
.strokeBorder(accuracyColor(context.state.accuracy).opacity(0.5), lineWidth: 1)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
// Coordinates in stylish cards
|
||||
HStack(spacing: 8) {
|
||||
// Latitude card
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: "arrow.up.arrow.down")
|
||||
.font(.caption2)
|
||||
Text("LATITUDE")
|
||||
.font(.caption2)
|
||||
.fontWeight(.semibold)
|
||||
}
|
||||
.foregroundColor(.cyan.opacity(0.8))
|
||||
|
||||
Text(formatCoord(context.state.latitude, isLat: true))
|
||||
.font(.system(.callout, design: .monospaced))
|
||||
.fontWeight(.medium)
|
||||
.foregroundColor(.white)
|
||||
.contentTransition(.numericText())
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(10)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 10)
|
||||
.fill(Color.cyan.opacity(0.1))
|
||||
)
|
||||
|
||||
// Longitude card
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: "arrow.left.arrow.right")
|
||||
.font(.caption2)
|
||||
Text("LONGITUDE")
|
||||
.font(.caption2)
|
||||
.fontWeight(.semibold)
|
||||
}
|
||||
.foregroundColor(.purple.opacity(0.8))
|
||||
|
||||
Text(formatCoord(context.state.longitude, isLat: false))
|
||||
.font(.system(.callout, design: .monospaced))
|
||||
.fontWeight(.medium)
|
||||
.foregroundColor(.white)
|
||||
.contentTransition(.numericText())
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(10)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 10)
|
||||
.fill(Color.purple.opacity(0.1))
|
||||
)
|
||||
}
|
||||
|
||||
// Speed and heading row (if available)
|
||||
if let speed = context.state.speed, speed >= 0 {
|
||||
HStack(spacing: 16) {
|
||||
// Speed
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: "speedometer")
|
||||
.foregroundColor(.orange)
|
||||
Text(String(format: "%.1f m/s", speed))
|
||||
.font(.system(.caption, design: .monospaced))
|
||||
.fontWeight(.medium)
|
||||
.foregroundColor(.white)
|
||||
.contentTransition(.numericText())
|
||||
}
|
||||
|
||||
// Heading if available
|
||||
if let heading = context.state.heading, heading >= 0 {
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: "safari")
|
||||
.foregroundColor(.mint)
|
||||
.rotationEffect(.degrees(heading))
|
||||
Text(String(format: "%.0f°", heading))
|
||||
.font(.system(.caption, design: .monospaced))
|
||||
.fontWeight(.medium)
|
||||
.foregroundColor(.white)
|
||||
.contentTransition(.numericText())
|
||||
}
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
// Last updated
|
||||
Text(context.state.lastUpdated, style: .relative)
|
||||
.font(.caption2)
|
||||
.foregroundColor(.gray)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
.activitySystemActionForegroundColor(.white)
|
||||
|
||||
} dynamicIsland: { context in
|
||||
DynamicIsland {
|
||||
// Expanded regions
|
||||
DynamicIslandExpandedRegion(.leading) {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
ZStack {
|
||||
Circle()
|
||||
.fill(accuracyColor(context.state.accuracy).opacity(0.3))
|
||||
.frame(width: 36, height: 36)
|
||||
Image(systemName: "location.fill")
|
||||
.foregroundColor(accuracyColor(context.state.accuracy))
|
||||
.font(.system(size: 16, weight: .bold))
|
||||
}
|
||||
Text("\(Int(context.state.accuracy))m")
|
||||
.font(.caption2)
|
||||
.fontWeight(.bold)
|
||||
.foregroundColor(accuracyColor(context.state.accuracy))
|
||||
.contentTransition(.numericText())
|
||||
}
|
||||
}
|
||||
|
||||
DynamicIslandExpandedRegion(.center) {
|
||||
VStack(spacing: 6) {
|
||||
// Latitude
|
||||
HStack(spacing: 4) {
|
||||
Text("LAT")
|
||||
.font(.caption2)
|
||||
.foregroundColor(.cyan.opacity(0.7))
|
||||
Text(String(format: "%.5f°", context.state.latitude))
|
||||
.font(.system(.caption, design: .monospaced))
|
||||
.fontWeight(.semibold)
|
||||
.foregroundColor(.cyan)
|
||||
.contentTransition(.numericText())
|
||||
}
|
||||
// Longitude
|
||||
HStack(spacing: 4) {
|
||||
Text("LON")
|
||||
.font(.caption2)
|
||||
.foregroundColor(.purple.opacity(0.7))
|
||||
Text(String(format: "%.5f°", context.state.longitude))
|
||||
.font(.system(.caption, design: .monospaced))
|
||||
.fontWeight(.semibold)
|
||||
.foregroundColor(.purple)
|
||||
.contentTransition(.numericText())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
DynamicIslandExpandedRegion(.trailing) {
|
||||
if let speed = context.state.speed, speed >= 0 {
|
||||
VStack(alignment: .trailing, spacing: 4) {
|
||||
Image(systemName: "speedometer")
|
||||
.foregroundColor(.orange)
|
||||
.font(.caption)
|
||||
Text(String(format: "%.1f", speed))
|
||||
.font(.system(.caption, design: .rounded))
|
||||
.fontWeight(.bold)
|
||||
.foregroundColor(.white)
|
||||
.contentTransition(.numericText())
|
||||
Text("m/s")
|
||||
.font(.caption2)
|
||||
.foregroundColor(.gray)
|
||||
}
|
||||
} else {
|
||||
// Show heading compass if no speed
|
||||
if let heading = context.state.heading, heading >= 0 {
|
||||
VStack(spacing: 2) {
|
||||
Image(systemName: "safari")
|
||||
.font(.title3)
|
||||
.foregroundColor(.mint)
|
||||
.rotationEffect(.degrees(heading))
|
||||
Text(String(format: "%.0f°", heading))
|
||||
.font(.caption2)
|
||||
.foregroundColor(.white)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
DynamicIslandExpandedRegion(.bottom) {
|
||||
HStack {
|
||||
Text(context.attributes.appName)
|
||||
.font(.caption2)
|
||||
.foregroundColor(.gray)
|
||||
Spacer()
|
||||
Text(context.state.lastUpdated, style: .relative)
|
||||
.font(.caption2)
|
||||
.foregroundColor(.gray)
|
||||
}
|
||||
}
|
||||
|
||||
} compactLeading: {
|
||||
ZStack {
|
||||
Circle()
|
||||
.fill(accuracyColor(context.state.accuracy).opacity(0.3))
|
||||
.frame(width: 24, height: 24)
|
||||
Image(systemName: "location.fill")
|
||||
.foregroundColor(accuracyColor(context.state.accuracy))
|
||||
.font(.caption)
|
||||
}
|
||||
} compactTrailing: {
|
||||
Text(String(format: "%.4f°", context.state.latitude))
|
||||
.font(.system(.caption2, design: .monospaced))
|
||||
.fontWeight(.medium)
|
||||
.foregroundColor(.cyan)
|
||||
.contentTransition(.numericText())
|
||||
} minimal: {
|
||||
ZStack {
|
||||
Circle()
|
||||
.fill(accuracyColor(context.state.accuracy).opacity(0.3))
|
||||
.frame(width: 22, height: 22)
|
||||
Image(systemName: "location.fill")
|
||||
.foregroundColor(accuracyColor(context.state.accuracy))
|
||||
.font(.system(size: 10, weight: .bold))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,282 @@
|
||||
//! A simple Dioxus app demonstrating how to build a native plugin using manganis.
|
||||
//!
|
||||
//! This example shows how to use the `#[manganis::ffi]` macro to automatically generate
|
||||
//! FFI bindings between Rust and native platforms (Swift/Kotlin).
|
||||
//!
|
||||
//! It also demonstrates how to use the widget!() macro to bundle a Widget Extension
|
||||
//! for Live Activities on iOS.
|
||||
|
||||
use dioxus::prelude::*;
|
||||
|
||||
// Import the local plugin module
|
||||
mod plugin;
|
||||
#[cfg(target_os = "ios")]
|
||||
use plugin::LiveActivityResult;
|
||||
use plugin::{Geolocation, PermissionState, PermissionStatus, Position, PositionOptions};
|
||||
|
||||
fn main() {
|
||||
dioxus::launch(App);
|
||||
}
|
||||
|
||||
#[component]
|
||||
fn App() -> Element {
|
||||
let mut geolocation = use_signal(Geolocation::new);
|
||||
let mut permission_status = use_signal(|| None::<PermissionStatus>);
|
||||
let mut last_position = use_signal(|| None::<Position>);
|
||||
let mut error = use_signal(|| None::<String>);
|
||||
let mut use_high_accuracy = use_signal(|| true);
|
||||
let mut max_age_input = use_signal(|| String::from("0"));
|
||||
|
||||
let on_check_permissions = {
|
||||
move |_| match geolocation.write().check_permissions() {
|
||||
Ok(status) => {
|
||||
permission_status.set(Some(status));
|
||||
error.set(None);
|
||||
}
|
||||
Err(err) => error.set(Some(err.to_string())),
|
||||
}
|
||||
};
|
||||
|
||||
let on_request_permissions = move |_| {
|
||||
let mut geo = geolocation.write();
|
||||
match geo.request_permissions(None) {
|
||||
Ok(_) => match geo.check_permissions() {
|
||||
Ok(status) => {
|
||||
permission_status.set(Some(status));
|
||||
error.set(None);
|
||||
}
|
||||
Err(err) => error.set(Some(err.to_string())),
|
||||
},
|
||||
Err(err) => error.set(Some(err.to_string())),
|
||||
}
|
||||
};
|
||||
|
||||
let on_toggle_accuracy = move |_| use_high_accuracy.toggle();
|
||||
|
||||
let on_max_age_input = move |evt: FormEvent| max_age_input.set(evt.value());
|
||||
|
||||
let on_fetch_position = move |_| {
|
||||
let maximum_age = max_age_input.read().trim().parse::<u32>().unwrap_or(0);
|
||||
|
||||
let options = PositionOptions {
|
||||
enable_high_accuracy: use_high_accuracy(),
|
||||
timeout: 10_000,
|
||||
maximum_age,
|
||||
};
|
||||
|
||||
match geolocation.write().get_current_position(Some(options)) {
|
||||
Ok(position) => {
|
||||
last_position.set(Some(position));
|
||||
error.set(None);
|
||||
}
|
||||
Err(err) => error.set(Some(err.to_string())),
|
||||
}
|
||||
};
|
||||
|
||||
let accuracy_label = if use_high_accuracy() {
|
||||
"High accuracy: on"
|
||||
} else {
|
||||
"High accuracy: off"
|
||||
};
|
||||
|
||||
rsx! {
|
||||
Stylesheet { href: asset!("/assets/main.css") }
|
||||
|
||||
main { class: "app",
|
||||
header { class: "hero",
|
||||
div { class: "hero__copy",
|
||||
h1 { "Geolocation plugin demo" }
|
||||
p { "One-shot location fetching through the Dioxus geolocation plugin.
|
||||
Measure permissions, request access, and inspect the last fix received from the device." }
|
||||
}
|
||||
}
|
||||
|
||||
div { class: "cards",
|
||||
section { class: "card",
|
||||
h2 { "Permissions" }
|
||||
p { class: "muted",
|
||||
"First, inspect what the OS currently allows this app to do. \
|
||||
On Android & iOS these calls talk to the native permission dialog APIs." }
|
||||
div { class: "button-row",
|
||||
button { onclick: on_check_permissions, "Check permissions" }
|
||||
button { class: "secondary", onclick: on_request_permissions, "Request permissions" }
|
||||
}
|
||||
match permission_status() {
|
||||
Some(status) => rsx! {
|
||||
div { class: "status-grid",
|
||||
PermissionBadge { label: "Location".to_string(), state: status.location }
|
||||
PermissionBadge { label: "Coarse location".to_string(), state: status.coarse_location }
|
||||
}
|
||||
},
|
||||
None => rsx!(p { class: "muted", "Tap “Check permissions” to see the current status." }),
|
||||
}
|
||||
}
|
||||
|
||||
section { class: "card",
|
||||
h2 { "Current position" }
|
||||
p { class: "muted",
|
||||
"The plugin resolves the device location once per request (no background watch). \
|
||||
Configure the query and then fetch the coordinates." }
|
||||
div { class: "settings",
|
||||
button {
|
||||
class: if use_high_accuracy() { "toggle toggle--active" } else { "toggle" },
|
||||
onclick: on_toggle_accuracy,
|
||||
"{accuracy_label}"
|
||||
}
|
||||
label { class: "field",
|
||||
span { "Max cached age (ms)" }
|
||||
input {
|
||||
r#type: "number",
|
||||
inputmode: "numeric",
|
||||
min: "0",
|
||||
placeholder: "0",
|
||||
value: "{max_age_input()}",
|
||||
oninput: on_max_age_input,
|
||||
}
|
||||
}
|
||||
}
|
||||
button { class: "primary full-width", onclick: on_fetch_position, "Get current position" }
|
||||
|
||||
match last_position() {
|
||||
Some(position) => {
|
||||
let snapshot = position.clone();
|
||||
let coords = snapshot.coords.clone();
|
||||
rsx! {
|
||||
div { class: "position",
|
||||
h3 { "Latest reading" }
|
||||
p { class: "muted", "Timestamp: {snapshot.timestamp} ms since Unix epoch" }
|
||||
div { class: "position__grid",
|
||||
CoordinateRow { label: "Latitude".to_string(), value: format!("{:.6}", coords.latitude) }
|
||||
CoordinateRow { label: "Longitude".to_string(), value: format!("{:.6}", coords.longitude) }
|
||||
CoordinateRow { label: "Accuracy (m)".to_string(), value: format!("{:.1}", coords.accuracy) }
|
||||
CoordinateRow { label: "Altitude (m)".to_string(), value: format_optional(coords.altitude) }
|
||||
CoordinateRow { label: "Altitude accuracy (m)".to_string(), value: format_optional(coords.altitude_accuracy) }
|
||||
CoordinateRow { label: "Speed (m/s)".to_string(), value: format_optional(coords.speed) }
|
||||
CoordinateRow { label: "Heading (°)".to_string(), value: format_optional(coords.heading) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
None => rsx!(p { class: "muted", "No location fetched yet." }),
|
||||
}
|
||||
}
|
||||
|
||||
// Live Activity card (iOS only)
|
||||
LiveActivityCard { geolocation, error }
|
||||
}
|
||||
|
||||
if let Some(message) = error() {
|
||||
div { class: "error-banner", "Last error: {message}" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
fn PermissionBadge(label: String, state: PermissionState) -> Element {
|
||||
let (text, class) = match state {
|
||||
PermissionState::Granted => ("Granted", "badge badge--granted"),
|
||||
PermissionState::Denied => ("Denied", "badge badge--denied"),
|
||||
PermissionState::Prompt | PermissionState::PromptWithRationale => {
|
||||
("Needs prompt", "badge badge--prompt")
|
||||
}
|
||||
};
|
||||
|
||||
rsx! {
|
||||
div { class: "permission-row",
|
||||
span { class: "muted", "{label}" }
|
||||
span { class: class, "{text}" }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
fn CoordinateRow(label: String, value: String) -> Element {
|
||||
rsx! {
|
||||
div { class: "coordinate-row",
|
||||
span { class: "muted", "{label}" }
|
||||
strong { "{value}" }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn format_optional(value: Option<f64>) -> String {
|
||||
value
|
||||
.map(|inner| format!("{inner:.2}"))
|
||||
.unwrap_or_else(|| "—".to_string())
|
||||
}
|
||||
|
||||
#[cfg(target_os = "ios")]
|
||||
#[component]
|
||||
fn LiveActivityCard(
|
||||
mut geolocation: Signal<Geolocation>,
|
||||
mut error: Signal<Option<String>>,
|
||||
) -> Element {
|
||||
let mut live_activity = use_signal(|| None::<LiveActivityResult>);
|
||||
|
||||
let on_start_live_activity = move |_| match geolocation.write().start_live_activity() {
|
||||
Ok(result) => {
|
||||
live_activity.set(Some(result));
|
||||
error.set(None);
|
||||
}
|
||||
Err(err) => error.set(Some(err.to_string())),
|
||||
};
|
||||
|
||||
let on_update_live_activity = move |_| match geolocation.write().update_live_activity() {
|
||||
Ok(_) => error.set(None),
|
||||
Err(err) => error.set(Some(err.to_string())),
|
||||
};
|
||||
|
||||
let on_end_live_activity = move |_| match geolocation.write().end_live_activity() {
|
||||
Ok(_) => {
|
||||
live_activity.set(None);
|
||||
error.set(None);
|
||||
}
|
||||
Err(err) => error.set(Some(err.to_string())),
|
||||
};
|
||||
|
||||
rsx! {
|
||||
section { class: "card",
|
||||
h2 { "Live Activity" }
|
||||
p { class: "muted",
|
||||
"Start a Live Activity to show your current location on the lock screen. \
|
||||
Fetch your position first, then start the activity." }
|
||||
div { class: "button-row",
|
||||
button { onclick: on_start_live_activity, "Start Activity" }
|
||||
button { class: "secondary", onclick: on_update_live_activity, "Update" }
|
||||
button { class: "secondary", onclick: on_end_live_activity, "End" }
|
||||
}
|
||||
match live_activity() {
|
||||
Some(activity) => rsx! {
|
||||
div { class: "status-grid",
|
||||
div { class: "permission-row",
|
||||
span { class: "muted", "Activity ID" }
|
||||
span { "{activity.activity_id}" }
|
||||
}
|
||||
div { class: "permission-row",
|
||||
span { class: "muted", "Latitude" }
|
||||
span { "{activity.latitude:.6}" }
|
||||
}
|
||||
div { class: "permission-row",
|
||||
span { class: "muted", "Longitude" }
|
||||
span { "{activity.longitude:.6}" }
|
||||
}
|
||||
div { class: "permission-row",
|
||||
span { class: "muted", "Accuracy" }
|
||||
span { class: "badge badge--granted", "{activity.accuracy:.1}m" }
|
||||
}
|
||||
}
|
||||
},
|
||||
None => rsx! {
|
||||
p { class: "muted", "No Live Activity running." }
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "ios"))]
|
||||
#[component]
|
||||
fn LiveActivityCard(geolocation: Signal<Geolocation>, error: Signal<Option<String>>) -> Element {
|
||||
VNode::empty()
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
// Copyright 2019-2023 Tauri Programme within The Commons Conservancy
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
use serde::{ser::Serializer, Serialize};
|
||||
|
||||
pub type Result<T> = std::result::Result<T, Error>;
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum Error {
|
||||
/// JSON serialization/deserialization error
|
||||
#[error("JSON error: {0}")]
|
||||
Json(#[from] serde_json::Error),
|
||||
|
||||
/// Platform bridge error
|
||||
#[error("Platform bridge error: {0}")]
|
||||
PlatformBridge(String),
|
||||
|
||||
/// Location unavailable
|
||||
#[error("Location unavailable: {0}")]
|
||||
LocationUnavailable(String),
|
||||
|
||||
/// Live Activity error (iOS 16.1+)
|
||||
#[cfg(target_os = "ios")]
|
||||
#[error("Live Activity error: {0}")]
|
||||
LiveActivity(String),
|
||||
}
|
||||
|
||||
impl Serialize for Error {
|
||||
fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
|
||||
where
|
||||
S: Serializer,
|
||||
{
|
||||
serializer.serialize_str(self.to_string().as_ref())
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&str> for Error {
|
||||
fn from(s: &str) -> Self {
|
||||
Error::PlatformBridge(s.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
impl From<String> for Error {
|
||||
fn from(s: String) -> Self {
|
||||
Error::PlatformBridge(s)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,313 @@
|
||||
#![allow(non_snake_case)]
|
||||
// Copyright 2019-2023 Tauri Programme within The Commons Conservancy
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
//! Dioxus Geolocation Plugin
|
||||
//!
|
||||
//! This plugin provides APIs for getting and tracking the device's current position
|
||||
//! on Android and iOS mobile platforms.
|
||||
//!
|
||||
//! This example demonstrates the use of the `#[manganis::ffi]` macro for automatic
|
||||
//! FFI binding generation between Rust and native platforms.
|
||||
|
||||
pub use models::*;
|
||||
|
||||
mod error;
|
||||
mod models;
|
||||
|
||||
pub use error::{Error, Result};
|
||||
|
||||
// Note: Permissions are now declared in Dioxus.toml using the unified manifest system.
|
||||
// See Dioxus.toml in the project root for the permission configuration:
|
||||
//
|
||||
// [permissions]
|
||||
// location = { precision = "fine", description = "Access your precise location..." }
|
||||
//
|
||||
// The CLI automatically maps these to platform-specific identifiers:
|
||||
// - Android: ACCESS_FINE_LOCATION in AndroidManifest.xml
|
||||
// - iOS: NSLocationWhenInUseUsageDescription in Info.plist
|
||||
|
||||
/// Access to the geolocation APIs.
|
||||
///
|
||||
/// This struct provides a unified interface for accessing geolocation functionality
|
||||
/// on both Android and iOS platforms. It uses the `#[manganis::ffi]` macro for
|
||||
/// automatic FFI binding generation.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```rust,no_run
|
||||
/// use plugin::{Geolocation, PermissionState, PositionOptions};
|
||||
///
|
||||
/// let mut geolocation = Geolocation::new();
|
||||
///
|
||||
/// // Check permissions
|
||||
/// let status = geolocation.check_permissions()?;
|
||||
/// if status.location == PermissionState::Prompt {
|
||||
/// let new_status = geolocation.request_permissions(None)?;
|
||||
/// }
|
||||
///
|
||||
/// // Get current position
|
||||
/// let options = PositionOptions {
|
||||
/// enable_high_accuracy: true,
|
||||
/// timeout: 10000,
|
||||
/// maximum_age: 0,
|
||||
/// };
|
||||
/// let position = geolocation.get_current_position(Some(options))?;
|
||||
/// println!("Latitude: {}, Longitude: {}", position.coords.latitude, position.coords.longitude);
|
||||
///
|
||||
/// # Ok::<(), plugin::Error>(())
|
||||
/// ```
|
||||
pub struct Geolocation {
|
||||
plugin: Option<GeolocationPlugin>,
|
||||
}
|
||||
|
||||
impl Geolocation {
|
||||
/// Create a new Geolocation instance
|
||||
pub fn new() -> Self {
|
||||
Self { plugin: None }
|
||||
}
|
||||
|
||||
/// Get or initialize the plugin instance
|
||||
fn get_plugin(&mut self) -> Result<&GeolocationPlugin> {
|
||||
if self.plugin.is_none() {
|
||||
self.plugin = Some(GeolocationPlugin::new()?);
|
||||
}
|
||||
Ok(self.plugin.as_ref().unwrap())
|
||||
}
|
||||
|
||||
/// Get the device's current position.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `options` - Optional position options. If `None`, default options are used.
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// Returns the current position or an error if the location cannot be obtained.
|
||||
pub fn get_current_position(&mut self, options: Option<PositionOptions>) -> Result<Position> {
|
||||
let options = options.unwrap_or_default();
|
||||
let options_json = serde_json::to_string(&options).map_err(Error::Json)?;
|
||||
|
||||
let plugin = self.get_plugin()?;
|
||||
let result_json = getCurrentPositionJson(plugin, options_json)?;
|
||||
|
||||
// Check for error in response
|
||||
let json_value: serde_json::Value =
|
||||
serde_json::from_str(&result_json).map_err(Error::Json)?;
|
||||
if let Some(error_msg) = json_value.get("error") {
|
||||
return Err(Error::LocationUnavailable(
|
||||
error_msg.as_str().unwrap_or("Unknown error").to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
let position: Position = serde_json::from_str(&result_json).map_err(Error::Json)?;
|
||||
Ok(position)
|
||||
}
|
||||
|
||||
/// Check the current permission status.
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// Returns the permission status for location and coarse location permissions.
|
||||
pub fn check_permissions(&mut self) -> Result<PermissionStatus> {
|
||||
let plugin = self.get_plugin()?;
|
||||
let result_json = checkPermissionsJson(plugin)?;
|
||||
let status: PermissionStatus = serde_json::from_str(&result_json).map_err(Error::Json)?;
|
||||
Ok(status)
|
||||
}
|
||||
|
||||
/// Request location permissions from the user.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `permissions` - Optional list of specific permission types to request.
|
||||
/// If `None`, requests all location permissions.
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// Returns the permission status after the user responds to the permission request.
|
||||
pub fn request_permissions(
|
||||
&mut self,
|
||||
permissions: Option<Vec<PermissionType>>,
|
||||
) -> Result<PermissionStatus> {
|
||||
let perms_json = serde_json::to_string(&permissions).map_err(Error::Json)?;
|
||||
let plugin = self.get_plugin()?;
|
||||
let result_json = requestPermissionsJson(plugin, perms_json)?;
|
||||
let status: PermissionStatus = serde_json::from_str(&result_json).map_err(Error::Json)?;
|
||||
Ok(status)
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Live Activity methods (iOS 16.1+)
|
||||
// =========================================================================
|
||||
|
||||
/// Start a Live Activity showing the current permission status.
|
||||
///
|
||||
/// Live Activities appear on the lock screen and Dynamic Island (on supported devices).
|
||||
/// Requires iOS 16.1+ and a Widget Extension for the UI (see docs).
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// Returns the activity ID and current permission status, or an error.
|
||||
#[cfg(target_os = "ios")]
|
||||
pub fn start_live_activity(&mut self) -> Result<LiveActivityResult> {
|
||||
let plugin = self.get_plugin()?;
|
||||
let result_json = startLiveActivityJson(plugin)?;
|
||||
|
||||
// Check for error in response
|
||||
let json_value: serde_json::Value =
|
||||
serde_json::from_str(&result_json).map_err(Error::Json)?;
|
||||
if let Some(error_msg) = json_value.get("error") {
|
||||
return Err(Error::LiveActivity(
|
||||
error_msg.as_str().unwrap_or("Unknown error").to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
let result: LiveActivityResult = serde_json::from_str(&result_json).map_err(Error::Json)?;
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
/// Update the Live Activity with the current permission status.
|
||||
///
|
||||
/// Call this after permission changes to reflect the new state.
|
||||
#[cfg(target_os = "ios")]
|
||||
pub fn update_live_activity(&mut self) -> Result<LiveActivityUpdate> {
|
||||
let plugin = self.get_plugin()?;
|
||||
let result_json = updateLiveActivityJson(plugin, "{}".to_string())?;
|
||||
|
||||
let json_value: serde_json::Value =
|
||||
serde_json::from_str(&result_json).map_err(Error::Json)?;
|
||||
if let Some(error_msg) = json_value.get("error") {
|
||||
return Err(Error::LiveActivity(
|
||||
error_msg.as_str().unwrap_or("Unknown error").to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
let result: LiveActivityUpdate = serde_json::from_str(&result_json).map_err(Error::Json)?;
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
/// End all Live Activities for this app.
|
||||
#[cfg(target_os = "ios")]
|
||||
pub fn end_live_activity(&mut self) -> Result<()> {
|
||||
let plugin = self.get_plugin()?;
|
||||
let result_json = endLiveActivityJson(plugin)?;
|
||||
|
||||
let json_value: serde_json::Value =
|
||||
serde_json::from_str(&result_json).map_err(Error::Json)?;
|
||||
if let Some(error_msg) = json_value.get("error") {
|
||||
return Err(Error::LiveActivity(
|
||||
error_msg.as_str().unwrap_or("Unknown error").to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for Geolocation {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
/// iOS/macOS native bindings - the macro generates all FFI code automatically.
|
||||
/// The path "src/ios/plugin" points to the SwiftPM package containing GeolocationPlugin.swift
|
||||
#[cfg(any(target_os = "ios", target_os = "macos"))]
|
||||
#[manganis::ffi("src/ios/plugin")]
|
||||
extern "Swift" {
|
||||
/// The native GeolocationPlugin class
|
||||
pub type GeolocationPlugin;
|
||||
|
||||
/// Get current position as JSON string
|
||||
/// Swift signature: func getCurrentPositionJson(_ optionsJson: String) -> String
|
||||
pub fn getCurrentPositionJson(this: &GeolocationPlugin, optionsJson: String) -> String;
|
||||
|
||||
/// Check permissions and return status as JSON
|
||||
/// Swift signature: func checkPermissionsJson() -> String
|
||||
pub fn checkPermissionsJson(this: &GeolocationPlugin) -> String;
|
||||
|
||||
/// Request permissions with optional types list as JSON, return status as JSON
|
||||
/// Swift signature: func requestPermissionsJson(_ permissionsJson: String) -> String
|
||||
pub fn requestPermissionsJson(this: &GeolocationPlugin, permissionsJson: String) -> String;
|
||||
|
||||
/// Start a Live Activity showing permission status (iOS 16.1+)
|
||||
/// Swift signature: func startLiveActivityJson() -> String
|
||||
pub fn startLiveActivityJson(this: &GeolocationPlugin) -> String;
|
||||
|
||||
/// Update the Live Activity with current permission status
|
||||
/// Swift signature: func updateLiveActivityJson(_ statusJson: String) -> String
|
||||
pub fn updateLiveActivityJson(this: &GeolocationPlugin, statusJson: String) -> String;
|
||||
|
||||
/// End all Live Activities
|
||||
/// Swift signature: func endLiveActivityJson() -> String
|
||||
pub fn endLiveActivityJson(this: &GeolocationPlugin) -> String;
|
||||
}
|
||||
|
||||
/// Android native bindings - the macro generates all JNI code automatically.
|
||||
/// The path "src/android" points to the Gradle project containing GeolocationPlugin.kt
|
||||
#[cfg(target_os = "android")]
|
||||
#[manganis::ffi("src/android")]
|
||||
extern "Kotlin" {
|
||||
/// The native GeolocationPlugin class
|
||||
pub type GeolocationPlugin;
|
||||
|
||||
/// Get current position as JSON string
|
||||
/// Kotlin signature: fun getCurrentPositionJson(optionsJson: String): String
|
||||
pub fn getCurrentPositionJson(this: &GeolocationPlugin, optionsJson: String) -> String;
|
||||
|
||||
/// Check permissions and return status as JSON
|
||||
/// Kotlin signature: fun checkPermissionsJson(): String
|
||||
pub fn checkPermissionsJson(this: &GeolocationPlugin) -> String;
|
||||
|
||||
/// Request permissions with optional types list as JSON, return status as JSON
|
||||
/// Kotlin signature: fun requestPermissionsJson(permissionsJson: String): String
|
||||
pub fn requestPermissionsJson(this: &GeolocationPlugin, permissionsJson: String) -> String;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Stub for non-native platforms (web, Linux desktop, etc.)
|
||||
// =============================================================================
|
||||
#[cfg(not(any(
|
||||
all(any(target_os = "ios", target_os = "macos")),
|
||||
all(target_os = "android")
|
||||
)))]
|
||||
use fallback::*;
|
||||
|
||||
#[cfg(not(any(
|
||||
all(any(target_os = "ios", target_os = "macos")),
|
||||
all(target_os = "android")
|
||||
)))]
|
||||
mod fallback {
|
||||
#![allow(non_snake_case)]
|
||||
use super::{Error, Result};
|
||||
|
||||
pub struct GeolocationPlugin;
|
||||
|
||||
impl GeolocationPlugin {
|
||||
pub fn new() -> Result<Self> {
|
||||
Err(Error::PlatformBridge(
|
||||
"Geolocation is only supported on Android, iOS, and macOS".to_string(),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
pub fn getCurrentPositionJson(_: &GeolocationPlugin, _: String) -> Result<String> {
|
||||
Err(Error::PlatformBridge(
|
||||
"Geolocation is only supported on Android, iOS, and macOS".to_string(),
|
||||
))
|
||||
}
|
||||
|
||||
pub fn checkPermissionsJson(_: &GeolocationPlugin) -> Result<String> {
|
||||
Err(Error::PlatformBridge(
|
||||
"Geolocation is only supported on Android, iOS, and macOS".to_string(),
|
||||
))
|
||||
}
|
||||
|
||||
pub fn requestPermissionsJson(_: &GeolocationPlugin, _: String) -> Result<String> {
|
||||
Err(Error::PlatformBridge(
|
||||
"Geolocation is only supported on Android, iOS, and macOS".to_string(),
|
||||
))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
// Copyright 2019-2023 Tauri Programme within The Commons Conservancy
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Permission state for geolocation permissions
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
#[derive(Default)]
|
||||
pub enum PermissionState {
|
||||
/// Permission not yet determined (user hasn't been asked)
|
||||
#[default]
|
||||
Prompt,
|
||||
|
||||
/// Permission prompt shown with rationale (Android 12+)
|
||||
PromptWithRationale,
|
||||
|
||||
/// Permission granted
|
||||
Granted,
|
||||
|
||||
/// Permission denied
|
||||
Denied,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PermissionStatus {
|
||||
/// Permission state for the location alias.
|
||||
///
|
||||
/// On Android it requests/checks both ACCESS_COARSE_LOCATION and ACCESS_FINE_LOCATION permissions.
|
||||
///
|
||||
/// On iOS it requests/checks location permissions.
|
||||
pub location: PermissionState,
|
||||
|
||||
/// Permissions state for the coarseLocation alias.
|
||||
///
|
||||
/// On Android it requests/checks ACCESS_COARSE_LOCATION.
|
||||
///
|
||||
/// On Android 12+, users can choose between Approximate location (ACCESS_COARSE_LOCATION) and Precise location (ACCESS_FINE_LOCATION).
|
||||
///
|
||||
/// On iOS it will have the same value as the `location` alias.
|
||||
pub coarse_location: PermissionState,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PositionOptions {
|
||||
/// High accuracy mode (such as GPS, if available)
|
||||
/// Will be ignored on Android 12+ if users didn't grant the ACCESS_FINE_LOCATION permission.
|
||||
pub enable_high_accuracy: bool,
|
||||
/// The maximum wait time in milliseconds for location updates.
|
||||
/// Default: 10000
|
||||
/// On Android the timeout gets ignored for getCurrentPosition.
|
||||
/// Ignored on iOS.
|
||||
// TODO: Handle Infinity and default to it.
|
||||
// TODO: Should be u64+ but specta doesn't like that?
|
||||
pub timeout: u32,
|
||||
/// The maximum age in milliseconds of a possible cached position that is acceptable to return.
|
||||
/// Default: 0
|
||||
/// Ignored on iOS.
|
||||
// TODO: Handle Infinity.
|
||||
// TODO: Should be u64+ but specta doesn't like that?
|
||||
pub maximum_age: u32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum PermissionType {
|
||||
Location,
|
||||
CoarseLocation,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Coordinates {
|
||||
/// Latitude in decimal degrees.
|
||||
pub latitude: f64,
|
||||
/// Longitude in decimal degrees.
|
||||
pub longitude: f64,
|
||||
/// Accuracy level of the latitude and longitude coordinates in meters.
|
||||
pub accuracy: f64,
|
||||
/// Accuracy level of the altitude coordinate in meters, if available.
|
||||
/// Available on all iOS versions and on Android 8 and above.
|
||||
pub altitude_accuracy: Option<f64>,
|
||||
/// The altitude the user is at, if available.
|
||||
pub altitude: Option<f64>,
|
||||
// The speed the user is traveling, if available.
|
||||
pub speed: Option<f64>,
|
||||
/// The heading the user is facing, if available.
|
||||
pub heading: Option<f64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Position {
|
||||
/// Creation time for these coordinates.
|
||||
// TODO: Check if we're actually losing precision.
|
||||
pub timestamp: u64,
|
||||
/// The GPS coordinates along with the accuracy of the data.
|
||||
pub coords: Coordinates,
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Live Activity types (iOS 16.1+)
|
||||
// =============================================================================
|
||||
|
||||
/// Result from starting a Live Activity
|
||||
#[cfg(target_os = "ios")]
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct LiveActivityResult {
|
||||
/// Unique identifier for the activity
|
||||
pub activity_id: String,
|
||||
/// Current latitude displayed in the activity
|
||||
pub latitude: f64,
|
||||
/// Current longitude displayed in the activity
|
||||
pub longitude: f64,
|
||||
/// Horizontal accuracy in meters
|
||||
pub accuracy: f64,
|
||||
}
|
||||
|
||||
/// Result from updating a Live Activity
|
||||
#[cfg(target_os = "ios")]
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct LiveActivityUpdate {
|
||||
/// Current latitude after update
|
||||
pub latitude: f64,
|
||||
/// Current longitude after update
|
||||
pub longitude: f64,
|
||||
/// Horizontal accuracy in meters
|
||||
pub accuracy: f64,
|
||||
}
|
||||
Reference in new issue
Block a user