Skip to content

Commit 8f2bd23

Browse files
committed
Add location-accuracy-test app and GitHub Actions workflow
test-apps/location-accuracy-test: standalone Android app demonstrating the LastLocationCapsule bug via MiniLastLocationCapsule (miniLLC). - Top panel: getLocation(FINE) → lastCoarseLocation [BUG replica] - Bottom panel: getLocation(FINE) → lastFineLocation [FIX replica] - Uses osmdroid (OpenStreetMap) dual-map UI, no API keys required .github/workflows/build-location-test.yml: builds debug APK on push and uploads as downloadable artifact
1 parent 046c00b commit 8f2bd23

13 files changed

Lines changed: 824 additions & 0 deletions

File tree

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
name: Build Location Accuracy Test APK
2+
3+
on:
4+
push:
5+
paths:
6+
- 'test-apps/location-accuracy-test/**'
7+
workflow_dispatch:
8+
9+
jobs:
10+
build:
11+
runs-on: ubuntu-latest
12+
13+
steps:
14+
- name: Checkout
15+
uses: actions/checkout@v4
16+
17+
- name: Set up JDK 17
18+
uses: actions/setup-java@v4
19+
with:
20+
java-version: '17'
21+
distribution: 'temurin'
22+
23+
- name: Build debug APK
24+
working-directory: test-apps/location-accuracy-test
25+
run: ./gradlew assembleDebug --no-daemon
26+
27+
- name: Upload APK
28+
uses: actions/upload-artifact@v4
29+
with:
30+
name: location-accuracy-test-debug
31+
path: test-apps/location-accuracy-test/app/build/outputs/apk/debug/app-debug.apk
32+
retention-days: 7
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
# Gradle
2+
.gradle/
3+
build/
4+
**/build/
5+
6+
# Android Studio
7+
.idea/
8+
*.iml
9+
*.ipr
10+
*.iws
11+
12+
# Local config (may contain SDK paths)
13+
local.properties
14+
15+
# Signing keys - NEVER commit these
16+
*.keystore
17+
*.jks
18+
*.p12
19+
*.pfx
20+
21+
# OS files
22+
.DS_Store
23+
Thumbs.db
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
plugins {
2+
id("com.android.application")
3+
id("org.jetbrains.kotlin.android")
4+
}
5+
6+
android {
7+
namespace = "org.microg.locationtest"
8+
compileSdk = 34
9+
10+
defaultConfig {
11+
applicationId = "org.microg.locationtest"
12+
minSdk = 21
13+
targetSdk = 34
14+
versionCode = 1
15+
versionName = "1.0"
16+
}
17+
18+
buildTypes {
19+
release {
20+
isMinifyEnabled = false
21+
}
22+
}
23+
24+
compileOptions {
25+
sourceCompatibility = JavaVersion.VERSION_1_8
26+
targetCompatibility = JavaVersion.VERSION_1_8
27+
}
28+
29+
kotlinOptions {
30+
jvmTarget = "1.8"
31+
}
32+
}
33+
34+
dependencies {
35+
implementation("androidx.appcompat:appcompat:1.6.1")
36+
implementation("com.google.android.gms:play-services-location:21.3.0")
37+
implementation("org.osmdroid:osmdroid-android:6.1.18")
38+
}
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
<?xml version="1.0" encoding="utf-8"?>
2+
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
3+
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
4+
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
5+
<uses-permission android:name="android.permission.INTERNET" />
6+
7+
<application
8+
android:allowBackup="false"
9+
android:label="Location Test"
10+
android:theme="@style/Theme.AppCompat.Light.NoActionBar">
11+
<activity
12+
android:name=".MainActivity"
13+
android:exported="true">
14+
<intent-filter>
15+
<action android:name="android.intent.action.MAIN" />
16+
<category android:name="android.intent.category.LAUNCHER" />
17+
</intent-filter>
18+
</activity>
19+
</application>
20+
</manifest>
Lines changed: 192 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,192 @@
1+
package org.microg.locationtest
2+
3+
import android.Manifest
4+
import android.content.pm.PackageManager
5+
import android.graphics.Color
6+
import android.location.Location
7+
import android.location.LocationListener
8+
import android.location.LocationManager
9+
import android.os.Bundle
10+
import android.os.Handler
11+
import android.os.Looper
12+
import android.os.SystemClock
13+
import android.widget.TextView
14+
import androidx.appcompat.app.AppCompatActivity
15+
import androidx.core.app.ActivityCompat
16+
import androidx.core.content.ContextCompat
17+
import androidx.core.content.getSystemService
18+
import org.osmdroid.config.Configuration
19+
import org.osmdroid.tileprovider.tilesource.TileSourceFactory
20+
import org.osmdroid.util.GeoPoint
21+
import org.osmdroid.views.MapView
22+
import org.osmdroid.views.overlay.Marker
23+
import org.osmdroid.views.overlay.Polygon
24+
25+
class MainActivity : AppCompatActivity() {
26+
27+
private lateinit var mapBug: MapView
28+
private lateinit var mapFix: MapView
29+
private lateinit var tvBugInfo: TextView
30+
private lateinit var tvFixInfo: TextView
31+
private lateinit var tvStatus: TextView
32+
33+
private lateinit var locationManager: LocationManager
34+
35+
private val miniLLC = MiniLastLocationCapsule()
36+
37+
private val handler = Handler(Looper.getMainLooper())
38+
private val tickRunnable = object : Runnable {
39+
override fun run() {
40+
refreshPanels()
41+
handler.postDelayed(this, 500)
42+
}
43+
}
44+
45+
// GPS → miniLLC.updateFineLocation (mirrors GmsCore)
46+
private val gpsListener = LocationListener { location ->
47+
miniLLC.updateFineLocation(location)
48+
refreshPanels()
49+
}
50+
51+
// Network → miniLLC.updateCoarseLocation only (mirrors GmsCore)
52+
private val networkListener = LocationListener { location ->
53+
miniLLC.updateCoarseLocation(location)
54+
refreshPanels()
55+
}
56+
57+
override fun onCreate(savedInstanceState: Bundle?) {
58+
super.onCreate(savedInstanceState)
59+
Configuration.getInstance().userAgentValue = packageName
60+
setContentView(R.layout.activity_main)
61+
62+
mapBug = findViewById(R.id.mapCoarse)
63+
mapFix = findViewById(R.id.mapFine)
64+
tvBugInfo = findViewById(R.id.tvCoarseInfo)
65+
tvFixInfo = findViewById(R.id.tvFineInfo)
66+
tvStatus = findViewById(R.id.tvStatus)
67+
68+
// Relabel panels
69+
findViewById<TextView>(R.id.labelCoarse).text = "getLocation(FINE) → lastCoarseLocation [BUG]"
70+
findViewById<TextView>(R.id.labelFine).text = "getLocation(FINE) → lastFineLocation [FIX]"
71+
72+
setupMap(mapBug)
73+
setupMap(mapFix)
74+
75+
locationManager = getSystemService()!!
76+
77+
if (ContextCompat.checkSelfPermission(this, Manifest.permission.ACCESS_FINE_LOCATION)
78+
!= PackageManager.PERMISSION_GRANTED
79+
) {
80+
ActivityCompat.requestPermissions(
81+
this, arrayOf(Manifest.permission.ACCESS_FINE_LOCATION), 1
82+
)
83+
} else {
84+
startUpdates()
85+
}
86+
}
87+
88+
private fun setupMap(map: MapView) {
89+
map.setTileSource(TileSourceFactory.MAPNIK)
90+
map.setMultiTouchControls(true)
91+
map.controller.setZoom(15.0)
92+
}
93+
94+
override fun onRequestPermissionsResult(
95+
requestCode: Int, permissions: Array<out String>, grantResults: IntArray
96+
) {
97+
super.onRequestPermissionsResult(requestCode, permissions, grantResults)
98+
if (grantResults.firstOrNull() == PackageManager.PERMISSION_GRANTED) startUpdates()
99+
else tvStatus.text = "Permission denied"
100+
}
101+
102+
@Suppress("MissingPermission")
103+
private fun startUpdates() {
104+
locationManager.requestLocationUpdates(LocationManager.GPS_PROVIDER, 1000L, 0f, gpsListener)
105+
locationManager.requestLocationUpdates(LocationManager.NETWORK_PROVIDER, 1000L, 0f, networkListener)
106+
handler.post(tickRunnable)
107+
}
108+
109+
private fun refreshPanels() {
110+
val bugLocation = miniLLC.getLocationBuggy()
111+
val fixLocation = miniLLC.getLocationFixed()
112+
113+
if (bugLocation != null) {
114+
updateMap(mapBug, bugLocation, "#220066FF", "#990066FF")
115+
updateInfo(tvBugInfo, bugLocation)
116+
}
117+
if (fixLocation != null) {
118+
updateMap(mapFix, fixLocation, "#2200AA00", "#9900AA00")
119+
updateInfo(tvFixInfo, fixLocation)
120+
}
121+
122+
updateStatus(bugLocation, fixLocation)
123+
}
124+
125+
private fun updateMap(map: MapView, loc: Location, fillHex: String, strokeHex: String) {
126+
val center = GeoPoint(loc.latitude, loc.longitude)
127+
map.overlays.clear()
128+
129+
if (loc.accuracy > 0f) {
130+
val circle = Polygon(map).apply {
131+
points = Polygon.pointsAsCircle(center, loc.accuracy.toDouble())
132+
fillColor = Color.parseColor(fillHex)
133+
strokeColor = Color.parseColor(strokeHex)
134+
strokeWidth = 2f
135+
}
136+
map.overlays.add(circle)
137+
}
138+
139+
val marker = Marker(map).apply {
140+
position = center
141+
setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_CENTER)
142+
title = "${loc.provider} ${loc.accuracy.toInt()} m"
143+
}
144+
map.overlays.add(marker)
145+
146+
map.controller.setCenter(center)
147+
if (map.zoomLevelDouble < 13.0) map.controller.setZoom(15.0)
148+
map.invalidate()
149+
}
150+
151+
private fun updateInfo(tv: TextView, loc: Location) {
152+
val ageMs = (SystemClock.elapsedRealtimeNanos() - loc.elapsedRealtimeNanos) / 1_000_000
153+
tv.text = "${String.format("%.6f", loc.latitude)}, ${String.format("%.6f", loc.longitude)}" +
154+
" | ${loc.accuracy.toInt()} m | provider: ${loc.provider} | ${ageMs / 1000.0}s ago"
155+
}
156+
157+
private fun updateStatus(bug: Location?, fix: Location?) {
158+
if (bug == null && fix == null) {
159+
tvStatus.text = "Waiting for location... (go outdoors for GPS)"
160+
tvStatus.setBackgroundColor(Color.parseColor("#212121"))
161+
return
162+
}
163+
164+
val bugAge = bug?.let { (SystemClock.elapsedRealtimeNanos() - it.elapsedRealtimeNanos) / 1_000_000_000.0 }
165+
val fixAge = fix?.let { (SystemClock.elapsedRealtimeNanos() - it.elapsedRealtimeNanos) / 1_000_000_000.0 }
166+
167+
val hasBug = fix != null && bug != null && bug.provider != "gps" && (fix.provider == "gps")
168+
169+
tvStatus.text = buildString {
170+
append("bug: ${bug?.accuracy?.toInt() ?: "?"}m (${bug?.provider}) ")
171+
append("fix: ${fix?.accuracy?.toInt() ?: "?"}m (${fix?.provider})\n")
172+
if (hasBug) {
173+
append("BUG CONFIRMED: fix panel got GPS (${fix!!.accuracy.toInt()}m) but bug panel still shows network")
174+
} else if (fix?.provider == "gps" && bug?.provider == "gps") {
175+
append("Both have GPS fix — bug not visible yet (network fix hasn't arrived)")
176+
} else {
177+
append("%.1fs since bug update | %.1fs since fix update".format(bugAge ?: 0.0, fixAge ?: 0.0))
178+
}
179+
}
180+
tvStatus.setBackgroundColor(if (hasBug) Color.parseColor("#B71C1C") else Color.parseColor("#212121"))
181+
}
182+
183+
override fun onResume() { super.onResume(); mapBug.onResume(); mapFix.onResume() }
184+
override fun onPause() { super.onPause(); mapBug.onPause(); mapFix.onPause() }
185+
186+
override fun onDestroy() {
187+
super.onDestroy()
188+
handler.removeCallbacks(tickRunnable)
189+
locationManager.removeUpdates(gpsListener)
190+
locationManager.removeUpdates(networkListener)
191+
}
192+
}
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
package org.microg.locationtest
2+
3+
import android.location.Location
4+
5+
/**
6+
* MiniLastLocationCapsule (miniLLC)
7+
*
8+
* Mirrors the internal logic of GmsCore's LastLocationCapsule to demonstrate the bug:
9+
*
10+
* updateFineLocation() → updates both lastFineLocation AND lastCoarseLocation
11+
* updateCoarseLocation() → updates lastCoarseLocation ONLY
12+
*
13+
* The bug in GmsCore's getLocation(GRANULARITY_FINE):
14+
* Current (buggy): returns lastCoarseLocation ← network fixes can overwrite this
15+
* Fixed: returns lastFineLocation ← only GPS updates this
16+
*/
17+
class MiniLastLocationCapsule {
18+
19+
var lastFineLocation: Location? = null
20+
private set
21+
22+
var lastCoarseLocation: Location? = null
23+
private set
24+
25+
/** Called when GPS fix arrives (mirrors GmsCore's updateFineLocation) */
26+
fun updateFineLocation(location: Location) {
27+
lastFineLocation = location
28+
updateCoarseLocation(location) // GPS also updates coarse, same as GmsCore
29+
}
30+
31+
/** Called when network fix arrives (mirrors GmsCore's updateCoarseLocation) */
32+
fun updateCoarseLocation(location: Location) {
33+
lastCoarseLocation = location // network only touches coarse
34+
// lastFineLocation is NOT updated here — only GPS can update it
35+
}
36+
37+
/** Buggy path: GRANULARITY_FINE → lastCoarseLocation (what GmsCore does now) */
38+
fun getLocationBuggy(): Location? = lastCoarseLocation
39+
40+
/** Fixed path: GRANULARITY_FINE → lastFineLocation (one-line fix) */
41+
fun getLocationFixed(): Location? = lastFineLocation
42+
}

0 commit comments

Comments
 (0)