using UnityEngine; using GeoSus.Client; using System; using System.Collections; namespace Subsystems { internal class CoroutineHost : MonoBehaviour { public CoroutineHost() { } } internal enum GPSState { Uninitialized, Initializing, Running, Failed } /// /// Position source backend. Selectable at runtime via the GPS overlay /// "Source" button so the user can recover when one path misbehaves on /// their phone: /// Auto - JNI: subscribe to gps + network, pick most recent fix. /// GpsOnly - JNI: subscribe to gps only (network's frequent indoor /// fixes don't drown out the slower-but-precise gps fix). /// NetworkOnly - JNI: subscribe to network only (cell tower / WiFi). /// Useful indoors when no satellite lock is possible. /// UnityInput - Unity's Input.location wrapper. Verified to hang on /// Mi 9T / A20e (which is why JNI exists), but works on /// newer Android where the JNI streaming-callbacks path /// silently doesn't fire (MIUI/HyperOS battery saver, /// approximate-vs-precise permission split, minDistance /// gating on stationary phones). /// EditorWasd - WASD-driven simulated position. Available regardless /// of testMode flag so desktop builds and editor sessions /// can navigate the map without real GPS. /// public enum PositionSource { Auto, GpsOnly, NetworkOnly, UnityInput, EditorWasd, } #if UNITY_ANDROID && !UNITY_EDITOR /// /// Bridges android.location.LocationListener to managed code. The method /// names here must match Java's LocationListener interface exactly so /// AndroidJavaProxy's reflection dispatcher can find them. /// internal class AndroidLocationProxy : AndroidJavaProxy { public AndroidLocationProvider Owner { get; set; } public AndroidLocationProxy() : base("android.location.LocationListener") { } // Called by Android each time a new fix arrives from the registered provider. public void onLocationChanged(AndroidJavaObject location) { try { if (location == null) return; double lat = location.Call("getLatitude"); double lon = location.Call("getLongitude"); long t = location.Call("getTime"); string provider = ""; try { provider = location.Call("getProvider"); } catch { } // Streaming callbacks are LIVE (never cached). The cached path // calls UpdateLocation directly with isCached=true. Owner?.UpdateLocation(lat, lon, t, provider, isCached: false); } catch (Exception ex) { Debug.LogWarning("[GPS-JNI] onLocationChanged failed: " + ex.Message); } } // Required by the LocationListener interface even if we don't use them. // Missing methods cause java.lang.AbstractMethodError at runtime. public void onStatusChanged(string provider, int status, AndroidJavaObject extras) { } public void onProviderEnabled(string provider) { } public void onProviderDisabled(string provider) { } } /// /// Direct wrapper around android.location.LocationManager via JNI, used as /// a replacement for Unity's Input.location on Android when the user picks /// Auto/GpsOnly/NetworkOnly. Subscribed providers are configurable so the /// position-source picker can rewire live without restart. /// internal class AndroidLocationProvider { private AndroidJavaObject _activity; private AndroidJavaObject _locationManager; private AndroidLocationProxy _gpsListener; private AndroidLocationProxy _networkListener; private double _lat, _lon; private long _lastTimeMillis; private long _lastLiveTimeMillis; // Time of most recent NON-cached fix. private bool _hasFix; private bool _hasLiveFix; // True once any streaming callback fired. private string _activeProvider = ""; // Captured at Initialize() so the diagnostic can report // "GPS provider DISABLED, only network enabled" etc. private bool _gpsProviderEnabled; private bool _networkProviderEnabled; private bool _gpsLastKnownExists; private bool _networkLastKnownExists; private string _enabledProvidersList = ""; // Subscription scope - set in Initialize, used in Shutdown to know // which listeners we registered. private bool _subscribedGps; private bool _subscribedNetwork; public bool HasFix => _hasFix; public bool HasLiveFix => _hasLiveFix; public long LastLiveTimeMillis => _lastLiveTimeMillis; public long LastTimeMillis => _lastTimeMillis; public double Lat => _lat; public double Lon => _lon; public string ActiveProvider => _activeProvider; public bool GpsProviderEnabled => _gpsProviderEnabled; public bool NetworkProviderEnabled => _networkProviderEnabled; public bool GpsLastKnownExists => _gpsLastKnownExists; public bool NetworkLastKnownExists => _networkLastKnownExists; public string EnabledProvidersList => _enabledProvidersList; public bool SubscribedGps => _subscribedGps; public bool SubscribedNetwork => _subscribedNetwork; public bool Initialize(out string error, bool useGps, bool useNetwork) { error = ""; try { using (var unityPlayer = new AndroidJavaClass("com.unity3d.player.UnityPlayer")) { _activity = unityPlayer.GetStatic("currentActivity"); } if (_activity == null) { error = "no current activity"; return false; } _locationManager = _activity.Call("getSystemService", "location"); if (_locationManager == null) { error = "getSystemService(\"location\") returned null"; return false; } // Capture provider enable state up front so the diagnostic // can distinguish "provider disabled at OS level" from // "provider enabled but produced no fix yet". _gpsProviderEnabled = SafeIsProviderEnabled("gps"); _networkProviderEnabled = SafeIsProviderEnabled("network"); _enabledProvidersList = SafeGetEnabledProviders(); Debug.Log($"[GPS-JNI] init useGps={useGps} useNetwork={useNetwork} gps enabled={_gpsProviderEnabled} network enabled={_networkProviderEnabled} all enabled=[{_enabledProvidersList}]"); // Try cached last-known fixes from the providers we're about // to subscribe to. If the OS already knows where we are // (e.g. from another app that recently used GPS), we get a // fix at zero cost and zero wait time. Tagged isCached so // the diagnostic can mark them and we know we still need // to wait for a streaming callback. if (useNetwork) TryLastKnown("network", out _networkLastKnownExists); if (useGps) TryLastKnown("gps", out _gpsLastKnownExists); _subscribedGps = useGps; _subscribedNetwork = useNetwork; if (useGps) _gpsListener = new AndroidLocationProxy { Owner = this }; if (useNetwork) _networkListener = new AndroidLocationProxy { Owner = this }; // requestLocationUpdates must be called on a thread with a // Looper. Use the Activity's UI thread, which always has one. // minTime=1000ms, minDistance=0f - we want updates on every // fix the OS produces. Previously this was 1f which gated // out updates from a stationary phone (MIUI/newer Android // are stricter about this and that's the suspected cause of // "via gps (cached)" sticking forever). _activity.Call("runOnUiThread", new AndroidJavaRunnable(() => { if (useGps) { try { _locationManager.Call("requestLocationUpdates", "gps", 1000L, 0f, _gpsListener); } catch (Exception ex) { Debug.LogWarning("[GPS-JNI] gps subscribe failed: " + ex.Message); } } if (useNetwork) { try { _locationManager.Call("requestLocationUpdates", "network", 1000L, 0f, _networkListener); } catch (Exception ex) { Debug.LogWarning("[GPS-JNI] network subscribe failed: " + ex.Message); } } })); return true; } catch (Exception ex) { error = "JNI init exception: " + ex.Message; return false; } } void TryLastKnown(string provider, out bool nonNullReturned) { nonNullReturned = false; try { var loc = _locationManager.Call("getLastKnownLocation", provider); if (loc != null) { nonNullReturned = true; double lat = loc.Call("getLatitude"); double lon = loc.Call("getLongitude"); long t = loc.Call("getTime"); UpdateLocation(lat, lon, t, provider, isCached: true); } } catch (Exception ex) { Debug.LogWarning($"[GPS-JNI] getLastKnownLocation({provider}) failed: " + ex.Message); } } bool SafeIsProviderEnabled(string provider) { try { return _locationManager.Call("isProviderEnabled", provider); } catch (Exception ex) { Debug.LogWarning($"[GPS-JNI] isProviderEnabled({provider}) failed: " + ex.Message); return false; } } // Build a comma-separated list of currently-enabled providers via // LocationManager.getProviders(true). We iterate the returned // java.util.List by index because AndroidJavaObject does not // implement IEnumerable. string SafeGetEnabledProviders() { try { var list = _locationManager.Call("getProviders", true); if (list == null) return ""; int size = list.Call("size"); var parts = new System.Text.StringBuilder(); for (int i = 0; i < size; i++) { var name = list.Call("get", i); if (i > 0) parts.Append(","); parts.Append(name); } return parts.ToString(); } catch (Exception ex) { Debug.LogWarning("[GPS-JNI] getProviders failed: " + ex.Message); return ""; } } public void UpdateLocation(double lat, double lon, long timeMillis, string provider, bool isCached) { // Ignore older fixes if a newer one is already in hand. This lets // both gps + network listeners feed us without ping-ponging // between stale and fresh data. if (timeMillis < _lastTimeMillis) return; _lat = lat; _lon = lon; _lastTimeMillis = timeMillis; // Active-provider name carries cached/live state in the diagnostic // banner so the user can see at a glance whether streaming has // kicked in or we're still on the initial cached snapshot. _activeProvider = (provider ?? "") + (isCached ? " (cached)" : ""); _hasFix = true; if (!isCached) { _hasLiveFix = true; _lastLiveTimeMillis = timeMillis; } } public void Shutdown() { try { if (_locationManager != null) { if (_gpsListener != null) _locationManager.Call("removeUpdates", _gpsListener); if (_networkListener != null) _locationManager.Call("removeUpdates", _networkListener); } } catch (Exception ex) { Debug.LogWarning("[GPS-JNI] Shutdown failed: " + ex.Message); } _gpsListener = null; _networkListener = null; _locationManager = null; _activity = null; } } #endif public static class PositonExtensions { public static Position ToLocal(this Position position, Position center) { double latDiff = position.Lat - center.Lat; double lonDiff = position.Lon - center.Lon; double metersPerDegreeLat = 111320.0; double metersPerDegreeLon = 111320.0 * Math.Cos(center.Lat * Math.PI / 180.0); float x = (float)(lonDiff * metersPerDegreeLon); float z = (float)(latDiff * metersPerDegreeLat); return new Position(z, x); } public static Vector3 ToLocalVector3(this Position position, Position center) { return position.ToLocal(center).ToVector3(); //TODO: Implementace v subsystemech } public static Vector3 ToVector3(this Position position) { return new Vector3((float)position.Lon, 0, (float)position.Lat); //TODO: Implementace v subsystemech } public static double DistanceTo(this Vector3 pos, Vector3 other) { return Math.Sqrt((other.x - pos.x) * (other.x - pos.x) + (other.z - pos.z) * (other.z - pos.z)); } } public class GameManager_Input { private GameClient _gameClient; private Position _currentPosition; private Position _lastSentPosition; private GameObject _player; private bool _testMode; // PlayerPrefs key for the user's chosen position source. Persists // across app restarts so a user who flipped to UnityInput because // their phone hated the JNI path doesn't have to flip again every // launch. private const string PrefsSourceKey = "PositionSource_v1"; private PositionSource _currentSource = PositionSource.Auto; // When the multi-client editor test mode picks a non-host bot as // active, we need the host's WASD path to NOT also move. Set true // by GameManager when active slot != 0. public bool SuppressWasd = false; private GPSState _GPSState = GPSState.Uninitialized; private float _speed = 0.00001f; private Position _mapCenter; private CoroutineHost _coroutineHost; private int _gpsRetryCount = 0; private const int _maxGpsRetries = 5; private float _lastPositionSendTime; private const float _positionKeepAliveSeconds = 1.0f; // Diagnostic state. We capture *why* GPS init failed so the UI can // surface it to the user without requiring logcat. Older Android // phones (Mi 9T, A20e) hit silent failure modes that are impossible // to distinguish from "still warming up" without this. private string _lastGpsError = ""; private float _gpsInitStartTime = -1f; // Bump from the original 20s. Cold-start GPS on older Android can // easily exceed 20s indoors or under cloud cover - by the time the // user notices nothing is happening, we've already given up. private const int _gpsInitTimeoutSeconds = 60; #if UNITY_ANDROID && !UNITY_EDITOR // JNI-backed location provider, used for Auto/GpsOnly/NetworkOnly. // UnityInput uses Input.location instead and leaves this null. private AndroidLocationProvider _androidProvider; #endif /// Last known GPS position (for CreateLobby centre point) public Position? LastKnownPosition => _currentPosition.Lat != 0 || _currentPosition.Lon != 0 ? _currentPosition : (Position?)null; /// Current GPS state machine value (debug/diagnostic). public string GpsStateName => _GPSState.ToString(); /// Last GPS error reason captured during init (empty if none). public string LastGpsError => _lastGpsError ?? ""; /// Retry count out of max (debug/diagnostic). public string GpsRetryProgress => $"{_gpsRetryCount}/{_maxGpsRetries}"; /// Currently selected position source (for UI cycle button). public PositionSource CurrentSource => _currentSource; /// Display name for the current source (for UI label). public string CurrentSourceName { get { switch (_currentSource) { case PositionSource.Auto: return "Auto (GPS+Net)"; case PositionSource.GpsOnly: return "GPS only"; case PositionSource.NetworkOnly: return "Network only"; case PositionSource.UnityInput: return "Unity Input"; case PositionSource.EditorWasd: return "WASD"; default: return _currentSource.ToString(); } } } /// /// Human-readable one-line GPS status for on-screen overlay. Designed /// to be visible without ADB so users can self-diagnose permission /// vs. timeout vs. device-disabled vs. running-but-no-fix-yet. /// public string GpsDiagnostic { get { if (_currentSource == PositionSource.EditorWasd) { if (_currentPosition.Lat == 0 && _currentPosition.Lon == 0) return "WASD: waiting for map center"; return $"WASD lat={_currentPosition.Lat:F5} lon={_currentPosition.Lon:F5}"; } switch (_GPSState) { case GPSState.Uninitialized: return "Uninitialized (will start on first lobby action)"; case GPSState.Initializing: { float elapsed = _gpsInitStartTime >= 0 ? Time.time - _gpsInitStartTime : 0; string providers = ""; #if UNITY_ANDROID && !UNITY_EDITOR if (_androidProvider != null && !string.IsNullOrEmpty(_androidProvider.EnabledProvidersList)) providers = $" providers=[{_androidProvider.EnabledProvidersList}]"; #endif return $"Initializing ({elapsed:F1}s / max {_gpsInitTimeoutSeconds}s){providers}"; } case GPSState.Running: { string suffix = ""; #if UNITY_ANDROID && !UNITY_EDITOR if (_androidProvider != null) { string p = _androidProvider.ActiveProvider; if (!string.IsNullOrEmpty(p)) suffix = " via " + p; // Show how stale the most recent fix is (ms-level // resolution) so "stuck on cached" is obvious at // a glance: "via gps (cached) [no live, 47s old]". if (!_androidProvider.HasLiveFix) { long now = (long)(DateTime.UtcNow - new DateTime(1970, 1, 1, 0, 0, 0, DateTimeKind.Utc)).TotalMilliseconds; long ageMs = now - _androidProvider.LastTimeMillis; if (_androidProvider.LastTimeMillis > 0 && ageMs > 0) suffix += $" [no live, {ageMs / 1000}s old]"; else suffix += " [no live]"; } else { long now = (long)(DateTime.UtcNow - new DateTime(1970, 1, 1, 0, 0, 0, DateTimeKind.Utc)).TotalMilliseconds; long ageMs = now - _androidProvider.LastLiveTimeMillis; if (ageMs > 5000) suffix += $" [live {ageMs / 1000}s old]"; } } #endif if (_currentPosition.Lat == 0 && _currentPosition.Lon == 0) return "Running but no fix yet (waiting for satellites)" + suffix; return $"Running lat={_currentPosition.Lat:F5} lon={_currentPosition.Lon:F5}" + suffix; } case GPSState.Failed: return $"Failed: {(_lastGpsError ?? "unknown")} (retries {GpsRetryProgress})"; default: return "?"; } } } public GameManager_Input(GameClient gameClient, GameObject player, bool testMode) { _gameClient = gameClient; _player = player; _testMode = testMode; // CoroutineHost needs a MonoBehaviour on a real GameObject var hostGO = new UnityEngine.GameObject("_CoroutineHost"); UnityEngine.Object.DontDestroyOnLoad(hostGO); _coroutineHost = hostGO.AddComponent(); // Restore the user's last picked source. Default depends on // platform: editor defaults to EditorWasd (no GPS hardware in // editor anyway); device defaults to Auto. string saved = PlayerPrefs.GetString(PrefsSourceKey, ""); if (!string.IsNullOrEmpty(saved) && Enum.TryParse(saved, out PositionSource parsed)) { _currentSource = parsed; } else { #if UNITY_EDITOR _currentSource = PositionSource.EditorWasd; #else _currentSource = PositionSource.Auto; #endif } // Legacy testMode flag forces EditorWasd. New code paths should // use SwitchPositionSource(EditorWasd) instead, but we keep the // old behavior for backward compatibility with the inspector flag. if (_testMode) _currentSource = PositionSource.EditorWasd; } /// Called from OnSceneLoaded when Client.unity loads so the /// Player capsule (which lives in Client.unity) can be wired at runtime. public void SetPlayerObject(GameObject player) { _player = player; } /// /// Switch the active position source backend live. Tears down the /// current backend's listeners (JNI proxies, Input.location), resets /// the state machine, and kicks off init for the new source. Persists /// the choice to PlayerPrefs. /// public void SwitchPositionSource(PositionSource newSource) { if (_currentSource == newSource) return; Debug.Log($"[GPS] SwitchPositionSource {_currentSource} -> {newSource}"); // Tear down whatever's running. ShutdownCurrentBackend(); _currentSource = newSource; PlayerPrefs.SetString(PrefsSourceKey, newSource.ToString()); PlayerPrefs.Save(); _GPSState = GPSState.Uninitialized; _gpsRetryCount = 0; _lastGpsError = ""; _gpsInitStartTime = -1f; // Don't clear _currentPosition - the user has presumably been // playing somewhere. Map markers/avatar position can stay until // the next fix arrives from the new source. EnsureGPSStarted(); } /// Cycle through the available sources for tap-to-cycle UI. public void CycleNextPositionSource() { var values = (PositionSource[])Enum.GetValues(typeof(PositionSource)); int idx = Array.IndexOf(values, _currentSource); var next = values[(idx + 1) % values.Length]; SwitchPositionSource(next); } private void ShutdownCurrentBackend() { #if UNITY_ANDROID && !UNITY_EDITOR if (_androidProvider != null) { _androidProvider.Shutdown(); _androidProvider = null; } #endif // Stop Unity Input.location too, in case it was running. try { Input.location.Stop(); } catch { } } /// /// Kick off GPS initialization if it hasn't started yet. Safe to call /// repeatedly. Hosts must call this from the lobby setup screen so /// that by the time they click "Create Lobby" we have a real GPS /// fix to use as the play-area center, instead of falling back to /// the hardcoded coordinates. /// public void EnsureGPSStarted() { if (_currentSource == PositionSource.EditorWasd) return; if (_coroutineHost == null) return; // Allow tapping "Create Lobby" again (or any caller of this // method) to retry from Failed up to _maxGpsRetries times. if (_GPSState == GPSState.Uninitialized) { _coroutineHost.StartCoroutine(InitiallizeGPS()); } else if (_GPSState == GPSState.Failed && _gpsRetryCount < _maxGpsRetries) { _gpsRetryCount++; _coroutineHost.StartCoroutine(InitiallizeGPS()); } } public void positionCheck() { var state = _gameClient?.CurrentLobbyState; if (state == null || state.Phase != GamePhase.Playing) return; try { if (_currentSource == PositionSource.EditorWasd) { if (_currentPosition == new Position(0, 0)) { if (state.MapData == null) return; //Init blok _currentPosition = state.MapData.Center; _mapCenter = state.MapData.Center; _lastSentPosition = _currentPosition; } if (!SuppressWasd) TestPlayerPosition(); else TrySendCurrentPosition(); // keep-alive only } else { if (_GPSState == GPSState.Uninitialized) { _coroutineHost.StartCoroutine(InitiallizeGPS()); return; } else if (_GPSState == GPSState.Initializing) { return; } else if (_GPSState == GPSState.Running) { EnsureMapCenter(); TrySendCurrentPosition(); } else { Debug.Log("GPS failed, trying again..."); if (_gpsRetryCount < _maxGpsRetries) { _gpsRetryCount++; _GPSState = GPSState.Uninitialized; } else { Debug.LogWarning("GPS unavailable after max retries. Using last known position."); // Keep _GPSState = Failed so we stop retrying } } } } catch (Exception ex) { Debug.LogWarning($"[Input] positionCheck failed: {ex.Message}"); } } private void EnsureMapCenter() { if (_mapCenter.Lat != 0 || _mapCenter.Lon != 0) return; var md = _gameClient?.CurrentLobbyState?.MapData; if (md != null) _mapCenter = md.Center; } private void TrySendCurrentPosition() { bool moved = _currentPosition != _lastSentPosition; bool keepAliveDue = (Time.time - _lastPositionSendTime) >= _positionKeepAliveSeconds; if (!moved && !keepAliveDue) return; var previous = _lastSentPosition; _gameClient.UpdatePosition(_currentPosition); _lastSentPosition = _currentPosition; _lastPositionSendTime = Time.time; if (_player == null || (_mapCenter.Lat == 0 && _mapCenter.Lon == 0)) return; var localCurrent = _currentPosition.ToLocalVector3(_mapCenter); _player.transform.position = localCurrent; if (previous.Lat == 0 && previous.Lon == 0) return; var heading = CalculateHeading(previous.ToLocalVector3(_mapCenter), localCurrent); if (heading.HasValue) _player.transform.rotation = Quaternion.Euler(0, (float)heading.Value, 0); } private void TestPlayerPosition() { double x = Input.GetAxis("Horizontal"); double y = Input.GetAxis("Vertical"); _currentPosition = new Position( _lastSentPosition.Lat + y * _speed, _lastSentPosition.Lon + x * _speed); var localCurrent = _currentPosition.ToLocalVector3(_mapCenter); var heading = CalculateHeading(_lastSentPosition.ToLocalVector3(_mapCenter), localCurrent); if (heading != null) { if (_player != null) _player.transform.rotation = Quaternion.Euler(0, (float)heading, 0); } if (_player != null) _player.transform.position = localCurrent; try { TrySendCurrentPosition(); } catch { _gameClient.UpdatePosition(_currentPosition); _lastSentPosition = _currentPosition; } } private double? CalculateHeading(Vector3 first, Vector3 second) { if ((first - second).magnitude < 0.0001f) return null; float dx = second.x - first.x; float dz = second.z - first.z; float heading = Mathf.Atan2(dx, dz) * Mathf.Rad2Deg; if (heading < 0) heading += 360f; return heading; } IEnumerator InitiallizeGPS() { _GPSState = GPSState.Initializing; _gpsInitStartTime = Time.time; _lastGpsError = ""; #if UNITY_ANDROID // Request fine location permission if not already granted. // On Android 12+ a "precise" toggle exists separately from coarse, // but Unity's FineLocation request covers both for our purposes. if (!UnityEngine.Android.Permission.HasUserAuthorizedPermission(UnityEngine.Android.Permission.FineLocation)) { UnityEngine.Android.Permission.RequestUserPermission(UnityEngine.Android.Permission.FineLocation); // Wait up to 10 seconds for user to respond to the permission dialog float waited = 0f; while (!UnityEngine.Android.Permission.HasUserAuthorizedPermission(UnityEngine.Android.Permission.FineLocation) && waited < 10f) { yield return new WaitForSeconds(0.5f); waited += 0.5f; } if (!UnityEngine.Android.Permission.HasUserAuthorizedPermission(UnityEngine.Android.Permission.FineLocation)) { _lastGpsError = "Permission denied (fine location)"; Debug.LogError("[GPS] " + _lastGpsError); _GPSState = GPSState.Failed; yield break; } } #endif #if UNITY_ANDROID && !UNITY_EDITOR // Choose subscription scope based on selected source. UnityInput // skips JNI entirely and falls through to the Input.location path // below (the same path iOS / editor use). if (_currentSource == PositionSource.Auto || _currentSource == PositionSource.GpsOnly || _currentSource == PositionSource.NetworkOnly) { bool useGps = (_currentSource != PositionSource.NetworkOnly); bool useNetwork = (_currentSource != PositionSource.GpsOnly); if (_androidProvider != null) { _androidProvider.Shutdown(); _androidProvider = null; } _androidProvider = new AndroidLocationProvider(); if (!_androidProvider.Initialize(out var initError, useGps, useNetwork)) { _lastGpsError = "Native LocationManager failed: " + initError; Debug.LogError("[GPS] " + _lastGpsError); _androidProvider = null; _GPSState = GPSState.Failed; yield break; } // Fast-fail if neither subscribed provider is enabled at OS // level. Waiting 60s for fixes from disabled providers is // pointless - tell the user immediately what's wrong. bool anyUsableEnabled = (useGps && _androidProvider.GpsProviderEnabled) || (useNetwork && _androidProvider.NetworkProviderEnabled); if (!anyUsableEnabled) { string which = useGps && useNetwork ? "gps + network" : useGps ? "gps" : "network"; _lastGpsError = $"{which} provider DISABLED at OS level. Open Settings > Location and switch it ON. Or tap [Source] to try a different backend."; Debug.LogError("[GPS] " + _lastGpsError); _androidProvider.Shutdown(); _androidProvider = null; _GPSState = GPSState.Failed; yield break; } // Wait for the first fix (cached or live). int maxWaitJni = _gpsInitTimeoutSeconds; while (!_androidProvider.HasFix && maxWaitJni > 0) { yield return new WaitForSeconds(1); maxWaitJni--; } if (!_androidProvider.HasFix) { string enabled = _androidProvider.EnabledProvidersList ?? ""; string gpsState = _androidProvider.GpsProviderEnabled ? "ON" : "OFF"; string netState = _androidProvider.NetworkProviderEnabled ? "ON" : "OFF"; string lastKnown = $"lastKnown[gps={(_androidProvider.GpsLastKnownExists ? "yes" : "no")}, net={(_androidProvider.NetworkLastKnownExists ? "yes" : "no")}]"; _lastGpsError = $"Timeout {_gpsInitTimeoutSeconds}s on {_currentSource}. enabled=[{enabled}] gps={gpsState} net={netState} {lastKnown}. Try [Source] cycle to switch backends."; Debug.LogError("[GPS] " + _lastGpsError); _androidProvider.Shutdown(); _androidProvider = null; _GPSState = GPSState.Failed; yield break; } _currentPosition = new Position(_androidProvider.Lat, _androidProvider.Lon); _GPSState = GPSState.Running; _gpsRetryCount = 0; _coroutineHost.StartCoroutine(AndroidGPSService()); yield break; } // _currentSource == UnityInput on Android: fall through to the // Input.location path below. This is the recovery path for // newer Android phones where JNI's streaming-callbacks don't // fire (MIUI/HyperOS background restrictions, approximate-vs- // precise permission, minDistance gating on stationary phones). #endif // iOS / editor / non-Android / Android-with-UnityInput-source: // use Unity's Input.location. if (!Input.location.isEnabledByUser) { _lastGpsError = "Location services not enabled by user"; Debug.LogError("[GPS] " + _lastGpsError); _GPSState = GPSState.Failed; yield break; } float desiredAccuracyInMeters = 5f; float updateDistanceInMeters = 1f; Input.location.Start(desiredAccuracyInMeters, updateDistanceInMeters); int maxWait = _gpsInitTimeoutSeconds; while (Input.location.status == LocationServiceStatus.Initializing && maxWait > 0) { yield return new WaitForSeconds(1); maxWait--; } if (maxWait < 1) { _lastGpsError = $"Timed out after {_gpsInitTimeoutSeconds}s waiting for first fix (try moving outdoors, or tap [Source] to try a different backend)"; Debug.LogError("[GPS] " + _lastGpsError); _GPSState = GPSState.Failed; yield break; } if (Input.location.status == LocationServiceStatus.Failed) { _lastGpsError = "Unity Input.location reported Failed status"; Debug.LogError("[GPS] " + _lastGpsError); _GPSState = GPSState.Failed; yield break; } _GPSState = GPSState.Running; _gpsRetryCount = 0; _coroutineHost.StartCoroutine(GPSService()); } #if UNITY_ANDROID && !UNITY_EDITOR /// /// Mirrors the JNI provider's most recent fix into _currentPosition /// every 0.5s so the rest of the game (which polls _currentPosition /// indirectly via LastKnownPosition / TrySendCurrentPosition) keeps /// working unchanged. Replaces GPSService on Android. /// IEnumerator AndroidGPSService() { while (_GPSState == GPSState.Running && _androidProvider != null) { if (_androidProvider.HasFix) { _currentPosition = new Position(_androidProvider.Lat, _androidProvider.Lon); } yield return new WaitForSeconds(0.5f); } // Loop ended (state != Running or provider disposed). Clean up // listeners so we don't leak across retries. if (_androidProvider != null) { _androidProvider.Shutdown(); _androidProvider = null; } } #endif IEnumerator GPSService() { while (_GPSState == GPSState.Running) { if (Input.location.status == LocationServiceStatus.Failed) { _lastGpsError = "Location service died after init (provider stopped)"; Debug.LogError("[GPS] " + _lastGpsError); _GPSState = GPSState.Failed; yield break; } // Keep current GPS position fresh; sending is throttled in positionCheck(). var data = Input.location.lastData; _currentPosition = new Position(data.latitude, data.longitude); yield return new WaitForSeconds(0.5f); } } } }