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
2 changes: 2 additions & 0 deletions FloconAndroid/gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ maven-publish = "0.37.0"
multiplatform-settings = "1.3.0"
navigation3 = "1.2.0-rc01"
okhttp-bom = "4.12.0"
play-services-code-scanner = "16.1.0"
process-phoenix = "3.0.0"
protobuf = "4.36.2"
protobuf-plugin = "0.10.0"
Expand Down Expand Up @@ -124,6 +125,7 @@ sqlite-jdbc = { module = "org.xerial:sqlite-jdbc", version.ref = "sqlite-jdbc" }

# Third Party
brotli-dec = { module = "org.brotli:dec", version.ref = "brotli" }
google-play-services-code-scanner = { module = "com.google.android.gms:play-services-code-scanner", version.ref = "play-services-code-scanner" }
jakewharton-process-phoenix = { group = "com.jakewharton", name = "process-phoenix", version.ref = "process-phoenix" }
multiplatform-settings = { module = "com.russhwolf:multiplatform-settings", version.ref = "multiplatform-settings" }
vanniktech-mavenPublish-gradlePlugin = { group = "com.vanniktech", name = "gradle-maven-publish-plugin", version.ref = "maven-publish" }
Expand Down
31 changes: 31 additions & 0 deletions FloconAndroid/qrscanner-no-op/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
plugins {
id("flocon.kotlin.library")
id("flocon.android.library")
id("flocon.jvm.library")
id("flocon.publish")
}

kotlin {
android {
namespace = "io.github.openflocon.flocon.qrscanner.noop"
}

sourceSets {
commonMain.dependencies {
api(projects.floconNoOp)
}
}
}

mavenPublishing {
coordinates(
groupId = project.property("floconGroupId") as String,
artifactId = "flocon-qr-scanner-no-op",
version = System.getenv("PROJECT_VERSION_NAME") ?: project.property("floconVersion") as String
)


pom {
name = "Flocon QR Scanner No-Op"
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package io.github.openflocon.flocon.qrscanner

import android.content.Context
import io.github.openflocon.flocon.Flocon
import io.github.openflocon.flocon.FloconConnectResult

/** Does nothing, no scanner is opened: always `null`. */
@Suppress("UNUSED_PARAMETER")
suspend fun Flocon.Companion.scanQrCodeAndConnect(context: Context): FloconConnectResult? = null
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package io.github.openflocon.flocon.qrscanner

import io.github.openflocon.flocon.Flocon
import io.github.openflocon.flocon.FloconConnectResult

/** Does nothing, always [FloconConnectResult.NotStarted]. */
@Suppress("UNUSED_PARAMETER")
suspend fun Flocon.Companion.connectWithQrCode(content: String): FloconConnectResult = FloconConnectResult.NotStarted
36 changes: 36 additions & 0 deletions FloconAndroid/qrscanner/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
plugins {
id("flocon.kotlin.library")
id("flocon.android.library")
id("flocon.jvm.library")
id("flocon.publish")
}

kotlin {
android {
namespace = "io.github.openflocon.flocon.qrscanner"
}

sourceSets {
commonMain.dependencies {
implementation(projects.flocon)
implementation(libs.kotlinx.coroutines.core)
}

androidMain.dependencies {
implementation(libs.google.play.services.code.scanner)
}
}
}

mavenPublishing {
coordinates(
groupId = project.property("floconGroupId") as String,
artifactId = "flocon-qr-scanner",
version = System.getenv("PROJECT_VERSION_NAME") ?: project.property("floconVersion") as String
)


pom {
name = "Flocon QR Scanner"
}
}
11 changes: 11 additions & 0 deletions FloconAndroid/qrscanner/src/androidMain/AndroidManifest.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">

<application>
<!-- Downloads the scanner module when the app is installed, so that the first scan is not delayed -->
<meta-data
android:name="com.google.mlkit.vision.DEPENDENCIES"
android:value="barcode_ui" />
</application>

</manifest>
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
package io.github.openflocon.flocon.qrscanner

import android.content.Context
import com.google.mlkit.vision.barcode.common.Barcode
import com.google.mlkit.vision.codescanner.GmsBarcodeScannerOptions
import com.google.mlkit.vision.codescanner.GmsBarcodeScanning
import io.github.openflocon.flocon.Flocon
import io.github.openflocon.flocon.FloconConnectResult
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlin.coroutines.resume

/**
* Opens the Google code scanner, then connects to the desktop app at the address held by the QR code
* it reads, see [connectWithQrCode].
*
* The scanner is a screen of Google Play Services: the app needs no camera permission. It requires
* Google Play Services on the device, otherwise the result is [FloconConnectResult.Failed].
*
* ```
* val result = Flocon.scanQrCodeAndConnect(context)
* ```
*
* @param context preferably an `Activity`.
* @return the result of the connection, or `null` if the user closed the scanner without scanning.
*/
suspend fun Flocon.Companion.scanQrCodeAndConnect(context: Context): FloconConnectResult? =
when (val scan = scanQrCode(context)) {
ScanQrCodeResult.Cancelled -> null
is ScanQrCodeResult.Failed -> FloconConnectResult.Failed(scan.reason)
is ScanQrCodeResult.Code -> connectWithQrCode(scan.content)
}

private sealed interface ScanQrCodeResult {
data class Code(val content: String) : ScanQrCodeResult
data object Cancelled : ScanQrCodeResult
data class Failed(val reason: String) : ScanQrCodeResult
}

private suspend fun scanQrCode(context: Context): ScanQrCodeResult =
suspendCancellableCoroutine { continuation ->
val options = GmsBarcodeScannerOptions.Builder()
.setBarcodeFormats(Barcode.FORMAT_QR_CODE)
.build()
GmsBarcodeScanning.getClient(context, options)
.startScan()
.addOnSuccessListener { barcode ->
val content = barcode.rawValue
continuation.resume(
if (content == null) {
ScanQrCodeResult.Failed("The QR code is empty")
} else {
ScanQrCodeResult.Code(content)
}
)
}
.addOnCanceledListener {
continuation.resume(ScanQrCodeResult.Cancelled)
}
.addOnFailureListener { error ->
continuation.resume(ScanQrCodeResult.Failed(error.message ?: "The scanner failed"))
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
package io.github.openflocon.flocon.qrscanner

import io.github.openflocon.flocon.Flocon
import io.github.openflocon.flocon.FloconConnectResult
import io.github.openflocon.flocon.connect

/**
* Connects to the desktop app at the address held by a scanned QR code.
*
* Same as `Flocon.connect(address)`, for the raw content of a QR code. The content is either the IP address
* (or host) of the computer, or a URL whose host is that address, such as `flocon://192.168.1.20`: the scheme,
* the port and the path are ignored. On Android, `Flocon.scanQrCodeAndConnect` scans the code and calls this.
*
* @param content the text read from the QR code.
* @return [FloconConnectResult.Connected], or why it could not connect. The messages are kept
* in every case, call it again to retry.
*/
suspend fun Flocon.Companion.connectWithQrCode(content: String): FloconConnectResult =
connect(addressFromQrCode(content))
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
package io.github.openflocon.flocon.qrscanner

/**
* The address to connect to for the content of a QR code.
*
* The content is either the bare address of the computer (`192.168.1.20`), or a URL whose host is that
* address (`flocon://192.168.1.20`, `wss://my-mac.local:9023`): the scheme, the port and the path are
* ignored, as the port is always 9023.
*
* Anything that is not a URL is returned trimmed and unchanged, so that `Flocon.connect` reports it
* as an invalid address when it cannot be read.
*/
internal fun addressFromQrCode(content: String): String {
val text = content.trim()
val schemeEnd = text.indexOf("://")
if (schemeEnd <= 0) return text
val host = text
.substring(schemeEnd + 3)
.substringBefore('/')
.substringBefore('?')
.substringBefore('#')
.substringAfterLast('@')
.substringBefore(':')
return host.ifEmpty { text }
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
package io.github.openflocon.flocon.qrscanner

import kotlin.test.Test
import kotlin.test.assertEquals

class QrCodeAddressTest {
@Test
fun `a plain address is kept`() {
assertEquals("192.168.1.20", addressFromQrCode("192.168.1.20"))
}

@Test
fun `whitespace around the address is removed`() {
assertEquals("my-mac.local", addressFromQrCode(" my-mac.local\n"))
}

@Test
fun `a url gives its host`() {
assertEquals("192.168.1.20", addressFromQrCode("flocon://192.168.1.20"))
assertEquals("my-mac.local", addressFromQrCode("wss://my-mac.local:9023/path?token=1"))
assertEquals("192.168.1.20", addressFromQrCode("https://user@192.168.1.20:9023#top"))
}

@Test
fun `text that is not an address is kept so it is reported as invalid`() {
assertEquals("hello world", addressFromQrCode("hello world"))
assertEquals("://", addressFromQrCode("://"))
assertEquals("flocon://", addressFromQrCode("flocon://"))
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package io.github.openflocon.flocon.myapplication.multi.ui

import io.github.openflocon.flocon.FloconConnectResult

/**
* Scans the QR code of Flocon Desktop and connects to it, on the platforms that can: the Android app sets it.
* The connection screen shows its button only when it is set.
*/
object SampleQrScanner {
/** Returns the result of the connection, or `null` if the scan was cancelled. */
var scan: (suspend () -> FloconConnectResult?)? = null
}
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ import io.github.openflocon.flocon.FloconConnectResult
import io.github.openflocon.flocon.FloconConnectionState
import io.github.openflocon.flocon.connect
import io.github.openflocon.flocon.connectionState
import io.github.openflocon.flocon.myapplication.multi.ui.SampleQrScanner
import io.github.openflocon.flocon.myapplication.multi.ui.components.AccentButton
import io.github.openflocon.flocon.myapplication.multi.ui.components.Card
import io.github.openflocon.flocon.myapplication.multi.ui.components.InfoCard
Expand Down Expand Up @@ -76,6 +77,18 @@ fun ConnectionScreen(onBack: () -> Unit) {
}
}

val scanQrCode = SampleQrScanner.scan?.let { scan ->
{
if (!connecting) {
scope.launch {
connecting = true
scan()?.let { result = it }
connecting = false
}
}
}
}

ScreenScaffold(
title = "Remote connection",
subtitle = "Connect to Flocon Desktop by IP",
Expand Down Expand Up @@ -138,6 +151,14 @@ fun ConnectionScreen(onBack: () -> Unit) {
onClick = connect,
)
}
if (scanQrCode != null) {
AccentButton(
text = "Scan the QR code of Flocon Desktop",
enabled = !connecting,
onClick = scanQrCode,
modifier = Modifier.fillMaxWidth().padding(start = 16.dp, end = 16.dp, bottom = 16.dp),
)
}
when (val current = result) {
is FloconConnectResult.Failed -> ResultLine("✕ ${current.reason}", colors.error)
is FloconConnectResult.InvalidAddress -> ResultLine("✕ ${current.reason}", colors.error)
Expand Down
2 changes: 2 additions & 0 deletions FloconAndroid/sample/android/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ dependencies {
implementation(projects.webview)
implementation(projects.crashreporter)
implementation(projects.tables)
implementation(projects.qrscanner)

implementation(platform(libs.okhttp.bom))
implementation(libs.okhttp)
Expand Down Expand Up @@ -159,6 +160,7 @@ val floconNoOpModules = mapOf(
":network:okhttp-interceptor" to ":network:okhttp-interceptor-no-op",
":sharedprefs" to ":sharedprefs-no-op",
":tables" to ":tables-no-op",
":qrscanner" to ":qrscanner-no-op",
)

configurations.configureEach {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ import io.github.openflocon.flocon.myapplication.multi.tester.AndroidTestContext
import io.github.openflocon.flocon.myapplication.multi.ui.App
import io.github.openflocon.flocon.myapplication.multi.ui.SampleFeature
import io.github.openflocon.flocon.myapplication.multi.ui.SampleFeatures
import io.github.openflocon.flocon.myapplication.multi.ui.SampleQrScanner
import io.github.openflocon.flocon.myapplication.multi.ui.registerAndroidSampleFeatures
import io.github.openflocon.flocon.myapplication.multi.ui.registerAndroidSampleWebView
import io.github.openflocon.flocon.webview.FloconWebView
Expand All @@ -42,6 +43,7 @@ import io.github.openflocon.flocon.plugins.device.FloconDevice
import io.github.openflocon.flocon.plugins.files.FloconFiles
import io.github.openflocon.flocon.plugins.sharedprefs.FloconPreferences
import io.github.openflocon.flocon.connect
import io.github.openflocon.flocon.qrscanner.scanQrCodeAndConnect
import io.github.openflocon.flocon.startFlocon
import io.github.openflocon.flocon.tables.FloconTable
import io.ktor.client.HttpClient
Expand Down Expand Up @@ -114,6 +116,7 @@ class MainActivity : ComponentActivity() {
AndroidTestContext.inMemoryDb = Databases.getInMemoryDogDatabase(applicationContext)
AndroidTestContext.grpcSayHello = { GrpcController.sayHello() }
registerAndroidSampleFeatures(applicationContext)
SampleQrScanner.scan = { Flocon.scanQrCodeAndConnect(this@MainActivity) }
Comment thread
florent37 marked this conversation as resolved.
Resolved
registerAndroidSampleWebView()
SampleFeatures.on(SampleFeature.Accessibility) {
val announcement = "Item added to the cart"
Expand Down
2 changes: 2 additions & 0 deletions FloconAndroid/settings.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,8 @@ include(":files")
include(":files-no-op")
include(":sharedprefs")
include(":sharedprefs-no-op")
include(":qrscanner")
include(":qrscanner-no-op")
include(":network:core")
include(":network:core-no-op")
include(":database:core")
Expand Down
Loading