diff --git a/examples/geolocation/AndroidManifest.xml b/examples/geolocation/AndroidManifest.xml new file mode 100644 index 0000000..17952d8 --- /dev/null +++ b/examples/geolocation/AndroidManifest.xml @@ -0,0 +1,36 @@ + + + + + + + + + + + + + + + + diff --git a/examples/geolocation/Cargo.toml b/examples/geolocation/Cargo.toml index 19b5377..b7f85db 100644 --- a/examples/geolocation/Cargo.toml +++ b/examples/geolocation/Cargo.toml @@ -5,11 +5,12 @@ edition = "2021" publish = false [dependencies] -dioxus = { workspace = true } +dioxus = "0.7.0-alpha0" dioxus-sdk-geolocation = { workspace = true } [features] default = ["desktop"] web = ["dioxus/web"] desktop = ["dioxus/desktop"] +mobile = ["dioxus/mobile"] diff --git a/examples/geolocation/Dioxus.toml b/examples/geolocation/Dioxus.toml new file mode 100644 index 0000000..462b019 --- /dev/null +++ b/examples/geolocation/Dioxus.toml @@ -0,0 +1,2 @@ +[application] +android_manifest = "./AndroidManifest.xml" \ No newline at end of file diff --git a/packages/geolocation/Cargo.toml b/packages/geolocation/Cargo.toml index 2585c12..a5dfdc0 100644 --- a/packages/geolocation/Cargo.toml +++ b/packages/geolocation/Cargo.toml @@ -34,3 +34,7 @@ windows = { version = "0.48.0", features = [ "Foundation", "Devices_Geolocation", ] } + +[target.'cfg(target_os = "android")'.dependencies] +dioxus = { workspace = true, features = ["mobile"] } +jni = "0.21.1" diff --git a/packages/geolocation/README.md b/packages/geolocation/README.md index e2dd4a7..0149056 100644 --- a/packages/geolocation/README.md +++ b/packages/geolocation/README.md @@ -4,10 +4,10 @@ Geolocation utilities and hooks for Dioxus. ### Supports - [x] Web - [x] Windows +- [x] Android (draft) - [ ] Mac - [ ] Linux -- [ ] Android -- [ ] iOs +- [ ] iOS ## Usage Add `dioxus-sdk-geolocation` to your `Cargo.toml`: @@ -41,3 +41,27 @@ fn App() -> Element { } } ``` + +## Platform Notes + +### Android + +> **Note:** Android support requires `dioxus = "0.7.0-alpha0"` or later. + +The Android implementation provides robust geolocation support via JNI: + +**Features:** +- **Automatic permission handling** - Requests `ACCESS_FINE_LOCATION` (GPS) or `ACCESS_COARSE_LOCATION` (network) based on `PowerMode` +- **Power mode support** - `PowerMode::High` uses GPS provider, `PowerMode::Low` uses network provider +- **Continuous location updates** - Background polling with configurable intervals (1s for High, 5s for Low accuracy) +- **Change detection** - Only emits `NewGeocoordinates` events when position changes (0.00001 degree threshold) +- **Permission monitoring** - Detects permission revocation during active listening +- **Automatic cleanup** - Listener thread stops when `Geolocator` is dropped + +**Required Permissions:** + +Add to your `AndroidManifest.xml`: +```xml + + +``` diff --git a/packages/geolocation/src/core.rs b/packages/geolocation/src/core.rs index e6f6779..cfc26e1 100644 --- a/packages/geolocation/src/core.rs +++ b/packages/geolocation/src/core.rs @@ -13,7 +13,7 @@ pub struct Geocoordinates { } /// To conserve battery, some devices allow setting a desired accuracy based on your use-case. -#[derive(Debug)] +#[derive(Debug, Clone, Copy)] pub enum PowerMode { /// Will generally enable the on-board GPS for precise coordinates. High, diff --git a/packages/geolocation/src/platform/android.rs b/packages/geolocation/src/platform/android.rs new file mode 100644 index 0000000..1dc3945 --- /dev/null +++ b/packages/geolocation/src/platform/android.rs @@ -0,0 +1,512 @@ +use std::sync::Arc; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::thread; +use std::time::Duration; + +use jni::JNIEnv; +use jni::objects::{JObject, JValue}; + +use crate::core::{Error, Event, Geocoordinates, PowerMode, Status}; + +const FINE_LOCATION_PERMISSION: &str = "android.permission.ACCESS_FINE_LOCATION"; +const COARSE_LOCATION_PERMISSION: &str = "android.permission.ACCESS_COARSE_LOCATION"; + +fn permission_for_power_mode(power_mode: PowerMode) -> &'static str { + match power_mode { + PowerMode::High => FINE_LOCATION_PERMISSION, + PowerMode::Low => COARSE_LOCATION_PERMISSION, + } +} + +/// Represents the geolocator for Android. +pub struct Geolocator { + power_mode: PowerMode, + stop_listening: Arc, +} + +impl Geolocator { + /// Create a new Geolocator for the device. + /// This will request location permissions if not already granted and wait for user response. + pub fn new() -> Result { + use std::sync::mpsc::channel; + + let permission = FINE_LOCATION_PERMISSION; + let already_granted = check_location_permission(permission)?; + + if !already_granted { + let (tx, rx) = channel(); + + dioxus::mobile::wry::prelude::dispatch( + move |env: &mut JNIEnv, activity: &JObject, _webview| { + let permission_str = match env.new_string(permission) { + Ok(s) => s, + Err(_) => { + let _ = tx.send(false); + return; + } + }; + let permissions = + match env.new_object_array(1, "java/lang/String", &permission_str) { + Ok(arr) => arr, + Err(_) => { + let _ = tx.send(false); + return; + } + }; + + let result = env.call_method( + activity, + "requestPermissions", + "([Ljava/lang/String;I)V", + &[JValue::Object(&permissions.into()), JValue::Int(1)], + ); + + let _ = tx.send(result.is_ok()); + }, + ); + + rx.recv().map_err(|e| Error::DeviceError(e.to_string()))?; + + // Poll for permission result with timeout + // The permission dialog is shown asynchronously, so we poll until + // the user responds or we timeout (30 seconds) + let poll_interval = Duration::from_millis(250); + let timeout = Duration::from_secs(30); + let start = std::time::Instant::now(); + + loop { + thread::sleep(poll_interval); + + match check_location_permission(permission) { + Ok(true) => break, + Ok(false) if start.elapsed() >= timeout => { + return Err(Error::AccessDenied); + } + Ok(false) => continue, + Err(e) => return Err(e), + } + } + } + + Ok(Self { + power_mode: PowerMode::High, + stop_listening: Arc::new(AtomicBool::new(false)), + }) + } +} + +impl Drop for Geolocator { + fn drop(&mut self) { + self.stop_listening.store(true, Ordering::SeqCst); + } +} + +fn check_location_permission(permission: &'static str) -> Result { + use std::sync::mpsc::channel; + + let (tx, rx) = channel(); + + dioxus::mobile::wry::prelude::dispatch( + move |env: &mut JNIEnv, activity: &JObject, _webview| { + let permission_str = match env.new_string(permission) { + Ok(s) => s, + Err(e) => { + let _ = tx.send(Err(Error::DeviceError(e.to_string()))); + return; + } + }; + + let check_result = env + .call_method( + activity, + "checkSelfPermission", + "(Ljava/lang/String;)I", + &[JValue::Object(&permission_str.into())], + ) + .and_then(|v| v.i()); + + let result = match check_result { + Ok(0) => Ok(true), + Ok(_) => Ok(false), + Err(e) => Err(Error::DeviceError(e.to_string())), + }; + let _ = tx.send(result); + }, + ); + + rx.recv().map_err(|e| Error::DeviceError(e.to_string()))? +} + +pub async fn get_coordinates(geolocator: &Geolocator) -> Result { + use std::sync::mpsc::channel; + + let power_mode = geolocator.power_mode; + let permission = permission_for_power_mode(power_mode); + + if !check_location_permission(permission)? { + return Err(Error::AccessDenied); + } + let (tx, rx) = channel(); + + dioxus::mobile::wry::prelude::dispatch( + move |env: &mut JNIEnv, activity: &JObject, _webview| { + // Get LocationManager + let location_service = match env.new_string("location") { + Ok(s) => s, + Err(e) => { + let _ = tx.send(Err(Error::DeviceError(e.to_string()))); + return; + } + }; + let location_manager = env + .call_method( + activity, + "getSystemService", + "(Ljava/lang/String;)Ljava/lang/Object;", + &[JValue::Object(&location_service.into())], + ) + .and_then(|v| v.l()); + + let location_manager = match location_manager { + Ok(lm) => lm, + Err(e) => { + let _ = tx.send(Err(Error::DeviceError(e.to_string()))); + return; + } + }; + + // Determine provider based on power mode + let provider = match power_mode { + PowerMode::High => "gps", + PowerMode::Low => "network", + }; + let provider_str = match env.new_string(provider) { + Ok(s) => s, + Err(e) => { + let _ = tx.send(Err(Error::DeviceError(e.to_string()))); + return; + } + }; + + // Get last known location + let location = env + .call_method( + &location_manager, + "getLastKnownLocation", + "(Ljava/lang/String;)Landroid/location/Location;", + &[JValue::Object(&provider_str.into())], + ) + .and_then(|v| v.l()); + + match location { + Ok(loc) if !loc.is_null() => { + let latitude = env + .call_method(&loc, "getLatitude", "()D", &[]) + .and_then(|v| v.d()) + .unwrap_or(0.0); + + let longitude = env + .call_method(&loc, "getLongitude", "()D", &[]) + .and_then(|v| v.d()) + .unwrap_or(0.0); + + let _ = tx.send(Ok(Geocoordinates { + latitude, + longitude, + })); + } + Ok(_) => { + let _ = tx.send(Err(Error::DeviceError( + "No last known location available".to_string(), + ))); + } + Err(e) => { + let _ = tx.send(Err(Error::DeviceError(e.to_string()))); + } + } + }, + ); + + rx.recv().map_err(|e| Error::DeviceError(e.to_string()))? +} + +/// This spawns a background thread that continuously polls for location updates. +pub fn listen( + geolocator: &Geolocator, + callback: Arc, +) -> Result<(), Error> { + use std::sync::Mutex; + use std::sync::mpsc::channel; + + let power_mode = geolocator.power_mode; + let permission = permission_for_power_mode(power_mode); + + if !check_location_permission(permission)? { + return Err(Error::AccessDenied); + } + let stop_flag = geolocator.stop_listening.clone(); + + stop_flag.store(false, Ordering::SeqCst); + + let (tx, rx) = channel(); + let callback_init = callback.clone(); + + dioxus::mobile::wry::prelude::dispatch( + move |env: &mut JNIEnv, activity: &JObject, _webview| { + // Get LocationManager + let location_service = match env.new_string("location") { + Ok(s) => s, + Err(e) => { + let _ = tx.send(Err(Error::DeviceError(e.to_string()))); + return; + } + }; + let location_manager = env + .call_method( + activity, + "getSystemService", + "(Ljava/lang/String;)Ljava/lang/Object;", + &[JValue::Object(&location_service.into())], + ) + .and_then(|v| v.l()); + + let location_manager = match location_manager { + Ok(lm) => lm, + Err(e) => { + let _ = tx.send(Err(Error::DeviceError(e.to_string()))); + return; + } + }; + + let provider = match power_mode { + PowerMode::High => "gps", + PowerMode::Low => "network", + }; + let provider_str = match env.new_string(provider) { + Ok(s) => s, + Err(e) => { + let _ = tx.send(Err(Error::DeviceError(e.to_string()))); + return; + } + }; + + let is_enabled = env + .call_method( + &location_manager, + "isProviderEnabled", + "(Ljava/lang/String;)Z", + &[JValue::Object(&provider_str.into())], + ) + .and_then(|v| v.z()) + .unwrap_or(false); + + if is_enabled { + callback_init(Event::StatusChanged(Status::Ready)); + } else { + callback_init(Event::StatusChanged(Status::Disabled)); + } + + // Get initial location + let provider_str = match env.new_string(provider) { + Ok(s) => s, + Err(e) => { + let _ = tx.send(Err(Error::DeviceError(e.to_string()))); + return; + } + }; + let location = env + .call_method( + &location_manager, + "getLastKnownLocation", + "(Ljava/lang/String;)Landroid/location/Location;", + &[JValue::Object(&provider_str.into())], + ) + .and_then(|v| v.l()); + + let initial_coords = if let Ok(loc) = location { + if !loc.is_null() { + let latitude = env + .call_method(&loc, "getLatitude", "()D", &[]) + .and_then(|v| v.d()) + .unwrap_or(0.0); + + let longitude = env + .call_method(&loc, "getLongitude", "()D", &[]) + .and_then(|v| v.d()) + .unwrap_or(0.0); + + Some(Geocoordinates { + latitude, + longitude, + }) + } else { + None + } + } else { + None + }; + + if let Some(coords) = initial_coords.clone() { + callback_init(Event::NewGeocoordinates(coords)); + } + + let _ = tx.send(Ok(initial_coords)); + }, + ); + + let initial_coords = rx.recv().map_err(|e| Error::DeviceError(e.to_string()))??; + + // Spawn a background thread for continuous updates + let last_coords: Arc>> = Arc::new(Mutex::new(initial_coords)); + + thread::spawn(move || { + // Polling interval: 1 second for High accuracy, 5 seconds for Low + let poll_interval = match power_mode { + PowerMode::High => Duration::from_secs(1), + PowerMode::Low => Duration::from_secs(5), + }; + + loop { + if stop_flag.load(Ordering::SeqCst) { + break; + } + + thread::sleep(poll_interval); + + if stop_flag.load(Ordering::SeqCst) { + break; + } + + let (tx, rx) = channel(); + let callback_poll = callback.clone(); + let last_coords_poll = last_coords.clone(); + + let permission_poll = permission_for_power_mode(power_mode); + dioxus::mobile::wry::prelude::dispatch( + move |env: &mut JNIEnv, activity: &JObject, _webview| { + // Check if permission is still granted + let permission_str = match env.new_string(permission_poll) { + Ok(s) => s, + Err(_) => { + let _ = tx.send(true); + return; + } + }; + + let has_permission = env + .call_method( + activity, + "checkSelfPermission", + "(Ljava/lang/String;)I", + &[JValue::Object(&permission_str.into())], + ) + .and_then(|v| v.i()) + .map(|v| v == 0) + .unwrap_or(false); + + if !has_permission { + callback_poll(Event::StatusChanged(Status::NotAvailable)); + let _ = tx.send(false); + return; + } + + let location_service = match env.new_string("location") { + Ok(s) => s, + Err(_) => { + let _ = tx.send(true); + return; + } + }; + let location_manager = env + .call_method( + activity, + "getSystemService", + "(Ljava/lang/String;)Ljava/lang/Object;", + &[JValue::Object(&location_service.into())], + ) + .and_then(|v| v.l()); + + let location_manager = match location_manager { + Ok(lm) => lm, + Err(_) => { + let _ = tx.send(true); + return; + } + }; + + let provider = match power_mode { + PowerMode::High => "gps", + PowerMode::Low => "network", + }; + let provider_str = match env.new_string(provider) { + Ok(s) => s, + Err(_) => { + let _ = tx.send(true); + return; + } + }; + + let location = env + .call_method( + &location_manager, + "getLastKnownLocation", + "(Ljava/lang/String;)Landroid/location/Location;", + &[JValue::Object(&provider_str.into())], + ) + .and_then(|v| v.l()); + + if let Ok(loc) = location { + if !loc.is_null() { + let latitude = env + .call_method(&loc, "getLatitude", "()D", &[]) + .and_then(|v| v.d()) + .unwrap_or(0.0); + + let longitude = env + .call_method(&loc, "getLongitude", "()D", &[]) + .and_then(|v| v.d()) + .unwrap_or(0.0); + + let new_coords = Geocoordinates { + latitude, + longitude, + }; + + let Ok(mut last) = last_coords_poll.lock() else { + let _ = tx.send(true); + return; + }; + let should_notify = match &*last { + Some(prev) => { + (prev.latitude - latitude).abs() > 0.00001 + || (prev.longitude - longitude).abs() > 0.00001 + } + None => true, + }; + + if should_notify { + *last = Some(new_coords.clone()); + callback_poll(Event::NewGeocoordinates(new_coords)); + } + } + } + + let _ = tx.send(true); + }, + ); + + // Wait for the dispatch to complete before next iteration + // If permission was revoked, stop the loop + if let Ok(false) = rx.recv() { + break; + } + } + }); + + Ok(()) +} + +pub fn set_power_mode(geolocator: &mut Geolocator, power_mode: PowerMode) -> Result<(), Error> { + geolocator.power_mode = power_mode; + Ok(()) +} diff --git a/packages/geolocation/src/platform/mod.rs b/packages/geolocation/src/platform/mod.rs index 59a88ef..964c5c3 100644 --- a/packages/geolocation/src/platform/mod.rs +++ b/packages/geolocation/src/platform/mod.rs @@ -8,7 +8,12 @@ mod wasm; #[cfg(target_family = "wasm")] pub use self::wasm::*; -#[cfg(not(any(target_family = "wasm", windows)))] +#[cfg(target_os = "android")] +mod android; +#[cfg(target_os = "android")] +pub use self::android::*; + +#[cfg(not(any(target_family = "wasm", windows, target_os = "android")))] mod unsupported { use std::sync::Arc; @@ -42,5 +47,5 @@ mod unsupported { } } -#[cfg(not(any(target_family = "wasm", windows)))] +#[cfg(not(any(target_family = "wasm", windows, target_os = "android")))] pub use self::unsupported::*;