diff options
| author | Claudomator Agent <agent@claudomator> | 2026-03-14 00:50:17 +0000 |
|---|---|---|
| committer | Claudomator Agent <agent@claudomator> | 2026-03-14 00:50:17 +0000 |
| commit | 0923c55af5c63539055933509302233ee3f4b26a (patch) | |
| tree | c0f7e24dff920872e43659f2d0552bd252921744 /android-app/app | |
| parent | 51f86cff118f9532783c4e61724e07173ec029d7 (diff) | |
feat: add LocationPermissionHandler with 7 unit tests for permission flows
Extract location permission decision logic from MainActivity into a
testable LocationPermissionHandler class. Covers: permission already
granted, needs request, fine-only granted, coarse-only granted, both
granted, both denied, and never-ask-again (empty grants) scenarios.
All permissions (INTERNET, ACCESS_FINE_LOCATION, ACCESS_COARSE_LOCATION)
were already declared in AndroidManifest.xml; no manifest changes needed.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Diffstat (limited to 'android-app/app')
3 files changed, 175 insertions, 11 deletions
diff --git a/android-app/app/src/main/kotlin/com/example/androidapp/ui/LocationPermissionHandler.kt b/android-app/app/src/main/kotlin/com/example/androidapp/ui/LocationPermissionHandler.kt new file mode 100644 index 0000000..664d5bb --- /dev/null +++ b/android-app/app/src/main/kotlin/com/example/androidapp/ui/LocationPermissionHandler.kt @@ -0,0 +1,43 @@ +package com.example.androidapp.ui + +/** + * Encapsulates location permission decision logic. + * + * Extracted for testability — no direct Android framework dependency in the core logic. + * + * Permissions handled: ACCESS_FINE_LOCATION, ACCESS_COARSE_LOCATION + * + * Usage: + * - Call [start] on activity start to check existing permission or trigger a request. + * - Call [onResult] from the ActivityResultLauncher callback with the permission grants map. + */ +class LocationPermissionHandler( + /** Returns true if location permission is already granted. */ + private val checkGranted: () -> Boolean, + /** Called when location permission is available (already granted or just granted). */ + private val onGranted: () -> Unit, + /** Called when location permission is denied or the user refuses (including "never ask again"). */ + private val onDenied: () -> Unit, + /** Called when permission needs to be requested from the user via the system dialog. */ + private val requestPermissions: () -> Unit +) { + /** + * Check current permission state and dispatch: + * - If already granted, invoke [onGranted] immediately. + * - Otherwise, invoke [requestPermissions] to trigger the system dialog. + */ + fun start() { + if (checkGranted()) onGranted() else requestPermissions() + } + + /** + * Process the result from the system permission dialog. + * + * @param grants Map of permission name → granted status from ActivityResultLauncher. + * Invokes [onGranted] if any permission was granted, [onDenied] otherwise. + * An empty map (e.g. "never ask again" scenario) also triggers [onDenied]. + */ + fun onResult(grants: Map<String, Boolean>) { + if (grants.values.any { it }) onGranted() else onDenied() + } +} diff --git a/android-app/app/src/main/kotlin/com/example/androidapp/ui/MainActivity.kt b/android-app/app/src/main/kotlin/com/example/androidapp/ui/MainActivity.kt index 0a326f4..17a636f 100644 --- a/android-app/app/src/main/kotlin/com/example/androidapp/ui/MainActivity.kt +++ b/android-app/app/src/main/kotlin/com/example/androidapp/ui/MainActivity.kt @@ -24,11 +24,30 @@ class MainActivity : AppCompatActivity() { private val defaultLat = 37.8 private val defaultLon = -122.4 + private val permissionHandler: LocationPermissionHandler by lazy { + LocationPermissionHandler( + checkGranted = { + ContextCompat.checkSelfPermission( + this, Manifest.permission.ACCESS_FINE_LOCATION + ) == PackageManager.PERMISSION_GRANTED + }, + onGranted = { fetchLocationAndLoad() }, + onDenied = { loadWeatherAtDefault() }, + requestPermissions = { + locationPermissionLauncher.launch( + arrayOf( + Manifest.permission.ACCESS_FINE_LOCATION, + Manifest.permission.ACCESS_COARSE_LOCATION + ) + ) + } + ) + } + private val locationPermissionLauncher = registerForActivityResult( ActivityResultContracts.RequestMultiplePermissions() ) { grants -> - val granted = grants.values.any { it } - if (granted) fetchLocationAndLoad() else loadWeatherAtDefault() + permissionHandler.onResult(grants) } override fun onCreate(savedInstanceState: Bundle?) { @@ -72,15 +91,7 @@ class MainActivity : AppCompatActivity() { } private fun requestLocationOrLoad() { - val fine = Manifest.permission.ACCESS_FINE_LOCATION - val coarse = Manifest.permission.ACCESS_COARSE_LOCATION - val hasPermission = ContextCompat.checkSelfPermission(this, fine) == - PackageManager.PERMISSION_GRANTED - if (hasPermission) { - fetchLocationAndLoad() - } else { - locationPermissionLauncher.launch(arrayOf(fine, coarse)) - } + permissionHandler.start() } private fun fetchLocationAndLoad() { diff --git a/android-app/app/src/test/kotlin/com/example/androidapp/ui/LocationPermissionHandlerTest.kt b/android-app/app/src/test/kotlin/com/example/androidapp/ui/LocationPermissionHandlerTest.kt new file mode 100644 index 0000000..54afc26 --- /dev/null +++ b/android-app/app/src/test/kotlin/com/example/androidapp/ui/LocationPermissionHandlerTest.kt @@ -0,0 +1,110 @@ +package com.example.androidapp.ui + +import org.junit.Assert.* +import org.junit.Test + +class LocationPermissionHandlerTest { + + // Convenience factory — callers override only the lambdas they care about. + private fun makeHandler( + checkGranted: () -> Boolean = { false }, + onGranted: () -> Unit = {}, + onDenied: () -> Unit = {}, + requestPermissions: () -> Unit = {} + ) = LocationPermissionHandler(checkGranted, onGranted, onDenied, requestPermissions) + + // ── start() ────────────────────────────────────────────────────────────── + + @Test + fun `start - permission already granted - calls onGranted without requesting`() { + var onGrantedCalled = false + var requestCalled = false + makeHandler( + checkGranted = { true }, + onGranted = { onGrantedCalled = true }, + requestPermissions = { requestCalled = true } + ).start() + + assertTrue("onGranted should be called", onGrantedCalled) + assertFalse("requestPermissions should NOT be called", requestCalled) + } + + @Test + fun `start - permission not granted - calls requestPermissions without calling onGranted`() { + var onGrantedCalled = false + var requestCalled = false + makeHandler( + checkGranted = { false }, + onGranted = { onGrantedCalled = true }, + requestPermissions = { requestCalled = true } + ).start() + + assertFalse("onGranted should NOT be called", onGrantedCalled) + assertTrue("requestPermissions should be called", requestCalled) + } + + // ── onResult() ─────────────────────────────────────────────────────────── + + @Test + fun `onResult - fine location granted - calls onGranted`() { + var onGrantedCalled = false + makeHandler(onGranted = { onGrantedCalled = true }).onResult( + mapOf( + "android.permission.ACCESS_FINE_LOCATION" to true, + "android.permission.ACCESS_COARSE_LOCATION" to false + ) + ) + assertTrue("onGranted should be called when fine location is granted", onGrantedCalled) + } + + @Test + fun `onResult - coarse location granted - calls onGranted`() { + var onGrantedCalled = false + makeHandler(onGranted = { onGrantedCalled = true }).onResult( + mapOf( + "android.permission.ACCESS_FINE_LOCATION" to false, + "android.permission.ACCESS_COARSE_LOCATION" to true + ) + ) + assertTrue("onGranted should be called when coarse location is granted", onGrantedCalled) + } + + @Test + fun `onResult - both permissions granted - calls onGranted`() { + var onGrantedCalled = false + makeHandler(onGranted = { onGrantedCalled = true }).onResult( + mapOf( + "android.permission.ACCESS_FINE_LOCATION" to true, + "android.permission.ACCESS_COARSE_LOCATION" to true + ) + ) + assertTrue(onGrantedCalled) + } + + @Test + fun `onResult - all permissions denied - calls onDenied not onGranted`() { + var onGrantedCalled = false + var onDeniedCalled = false + makeHandler( + onGranted = { onGrantedCalled = true }, + onDenied = { onDeniedCalled = true } + ).onResult( + mapOf( + "android.permission.ACCESS_FINE_LOCATION" to false, + "android.permission.ACCESS_COARSE_LOCATION" to false + ) + ) + assertFalse("onGranted should NOT be called", onGrantedCalled) + assertTrue("onDenied should be called", onDeniedCalled) + } + + @Test + fun `onResult - empty grants (never ask again scenario) - calls onDenied`() { + var onDeniedCalled = false + makeHandler(onDenied = { onDeniedCalled = true }).onResult(emptyMap()) + assertTrue( + "onDenied should be called for empty grants (never-ask-again)", + onDeniedCalled + ) + } +} |
