Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_LOCAL_NETWORK" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.CAMERA" />
<uses-feature android:name="android.hardware.camera" android:required="false" />
Expand Down
43 changes: 26 additions & 17 deletions app/src/main/java/dev/intent/app/MainActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -31,14 +33,15 @@ class MainActivity : ComponentActivity() {
private val model by lazy { ViewModelProvider(this)[ConnectionViewModel::class.java] }
private var notificationWorkspace by mutableStateOf<Pair<String, String>?>(null)
private val preferences by lazy { AppPreferences(applicationContext) }
private var pendingIntent by mutableStateOf<Intent?>(null)
private val scanner = registerForActivityResult(ScanContract()) { result ->
result.contents?.let(model.controller::prepareLink)
}

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) {
Expand All @@ -52,29 +55,35 @@ 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)
)
}
}
}
}
}

override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
acceptLink(intent)
pendingIntent = intent
}

private fun acceptLink(intent: Intent) {
Expand Down
Original file line number Diff line number Diff line change
@@ -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") }
}
}
}
Original file line number Diff line number Diff line change
@@ -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)
}
}
}
Loading