diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 067be77..14f8ac1 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -1,6 +1,7 @@ + diff --git a/app/src/main/java/dev/intent/app/MainActivity.kt b/app/src/main/java/dev/intent/app/MainActivity.kt index 0813f50..0d31e87 100644 --- a/app/src/main/java/dev/intent/app/MainActivity.kt +++ b/app/src/main/java/dev/intent/app/MainActivity.kt @@ -11,6 +11,7 @@ import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.material3.Scaffold +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.SideEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue @@ -22,6 +23,7 @@ import com.journeyapps.barcodescanner.ScanContract import com.journeyapps.barcodescanner.ScanOptions import dev.intent.app.connection.ConnectionRoot import dev.intent.app.connection.ConnectionViewModel +import dev.intent.app.connection.LocalNetworkPermissionGate import dev.intent.app.connection.PairingScannerActivity import dev.intent.app.design.IntentTheme import dev.intent.app.settings.AppPreferences @@ -31,6 +33,7 @@ class MainActivity : ComponentActivity() { private val model by lazy { ViewModelProvider(this)[ConnectionViewModel::class.java] } private var notificationWorkspace by mutableStateOf?>(null) private val preferences by lazy { AppPreferences(applicationContext) } + private var pendingIntent by mutableStateOf(null) private val scanner = registerForActivityResult(ScanContract()) { result -> result.contents?.let(model.controller::prepareLink) } @@ -38,7 +41,7 @@ class MainActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) enableEdgeToEdge() - if (savedInstanceState == null) acceptLink(intent) + if (savedInstanceState == null) pendingIntent = intent setContent { val appearance by preferences.appearance.collectAsState() val darkTheme = when (appearance) { @@ -52,21 +55,27 @@ class MainActivity : ComponentActivity() { } IntentTheme(darkTheme = darkTheme) { Scaffold { padding -> - ConnectionRoot( - model.controller, - preferences = preferences, - notificationWorkspace = notificationWorkspace, - onScan = { - scanner.launch( - ScanOptions().setDesiredBarcodeFormats(ScanOptions.QR_CODE) - .setCaptureActivity(PairingScannerActivity::class.java) - .setOrientationLocked(false) - .setPrompt("Scan the Intent pairing code") - .setBeepEnabled(false).setBarcodeImageEnabled(false) - ) - }, - modifier = Modifier.fillMaxSize().padding(padding) - ) + LocalNetworkPermissionGate(Modifier.fillMaxSize().padding(padding)) { + LaunchedEffect(pendingIntent) { + pendingIntent?.let(::acceptLink) + pendingIntent = null + } + ConnectionRoot( + model.controller, + preferences = preferences, + notificationWorkspace = notificationWorkspace, + onScan = { + scanner.launch( + ScanOptions().setDesiredBarcodeFormats(ScanOptions.QR_CODE) + .setCaptureActivity(PairingScannerActivity::class.java) + .setOrientationLocked(false) + .setPrompt("Scan the Intent pairing code") + .setBeepEnabled(false).setBarcodeImageEnabled(false) + ) + }, + modifier = Modifier.fillMaxSize().padding(padding) + ) + } } } } @@ -74,7 +83,7 @@ class MainActivity : ComponentActivity() { override fun onNewIntent(intent: Intent) { super.onNewIntent(intent) - acceptLink(intent) + pendingIntent = intent } private fun acceptLink(intent: Intent) { diff --git a/app/src/main/java/dev/intent/app/connection/LocalNetworkPermissionGate.kt b/app/src/main/java/dev/intent/app/connection/LocalNetworkPermissionGate.kt new file mode 100644 index 0000000..4ec58cc --- /dev/null +++ b/app/src/main/java/dev/intent/app/connection/LocalNetworkPermissionGate.kt @@ -0,0 +1,69 @@ +package dev.intent.app.connection + +import android.Manifest +import android.content.Context +import android.content.Intent +import android.content.pm.PackageManager +import android.net.Uri +import android.os.Build +import android.provider.Settings +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.compose.LifecycleEventEffect +import dev.intent.app.design.IntentButton +import dev.intent.app.design.IntentScreen +import dev.intent.app.design.IntentTextButton +import dev.intent.app.design.intentPageInsets + +internal fun hasLocalNetworkAccess(context: Context): Boolean = + Build.VERSION.SDK_INT < Build.VERSION_CODES.CINNAMON_BUN || + context.checkSelfPermission(Manifest.permission.ACCESS_LOCAL_NETWORK) == + PackageManager.PERMISSION_GRANTED + +@Composable +fun LocalNetworkPermissionGate(modifier: Modifier = Modifier, content: @Composable () -> Unit) { + val context = LocalContext.current + var allowed by remember { mutableStateOf(hasLocalNetworkAccess(context)) } + val permission = + rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { + allowed = hasLocalNetworkAccess(context) + } + LifecycleEventEffect(Lifecycle.Event.ON_RESUME) { + allowed = hasLocalNetworkAccess(context) + } + if (allowed) { + content() + } else { + Column( + modifier.intentPageInsets(), + verticalArrangement = Arrangement.spacedBy(IntentScreen.sectionSpacing) + ) { + Text("Connect to your computer") + Text( + "Allow local network access so Intent can connect to your computer on this network." + ) + IntentButton(onClick = { + permission.launch(Manifest.permission.ACCESS_LOCAL_NETWORK) + }) { Text("Allow local network access") } + IntentTextButton(onClick = { + context.startActivity( + Intent( + Settings.ACTION_APPLICATION_DETAILS_SETTINGS, + Uri.parse("package:${context.packageName}") + ) + ) + }) { Text("Open Android settings") } + } + } +} diff --git a/app/src/test/java/dev/intent/app/connection/LocalNetworkPermissionTest.kt b/app/src/test/java/dev/intent/app/connection/LocalNetworkPermissionTest.kt new file mode 100644 index 0000000..5237fb1 --- /dev/null +++ b/app/src/test/java/dev/intent/app/connection/LocalNetworkPermissionTest.kt @@ -0,0 +1,43 @@ +package dev.intent.app.connection + +import android.Manifest +import android.os.Build +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.Shadows.shadowOf +import org.robolectric.annotation.Config +import org.robolectric.util.ReflectionHelpers + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [35]) +class LocalNetworkPermissionTest { + @Test fun olderAndroidDoesNotRequireLocalNetworkPermission() { + val context = RuntimeEnvironment.getApplication() + shadowOf(context).denyPermissions(Manifest.permission.ACCESS_LOCAL_NETWORK) + assertTrue(hasLocalNetworkAccess(context)) + } + + @Test fun android17BlocksUntilPermissionGrantedAndBlocksAgainAfterRevocation() { + val context = RuntimeEnvironment.getApplication() + val original = Build.VERSION.SDK_INT + try { + ReflectionHelpers.setStaticField( + Build.VERSION::class.java, + "SDK_INT", + Build.VERSION_CODES.CINNAMON_BUN + ) + shadowOf(context).denyPermissions(Manifest.permission.ACCESS_LOCAL_NETWORK) + assertFalse(hasLocalNetworkAccess(context)) + shadowOf(context).grantPermissions(Manifest.permission.ACCESS_LOCAL_NETWORK) + assertTrue(hasLocalNetworkAccess(context)) + shadowOf(context).denyPermissions(Manifest.permission.ACCESS_LOCAL_NETWORK) + assertFalse(hasLocalNetworkAccess(context)) + } finally { + ReflectionHelpers.setStaticField(Build.VERSION::class.java, "SDK_INT", original) + } + } +}