initial
1
app/.gitignore
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
/build
|
||||
78
app/build.gradle
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
plugins {
|
||||
id 'com.android.application'
|
||||
id 'org.jetbrains.kotlin.android'
|
||||
}
|
||||
|
||||
android {
|
||||
namespace 'org.fnives.android.qrcodetransfer'
|
||||
compileSdk 34
|
||||
|
||||
defaultConfig {
|
||||
applicationId "org.fnives.android.qrcodetransfer"
|
||||
minSdk 26
|
||||
targetSdk 34
|
||||
versionCode 1
|
||||
versionName "1.0"
|
||||
|
||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||
vectorDrawables {
|
||||
useSupportLibrary true
|
||||
}
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
minifyEnabled false
|
||||
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
|
||||
}
|
||||
}
|
||||
compileOptions {
|
||||
sourceCompatibility JavaVersion.VERSION_1_8
|
||||
targetCompatibility JavaVersion.VERSION_1_8
|
||||
}
|
||||
kotlinOptions {
|
||||
jvmTarget = '1.8'
|
||||
}
|
||||
buildFeatures {
|
||||
compose true
|
||||
}
|
||||
composeOptions {
|
||||
kotlinCompilerExtensionVersion '1.5.4'
|
||||
}
|
||||
packagingOptions {
|
||||
resources {
|
||||
excludes += '/META-INF/{AL2.0,LGPL2.1}'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
|
||||
def compose_ui_version = '1.5.4'
|
||||
implementation 'androidx.core:core-ktx:1.12.0'
|
||||
implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.6.2'
|
||||
implementation 'androidx.activity:activity-compose:1.8.1'
|
||||
implementation "androidx.compose.ui:ui:$compose_ui_version"
|
||||
implementation "androidx.compose.ui:ui-tooling-preview:$compose_ui_version"
|
||||
implementation 'androidx.compose.material:material:1.5.4'
|
||||
|
||||
// qr code
|
||||
implementation "com.google.zxing:core:3.5.2"
|
||||
|
||||
// permission
|
||||
implementation 'com.google.accompanist:accompanist-permissions:0.32.0'
|
||||
|
||||
// camerax
|
||||
def camerax_version = "1.3.0"
|
||||
implementation "androidx.camera:camera-camera2:$camerax_version"
|
||||
implementation "androidx.camera:camera-lifecycle:$camerax_version"
|
||||
implementation "androidx.camera:camera-view:$camerax_version"
|
||||
implementation "androidx.camera:camera-extensions:$camerax_version"
|
||||
|
||||
testImplementation 'junit:junit:4.13.2'
|
||||
androidTestImplementation 'androidx.test.ext:junit:1.1.5'
|
||||
androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1'
|
||||
androidTestImplementation "androidx.compose.ui:ui-test-junit4:$compose_ui_version"
|
||||
debugImplementation "androidx.compose.ui:ui-tooling:$compose_ui_version"
|
||||
debugImplementation "androidx.compose.ui:ui-test-manifest:$compose_ui_version"
|
||||
}
|
||||
21
app/proguard-rules.pro
vendored
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
# Add project specific ProGuard rules here.
|
||||
# You can control the set of applied configuration files using the
|
||||
# proguardFiles setting in build.gradle.
|
||||
#
|
||||
# For more details, see
|
||||
# http://developer.android.com/guide/developing/tools/proguard.html
|
||||
|
||||
# If your project uses WebView with JS, uncomment the following
|
||||
# and specify the fully qualified class name to the JavaScript interface
|
||||
# class:
|
||||
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
|
||||
# public *;
|
||||
#}
|
||||
|
||||
# Uncomment this to preserve the line number information for
|
||||
# debugging stack traces.
|
||||
#-keepattributes SourceFile,LineNumberTable
|
||||
|
||||
# If you keep the line number information, uncomment this to
|
||||
# hide the original source file name.
|
||||
#-renamesourcefileattribute SourceFile
|
||||
|
|
@ -0,0 +1,85 @@
|
|||
package org.fnives.android.qrcodetransfer
|
||||
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import kotlin.io.encoding.Base64
|
||||
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||
import org.fnives.android.qrcodetransfer.read.ReadState
|
||||
import org.fnives.android.qrcodetransfer.read.currentText
|
||||
import org.junit.Assert
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
|
||||
/**
|
||||
* Instrumented test, which will execute on an Android device.
|
||||
*
|
||||
* See [testing documentation](http://d.android.com/tools/testing).
|
||||
*/
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class QRCodeTest {
|
||||
@Test
|
||||
fun simple() {
|
||||
val bitMatrix = SequenceProtocol.createBitMatrix("alma")
|
||||
val readResult = SequenceProtocol.read(bitMatrix[0].toBitmap().toBinaryBitmap())
|
||||
|
||||
Assert.assertEquals(
|
||||
SequenceProtocol.SequenceInfo.NotSequence("alma"),
|
||||
readResult?.sequenceInfo
|
||||
)
|
||||
Assert.assertEquals(1, bitMatrix.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun emptyThrows() {
|
||||
Assert.assertThrows(IllegalArgumentException::class.java) {
|
||||
SequenceProtocol.createBitMatrix("")
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun sequence() {
|
||||
val text = StringBuilder("alma")
|
||||
repeat(20) {
|
||||
text.append("alma")
|
||||
}
|
||||
val bitMatrix = SequenceProtocol.createBitMatrix(text.toString())
|
||||
Assert.assertTrue(bitMatrix.size > 1)
|
||||
val readResults = bitMatrix.map {
|
||||
val bitmap = it.toBitmap()
|
||||
SequenceProtocol.read(bitmap.toBinaryBitmap())
|
||||
}
|
||||
|
||||
val endResult = readResults.fold(ReadState(0, emptyMap())) { readState, it ->
|
||||
val sequenceInfo = (it?.sequenceInfo as SequenceProtocol.SequenceInfo.SequenceElement)
|
||||
readState.copy(
|
||||
length = sequenceInfo.length,
|
||||
parts = readState.parts + mapOf(sequenceInfo.current to sequenceInfo.content)
|
||||
)
|
||||
}
|
||||
|
||||
Assert.assertEquals(text.toString(), endResult.currentText)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalEncodingApi::class)
|
||||
@Test
|
||||
fun everyASCIICharacterCanBeSend() {
|
||||
SequenceProtocol.encodeBase64 = true
|
||||
SequenceProtocol.versionCode = 4
|
||||
val inputs = (0 until 255).map { it.toChar() }
|
||||
.map { "$it" }
|
||||
|
||||
val results = mutableMapOf<String, String?>()
|
||||
|
||||
inputs.forEach {
|
||||
val bitmap = SequenceProtocol.createBitMatrix(it)[0].toBitmap()
|
||||
val read = SequenceProtocol.read(bitmap.toBinaryBitmap())
|
||||
|
||||
results[it] = read?.sequenceInfo?.content
|
||||
}
|
||||
val notMatching = results.entries.filter { it.key != it.value }
|
||||
println(notMatching)
|
||||
|
||||
results.forEach { (input, actual) ->
|
||||
Assert.assertEquals(input, actual)
|
||||
}
|
||||
}
|
||||
}
|
||||
29
app/src/main/AndroidManifest.xml
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools">
|
||||
|
||||
<uses-permission android:name="android.permission.CAMERA" />
|
||||
|
||||
<application
|
||||
android:allowBackup="true"
|
||||
android:dataExtractionRules="@xml/data_extraction_rules"
|
||||
android:fullBackupContent="@xml/backup_rules"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:label="@string/app_name"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/Theme.QRCodeTransfer"
|
||||
tools:targetApi="31">
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true"
|
||||
android:windowSoftInputMode="adjustResize"
|
||||
android:theme="@style/Theme.QRCodeTransfer">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
|
|
@ -0,0 +1,85 @@
|
|||
package org.fnives.android.qrcodetransfer
|
||||
|
||||
import android.os.Bundle
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.compose.animation.AnimatedContent
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material.BottomNavigation
|
||||
import androidx.compose.material.BottomNavigationItem
|
||||
import androidx.compose.material.Icon
|
||||
import androidx.compose.material.MaterialTheme
|
||||
import androidx.compose.material.Scaffold
|
||||
import androidx.compose.material.Surface
|
||||
import androidx.compose.material.Text
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Create
|
||||
import androidx.compose.material.icons.filled.Search
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import org.fnives.android.qrcodetransfer.create.CreateQRCode
|
||||
import org.fnives.android.qrcodetransfer.read.ReadQRCode
|
||||
import org.fnives.android.qrcodetransfer.ui.theme.QRCodeTransferTheme
|
||||
|
||||
|
||||
class MainActivity : ComponentActivity() {
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
|
||||
|
||||
setContent {
|
||||
QRCodeTransferTheme {
|
||||
var writerSelected by rememberSaveable { mutableStateOf(true) }
|
||||
// A surface container using the 'background' color from the theme
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
color = MaterialTheme.colors.background,
|
||||
) {
|
||||
Scaffold(bottomBar = {
|
||||
NavBar(
|
||||
writerSelected = writerSelected,
|
||||
setWriterSelected = { writerSelected = it })
|
||||
}) {
|
||||
Box(Modifier.padding(it)) {
|
||||
AnimatedContent(targetState = writerSelected) { showWriter ->
|
||||
if (showWriter) {
|
||||
CreateQRCode()
|
||||
} else {
|
||||
ReadQRCode()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun NavBar(writerSelected: Boolean, setWriterSelected: (Boolean) -> Unit) {
|
||||
BottomNavigation(Modifier.fillMaxWidth()) {
|
||||
BottomNavigationItem(
|
||||
selected = writerSelected,
|
||||
onClick = { setWriterSelected(true) },
|
||||
icon = { Icon(Icons.Filled.Create, contentDescription = null) },
|
||||
label = { Text(stringResource(id = R.string.create_qr_code)) },
|
||||
)
|
||||
|
||||
BottomNavigationItem(
|
||||
selected = !writerSelected,
|
||||
onClick = { setWriterSelected(false) },
|
||||
icon = { Icon(Icons.Filled.Search, contentDescription = null) },
|
||||
label = { Text(stringResource(id = R.string.read_qr_code)) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,159 @@
|
|||
package org.fnives.android.qrcodetransfer
|
||||
|
||||
import com.google.zxing.BarcodeFormat
|
||||
import com.google.zxing.BinaryBitmap
|
||||
import com.google.zxing.EncodeHintType
|
||||
import com.google.zxing.common.BitMatrix
|
||||
import com.google.zxing.common.CharacterSetECI
|
||||
import com.google.zxing.qrcode.QRCodeReader
|
||||
import com.google.zxing.qrcode.QRCodeWriter
|
||||
import com.google.zxing.qrcode.decoder.ErrorCorrectionLevel
|
||||
import kotlin.io.encoding.Base64
|
||||
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||
import kotlin.math.max
|
||||
import kotlin.math.min
|
||||
import com.google.zxing.Result as QRCodeResult
|
||||
|
||||
// Since zxing only supports reading structured append and I cannot be bothered to modify its encoding
|
||||
// I simply add header to the "content" of the QR code
|
||||
// Format: starts with "S://[C][L]" where C is the current index and L is length
|
||||
// C & L are binary numbers aka character A means 65 (ASCII)
|
||||
// is this naive? yes, but it seems to be enough for my use case
|
||||
object SequenceProtocol {
|
||||
|
||||
private val writer by lazy { QRCodeWriter() }
|
||||
private val reader by lazy { QRCodeReader() }
|
||||
private val maxSizeMap = mutableMapOf<Int, Int>()
|
||||
private val maxSize get() = maxSizeMap[versionCode] ?: findMaxSize().also { maxSizeMap[versionCode] = it }
|
||||
private val formatCurrent = 'C'
|
||||
private val formatLength = 'L'
|
||||
private val formatPrefix = "S://"
|
||||
private val format = "${formatPrefix}${formatCurrent}${formatLength}"
|
||||
var versionCode: Int = 4
|
||||
set(value) {
|
||||
field = max(min(value, validVersionCodes.last), validVersionCodes.first)
|
||||
}
|
||||
val validVersionCodes = 2 until 40
|
||||
var encodeBase64: Boolean = false
|
||||
|
||||
@OptIn(ExperimentalEncodingApi::class)
|
||||
@Throws
|
||||
fun createBitMatrix(text: String): List<BitMatrix> {
|
||||
val message = base64Encode(text)
|
||||
if (message.length < maxSize) {
|
||||
return listOf(encode(message))
|
||||
}
|
||||
|
||||
val contentThatFits = (maxSize - format.length)
|
||||
val chunks = message.chunked(contentThatFits)
|
||||
val formatWithLength = format.replace(formatLength, chunks.size.toChar())
|
||||
val messages = chunks.mapIndexed { index, s ->
|
||||
val prefix = formatWithLength.replace(formatCurrent, index.toChar())
|
||||
"${prefix}${s}".also {
|
||||
println("MYLOG${index} $it")
|
||||
println("MYLOG$it")
|
||||
}
|
||||
}
|
||||
return messages.map {
|
||||
encode(it)
|
||||
}
|
||||
}
|
||||
|
||||
@Throws
|
||||
fun read(binaryBitmap: BinaryBitmap): ReadResult? {
|
||||
val result = decode(binaryBitmap) ?: return null
|
||||
|
||||
if (!result.text.startsWith(formatPrefix)) {
|
||||
return ReadResult(SequenceInfo.NotSequence(base64Decode(result.text)), result)
|
||||
}
|
||||
|
||||
val remaining = result.text.drop(formatPrefix.length)
|
||||
val current = remaining[0]
|
||||
val length = remaining[1]
|
||||
val content = base64Decode(remaining.drop(2))
|
||||
return ReadResult(
|
||||
SequenceInfo.SequenceElement(
|
||||
current = current.code,
|
||||
length = length.code,
|
||||
content = content,
|
||||
),
|
||||
result
|
||||
)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalEncodingApi::class)
|
||||
private fun base64Decode(text: String) =
|
||||
if (encodeBase64) {
|
||||
Base64.decode(text).toString(Charsets.UTF_16)
|
||||
} else {
|
||||
text
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalEncodingApi::class)
|
||||
private fun base64Encode(text: String) =
|
||||
if (encodeBase64) {
|
||||
Base64.encode(text.toByteArray(Charsets.UTF_16))
|
||||
} else {
|
||||
text
|
||||
}
|
||||
|
||||
private fun decode(binaryBitmap: BinaryBitmap) =
|
||||
try {
|
||||
reader.decode(binaryBitmap)
|
||||
} catch (e: Throwable) {
|
||||
if (BuildConfig.DEBUG) {
|
||||
e.printStackTrace()
|
||||
}
|
||||
null
|
||||
}
|
||||
|
||||
private fun encode(message: String): BitMatrix {
|
||||
return writer.encode(
|
||||
message,
|
||||
BarcodeFormat.QR_CODE,
|
||||
256,
|
||||
256,
|
||||
mapOf(
|
||||
EncodeHintType.CHARACTER_SET to CharacterSetECI.ASCII,
|
||||
EncodeHintType.ERROR_CORRECTION to ErrorCorrectionLevel.M,
|
||||
EncodeHintType.QR_VERSION to versionCode,
|
||||
EncodeHintType.MARGIN to max(versionCode / 2, 3)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Naive method to find the max size we can fit into our encoding ¯\_(ツ)_/¯
|
||||
*/
|
||||
fun findMaxSize(): Int {
|
||||
val msg = StringBuilder("a")
|
||||
var maxLength: Int? = null
|
||||
while (maxLength == null) {
|
||||
try {
|
||||
encode(msg.toString())
|
||||
} catch (e: Throwable) {
|
||||
maxLength = msg.length
|
||||
}
|
||||
msg.append("a")
|
||||
}
|
||||
return maxLength - 1
|
||||
}
|
||||
|
||||
data class ReadResult(
|
||||
val sequenceInfo: SequenceInfo,
|
||||
val underlyingResult: QRCodeResult
|
||||
)
|
||||
|
||||
// whether we are dealing with normal QR Code or "sequenced" one
|
||||
sealed interface SequenceInfo {
|
||||
|
||||
abstract val content: String
|
||||
|
||||
data class NotSequence(override val content: String) : SequenceInfo
|
||||
data class SequenceElement(
|
||||
val current: Int,
|
||||
val length: Int,
|
||||
override val content: String
|
||||
) : SequenceInfo
|
||||
}
|
||||
}
|
||||
79
app/src/main/java/org/fnives/android/qrcodetransfer/Util.kt
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
package org.fnives.android.qrcodetransfer
|
||||
|
||||
import android.content.ClipData
|
||||
import android.content.ClipboardManager
|
||||
import android.content.Context
|
||||
import android.content.Context.CLIPBOARD_SERVICE
|
||||
import android.content.Intent
|
||||
import android.content.Intent.CATEGORY_DEFAULT
|
||||
import android.content.Intent.FLAG_ACTIVITY_EXCLUDE_FROM_RECENTS
|
||||
import android.content.Intent.FLAG_ACTIVITY_NEW_TASK
|
||||
import android.content.Intent.FLAG_ACTIVITY_NO_HISTORY
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.Color
|
||||
import android.icu.text.MessageFormat
|
||||
import android.net.Uri
|
||||
import android.provider.Settings.ACTION_APPLICATION_DETAILS_SETTINGS
|
||||
import com.google.zxing.BinaryBitmap
|
||||
import com.google.zxing.LuminanceSource
|
||||
import com.google.zxing.RGBLuminanceSource
|
||||
import com.google.zxing.common.BitMatrix
|
||||
import com.google.zxing.common.HybridBinarizer
|
||||
import java.util.Locale
|
||||
|
||||
|
||||
fun BitMatrix.toBitmap(): Bitmap {
|
||||
val bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888)
|
||||
for (x in 0 until width) {
|
||||
for (y in 0 until height) {
|
||||
bitmap.setPixel(x, y, if (get(x, y)) Color.BLACK else Color.WHITE)
|
||||
}
|
||||
}
|
||||
|
||||
return bitmap
|
||||
}
|
||||
|
||||
fun Bitmap.toBinaryBitmap(): BinaryBitmap {
|
||||
//copy pixel data from the Bitmap into the 'intArray' array
|
||||
val intArray = IntArray(width * height)
|
||||
getPixels(intArray, 0, width, 0, 0, width, height)
|
||||
|
||||
val source: LuminanceSource = RGBLuminanceSource(width, height, intArray)
|
||||
|
||||
return BinaryBitmap(HybridBinarizer(source))
|
||||
}
|
||||
|
||||
fun Context.openAppSettings() {
|
||||
val intent = Intent(ACTION_APPLICATION_DETAILS_SETTINGS)
|
||||
intent.data = Uri.fromParts("package", packageName, null)
|
||||
intent.addCategory(CATEGORY_DEFAULT)
|
||||
intent.addFlags(FLAG_ACTIVITY_NEW_TASK)
|
||||
intent.addFlags(FLAG_ACTIVITY_NO_HISTORY)
|
||||
intent.addFlags(FLAG_ACTIVITY_EXCLUDE_FROM_RECENTS)
|
||||
|
||||
startActivity(intent)
|
||||
}
|
||||
|
||||
fun Int.toOrdinal(): String {
|
||||
val formatter = MessageFormat("{0,ordinal}", Locale.US)
|
||||
return formatter.format(arrayOf(this))
|
||||
}
|
||||
|
||||
fun Context.copyToClipboard(text: String) {
|
||||
val clipboard = getSystemService(CLIPBOARD_SERVICE) as ClipboardManager;
|
||||
val clipData = ClipData.newPlainText("label", text);
|
||||
clipboard.setPrimaryClip(clipData)
|
||||
}
|
||||
|
||||
fun String.isMaybeLink() = contains("://")
|
||||
|
||||
fun Context.openLink(link: String) {
|
||||
try {
|
||||
val intent = Intent(Intent.ACTION_VIEW, Uri.parse(link))
|
||||
intent.addFlags(FLAG_ACTIVITY_NEW_TASK)
|
||||
intent.addFlags(FLAG_ACTIVITY_NO_HISTORY)
|
||||
startActivity(intent)
|
||||
} catch (ignored: Throwable) {
|
||||
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
package org.fnives.android.qrcodetransfer.create
|
||||
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.material.Checkbox
|
||||
import androidx.compose.material.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import java.util.Base64
|
||||
import org.fnives.android.qrcodetransfer.R
|
||||
|
||||
@Composable
|
||||
fun Base64EncodeCheckbox(encode: Boolean, setEncode: (Boolean) -> Unit) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Checkbox(checked = encode, onCheckedChange = setEncode)
|
||||
Spacer(Modifier.width(4.dp))
|
||||
Text(stringResource(id = R.string.encode_base64))
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,214 @@
|
|||
package org.fnives.android.qrcodetransfer.create
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.sizeIn
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.Button
|
||||
import androidx.compose.material.CircularProgressIndicator
|
||||
import androidx.compose.material.OutlinedTextField
|
||||
import androidx.compose.material.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.rememberCoroutineScope
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.ExperimentalComposeUiApi
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.graphics.asImageBitmap
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.unit.dp
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.fnives.android.qrcodetransfer.R
|
||||
import org.fnives.android.qrcodetransfer.SequenceProtocol
|
||||
import org.fnives.android.qrcodetransfer.toBitmap
|
||||
|
||||
|
||||
@Composable
|
||||
fun CreateQRCode() {
|
||||
var bitmaps by remember { mutableStateOf(listOf<Bitmap>()) }
|
||||
var bitmapIndex by remember(bitmaps) { mutableStateOf(0) }
|
||||
var loading by remember { mutableStateOf(false) }
|
||||
|
||||
Column(
|
||||
Modifier
|
||||
.padding(24.dp)
|
||||
.fillMaxSize()
|
||||
) {
|
||||
Box(Modifier.weight(1f)) {
|
||||
if (loading) {
|
||||
CircularProgressIndicator(Modifier.align(Alignment.Center))
|
||||
}
|
||||
QRCodeCarousel(
|
||||
bitmaps = bitmaps,
|
||||
bitmapIndex = bitmapIndex,
|
||||
setBitmapIndex = { bitmapIndex = it },
|
||||
)
|
||||
}
|
||||
QRCodeContentInput(
|
||||
bitmaps = bitmaps,
|
||||
setBitmaps = {
|
||||
bitmaps.forEach { it.recycle() }
|
||||
bitmaps = it
|
||||
},
|
||||
setLoading = { loading = it }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun QRCodeCarousel(
|
||||
bitmaps: List<Bitmap>,
|
||||
bitmapIndex: Int,
|
||||
setBitmapIndex: (Int) -> Unit
|
||||
) {
|
||||
Column(Modifier.fillMaxSize()) {
|
||||
val imageBitmap = remember(bitmaps, bitmapIndex) {
|
||||
if (bitmapIndex < bitmaps.size) {
|
||||
bitmaps[bitmapIndex].asImageBitmap()
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
if (imageBitmap != null) {
|
||||
Image(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.fillMaxWidth(),
|
||||
bitmap = imageBitmap,
|
||||
contentDescription = "",
|
||||
contentScale = ContentScale.Fit
|
||||
)
|
||||
|
||||
if (bitmaps.size > 1) {
|
||||
Spacer(Modifier.height(16.dp))
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
val isAfterFirst = bitmapIndex > 0
|
||||
val isBeforeLast = bitmapIndex + 1 < bitmaps.size
|
||||
Button(
|
||||
modifier = Modifier.alpha(if (isAfterFirst) 1f else 0f),
|
||||
onClick = {
|
||||
if (isAfterFirst) {
|
||||
setBitmapIndex(bitmapIndex - 1)
|
||||
}
|
||||
}) {
|
||||
Text(stringResource(id = R.string.previous))
|
||||
}
|
||||
Spacer(Modifier.weight(1f))
|
||||
Text("${bitmapIndex + 1} / ${bitmaps.size}")
|
||||
Spacer(Modifier.weight(1f))
|
||||
Button(
|
||||
modifier = Modifier.alpha(if (isBeforeLast) 1f else 0f),
|
||||
onClick = {
|
||||
if (isBeforeLast) {
|
||||
setBitmapIndex(bitmapIndex + 1)
|
||||
}
|
||||
}) {
|
||||
Text(stringResource(id = R.string.next))
|
||||
}
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(16.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class JobHolder(var job: Job?) {
|
||||
fun cancel() = job?.cancel()
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalComposeUiApi::class)
|
||||
@Composable
|
||||
fun QRCodeContentInput(
|
||||
bitmaps: List<Bitmap>,
|
||||
setLoading: (Boolean) -> Unit,
|
||||
setBitmaps: (List<Bitmap>) -> Unit,
|
||||
) {
|
||||
var content by rememberSaveable { mutableStateOf("") }
|
||||
var number by rememberSaveable { mutableStateOf(SequenceProtocol.versionCode) }
|
||||
SequenceProtocol.versionCode = number
|
||||
var encodeBase64 by rememberSaveable { mutableStateOf(SequenceProtocol.encodeBase64) }
|
||||
SequenceProtocol.encodeBase64 = encodeBase64
|
||||
val keyboardController = LocalSoftwareKeyboardController.current
|
||||
val coroutineScope = rememberCoroutineScope { Dispatchers.IO }
|
||||
val holder = remember(coroutineScope) { JobHolder(null) }
|
||||
|
||||
val createBitmaps = remember(bitmaps, content) {
|
||||
return@remember fun() {
|
||||
if (content.isBlank()) return
|
||||
keyboardController?.hide()
|
||||
setLoading(true)
|
||||
holder.cancel()
|
||||
holder.job = coroutineScope.launch {
|
||||
val matrix = SequenceProtocol.createBitMatrix(content)
|
||||
val newBitmaps = matrix.map { it.toBitmap() }
|
||||
withContext(Dispatchers.Main) {
|
||||
setBitmaps(newBitmaps)
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
val inputScrollState = rememberScrollState()
|
||||
|
||||
OutlinedTextField(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.sizeIn(maxHeight = 128.dp)
|
||||
.verticalScroll(inputScrollState),
|
||||
label = { Text(stringResource(id = R.string.qr_code_content)) },
|
||||
value = content,
|
||||
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
|
||||
keyboardActions = KeyboardActions(onDone = { createBitmaps() }),
|
||||
onValueChange = {
|
||||
setBitmaps(emptyList())
|
||||
content = it
|
||||
})
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Row(
|
||||
Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.End
|
||||
) {
|
||||
Column {
|
||||
QRCodeVersionNumberDropdown(number, setVersionNumber = {
|
||||
number = it
|
||||
SequenceProtocol.versionCode = it
|
||||
createBitmaps()
|
||||
})
|
||||
Base64EncodeCheckbox(encode = encodeBase64, setEncode = {
|
||||
encodeBase64 = it
|
||||
SequenceProtocol.encodeBase64 = it
|
||||
createBitmaps()
|
||||
})
|
||||
}
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
Button(onClick = createBitmaps) {
|
||||
Text(stringResource(id = R.string.create))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,64 @@
|
|||
package org.fnives.android.qrcodetransfer.create
|
||||
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.sizeIn
|
||||
import androidx.compose.material.DropdownMenu
|
||||
import androidx.compose.material.DropdownMenuItem
|
||||
import androidx.compose.material.MaterialTheme
|
||||
import androidx.compose.material.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.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import org.fnives.android.qrcodetransfer.R
|
||||
import org.fnives.android.qrcodetransfer.SequenceProtocol
|
||||
|
||||
@Composable
|
||||
fun QRCodeVersionNumberDropdown(versionNumber: Int, setVersionNumber: (Int) -> Unit) {
|
||||
val validCodes = SequenceProtocol.validVersionCodes
|
||||
var expanded by remember {
|
||||
mutableStateOf(false)
|
||||
}
|
||||
|
||||
Box(modifier = Modifier) {
|
||||
Text(
|
||||
stringResource(R.string.version_number, versionNumber),
|
||||
modifier = Modifier
|
||||
.border(
|
||||
1.dp,
|
||||
color = MaterialTheme.colors.primary,
|
||||
shape = MaterialTheme.shapes.medium
|
||||
)
|
||||
.clickable(onClick = { expanded = true })
|
||||
.padding(vertical = 4.dp, horizontal = 8.dp)
|
||||
)
|
||||
DropdownMenu(
|
||||
modifier = Modifier.sizeIn(maxHeight = 256.dp),
|
||||
expanded = expanded,
|
||||
onDismissRequest = { expanded = false },
|
||||
) {
|
||||
validCodes.forEach { value ->
|
||||
DropdownMenuItem(onClick = {
|
||||
setVersionNumber(value)
|
||||
expanded = false
|
||||
}) {
|
||||
val text = if (versionNumber == value) {
|
||||
stringResource(id = R.string.selected_version_number, value)
|
||||
} else if (value == 14) {
|
||||
stringResource(id = R.string.recommended_max, value)
|
||||
} else {
|
||||
"$value"
|
||||
}
|
||||
Text(text = text)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,35 @@
|
|||
package org.fnives.android.qrcodetransfer.read
|
||||
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.material.Button
|
||||
import androidx.compose.material.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import org.fnives.android.qrcodetransfer.R
|
||||
import org.fnives.android.qrcodetransfer.copyToClipboard
|
||||
import org.fnives.android.qrcodetransfer.isMaybeLink
|
||||
import org.fnives.android.qrcodetransfer.openLink
|
||||
|
||||
@Composable
|
||||
fun ActionRow(readState: ReadState?) {
|
||||
val context = LocalContext.current
|
||||
|
||||
Row(Modifier.alpha(if (readState?.smallestMissingIndex == null) 1f else 0f)) {
|
||||
Spacer(Modifier.weight(1f))
|
||||
Button(onClick = { context.copyToClipboard(readState?.currentText.orEmpty()) }) {
|
||||
Text(stringResource(id = R.string.copy))
|
||||
}
|
||||
if (readState?.currentText?.isMaybeLink() == true) {
|
||||
Spacer(Modifier.width(16.dp))
|
||||
Button(onClick = { context.openLink(readState.currentText.orEmpty()) }) {
|
||||
Text(stringResource(id = R.string.open))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,112 @@
|
|||
package org.fnives.android.qrcodetransfer.read
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import android.view.ViewGroup
|
||||
import android.widget.LinearLayout
|
||||
import androidx.annotation.WorkerThread
|
||||
import androidx.camera.view.LifecycleCameraController
|
||||
import androidx.camera.view.PreviewView
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material.Scaffold
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.toArgb
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalLifecycleOwner
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
import java.time.Duration
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.flow.filterNotNull
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
fun interface PreviewProcessor {
|
||||
|
||||
@WorkerThread
|
||||
fun process(image: Bitmap)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun CameraView(
|
||||
interval: Duration,
|
||||
processImage: PreviewProcessor,
|
||||
backgroundColor: Color = Color.Black,
|
||||
) {
|
||||
val lifecycleOwner = LocalLifecycleOwner.current
|
||||
val context = LocalContext.current
|
||||
val cameraController = remember { LifecycleCameraController(context) }
|
||||
val bitmapReaderScope = rememberCoroutineScope()
|
||||
val bitmapStream = MutableStateFlow<Bitmap?>(null)
|
||||
|
||||
ImageProcessingEffect(bitmapStream, processImage)
|
||||
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
AndroidView(
|
||||
modifier = Modifier
|
||||
.fillMaxSize(),
|
||||
factory = { context ->
|
||||
PreviewView(context).apply {
|
||||
setBackgroundColor(backgroundColor.toArgb())
|
||||
layoutParams = LinearLayout.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
ViewGroup.LayoutParams.MATCH_PARENT
|
||||
)
|
||||
scaleType = PreviewView.ScaleType.FIT_CENTER
|
||||
implementationMode = PreviewView.ImplementationMode.PERFORMANCE
|
||||
|
||||
|
||||
controller = cameraController
|
||||
cameraController.bindToLifecycle(lifecycleOwner)
|
||||
|
||||
bitmapReaderScope.launch {
|
||||
while (isActive) {
|
||||
delay(interval.toMillis())
|
||||
bitmapStream.value = bitmap
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
onRelease = {
|
||||
cameraController.unbind()
|
||||
},
|
||||
update = {
|
||||
it.setBackgroundColor(backgroundColor.toArgb())
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ImageProcessingEffect(
|
||||
bitmapStream: Flow<Bitmap?>,
|
||||
processImage: PreviewProcessor
|
||||
) {
|
||||
DisposableEffect(processImage) {
|
||||
val processScope = CoroutineScope(Dispatchers.IO)
|
||||
processScope.launch {
|
||||
bitmapStream
|
||||
.filterNotNull()
|
||||
.collectLatest {
|
||||
processImage.process(it)
|
||||
}
|
||||
}
|
||||
|
||||
onDispose {
|
||||
processScope.cancel()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,25 @@
|
|||
package org.fnives.android.qrcodetransfer.read
|
||||
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.material.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import org.fnives.android.qrcodetransfer.R
|
||||
import org.fnives.android.qrcodetransfer.toOrdinal
|
||||
|
||||
@Composable
|
||||
fun PageInfo(readState: ReadState?) {
|
||||
val missingIndex = readState?.smallestMissingIndex
|
||||
if (missingIndex != null) {
|
||||
Text(
|
||||
text = stringResource(
|
||||
id = R.string.next_qr_code,
|
||||
(missingIndex + 1).toOrdinal()
|
||||
)
|
||||
)
|
||||
Spacer(Modifier.height(16.dp))
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,51 @@
|
|||
package org.fnives.android.qrcodetransfer.read
|
||||
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.material.Button
|
||||
import androidx.compose.material.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.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import com.google.accompanist.permissions.ExperimentalPermissionsApi
|
||||
import com.google.accompanist.permissions.PermissionState
|
||||
import com.google.accompanist.permissions.shouldShowRationale
|
||||
import org.fnives.android.qrcodetransfer.R
|
||||
import org.fnives.android.qrcodetransfer.openAppSettings
|
||||
|
||||
@OptIn(ExperimentalPermissionsApi::class)
|
||||
@Composable
|
||||
fun PermissionRequester(permissionState: PermissionState) {
|
||||
var wasRequested by remember { mutableStateOf(false) }
|
||||
val context = LocalContext.current
|
||||
|
||||
Column(
|
||||
Modifier.fillMaxSize(),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Spacer(Modifier.weight(1f))
|
||||
Text(stringResource(id = R.string.camera_required))
|
||||
Button(onClick = {
|
||||
if (wasRequested && !permissionState.status.shouldShowRationale) {
|
||||
context.openAppSettings()
|
||||
} else {
|
||||
permissionState.launchPermissionRequest()
|
||||
wasRequested = true
|
||||
}
|
||||
}) {
|
||||
if (wasRequested && !permissionState.status.shouldShowRationale) {
|
||||
Text(stringResource(id = R.string.open_settings))
|
||||
} else {
|
||||
Text(stringResource(id = R.string.allow))
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.weight(1f))
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,121 @@
|
|||
package org.fnives.android.qrcodetransfer.read
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.text.selection.SelectionContainer
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.LocalTextStyle
|
||||
import androidx.compose.material.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.draw.alpha
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.TextUnit
|
||||
import androidx.compose.ui.unit.TextUnitType
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.google.accompanist.permissions.ExperimentalPermissionsApi
|
||||
import com.google.accompanist.permissions.PermissionStatus
|
||||
import com.google.accompanist.permissions.rememberPermissionState
|
||||
import java.time.Duration
|
||||
import org.fnives.android.qrcodetransfer.R
|
||||
import org.fnives.android.qrcodetransfer.SequenceProtocol
|
||||
import org.fnives.android.qrcodetransfer.create.Base64EncodeCheckbox
|
||||
import org.fnives.android.qrcodetransfer.toBinaryBitmap
|
||||
|
||||
|
||||
@OptIn(ExperimentalPermissionsApi::class)
|
||||
@Composable
|
||||
fun ReadQRCode() {
|
||||
val permissionState = rememberPermissionState(permission = "android.permission.CAMERA")
|
||||
|
||||
if (permissionState.status == PermissionStatus.Granted) {
|
||||
QRCodeReader()
|
||||
} else {
|
||||
PermissionRequester(permissionState)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun QRCodeReader() {
|
||||
var readState by remember { mutableStateOf<ReadState?>(null) }
|
||||
var encodeBase64 by remember { mutableStateOf<Boolean>(SequenceProtocol.encodeBase64) }
|
||||
val textScrollState = rememberScrollState()
|
||||
|
||||
Column {
|
||||
Box(
|
||||
Modifier
|
||||
.weight(1f)
|
||||
.fillMaxWidth(),
|
||||
) {
|
||||
CameraView(interval = Duration.ofSeconds(1), processImage = {
|
||||
readState = processImage(it, readState)
|
||||
})
|
||||
}
|
||||
Column {
|
||||
Base64EncodeCheckbox(encode = encodeBase64, setEncode = {
|
||||
SequenceProtocol.encodeBase64 = it
|
||||
readState = null
|
||||
encodeBase64 = it
|
||||
})
|
||||
Column(
|
||||
Modifier
|
||||
.padding(24.dp)
|
||||
.alpha(if (readState == null) 0f else 1f)
|
||||
) {
|
||||
PageInfo(readState = readState)
|
||||
|
||||
Text(
|
||||
text = stringResource(id = if (readState?.smallestMissingIndex == null) R.string.content_read else R.string.content_read_partial),
|
||||
style = LocalTextStyle.current.copy(fontSize = TextUnit(12f, TextUnitType.Sp))
|
||||
)
|
||||
Spacer(Modifier.height(4.dp))
|
||||
SelectionContainer {
|
||||
Text(
|
||||
modifier = Modifier
|
||||
.heightIn(max = 128.dp)
|
||||
.verticalScroll(textScrollState),
|
||||
text = readState?.currentText.orEmpty(),
|
||||
)
|
||||
}
|
||||
|
||||
ActionRow(readState)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun processImage(bitmap: Bitmap, readState: ReadState?): ReadState? {
|
||||
val readResult = SequenceProtocol.read(bitmap.toBinaryBitmap()) ?: return readState
|
||||
if (readResult.sequenceInfo is SequenceProtocol.SequenceInfo.NotSequence) {
|
||||
return ReadState(
|
||||
length = 1,
|
||||
parts = mapOf(0 to readResult.sequenceInfo.content)
|
||||
)
|
||||
}
|
||||
if (readResult.sequenceInfo is SequenceProtocol.SequenceInfo.SequenceElement) {
|
||||
val currentMap =
|
||||
mapOf(readResult.sequenceInfo.current to readResult.sequenceInfo.content)
|
||||
val parts =
|
||||
if (readResult.sequenceInfo.current == 0 || readState?.length != readResult.sequenceInfo.length) {
|
||||
currentMap
|
||||
} else {
|
||||
currentMap + readState.parts
|
||||
}
|
||||
return ReadState(
|
||||
length = readResult.sequenceInfo.length,
|
||||
parts = parts
|
||||
)
|
||||
}
|
||||
return readState
|
||||
}
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
package org.fnives.android.qrcodetransfer.read
|
||||
|
||||
data class ReadState(
|
||||
val length: Int,
|
||||
val parts: Map<Int, String>,
|
||||
)
|
||||
|
||||
val ReadState.currentText: String
|
||||
get() = (0 until length).map {
|
||||
parts.getOrDefault(it, " ")
|
||||
}.joinToString(separator = "")
|
||||
|
||||
val ReadState.smallestMissingIndex: Int?
|
||||
get() =
|
||||
(0 until length).firstOrNull { parts[it] == null }
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
package org.fnives.android.qrcodetransfer.ui.theme
|
||||
|
||||
import androidx.compose.ui.graphics.Color
|
||||
|
||||
val Purple200 = Color(0xFFBB86FC)
|
||||
val Purple500 = Color(0xFF6200EE)
|
||||
val Purple700 = Color(0xFF3700B3)
|
||||
val Teal200 = Color(0xFF03DAC5)
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
package org.fnives.android.qrcodetransfer.ui.theme
|
||||
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.Shapes
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
val Shapes = Shapes(
|
||||
small = RoundedCornerShape(4.dp),
|
||||
medium = RoundedCornerShape(4.dp),
|
||||
large = RoundedCornerShape(0.dp)
|
||||
)
|
||||
|
|
@ -0,0 +1,47 @@
|
|||
package org.fnives.android.qrcodetransfer.ui.theme
|
||||
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.material.MaterialTheme
|
||||
import androidx.compose.material.darkColors
|
||||
import androidx.compose.material.lightColors
|
||||
import androidx.compose.runtime.Composable
|
||||
|
||||
private val DarkColorPalette = darkColors(
|
||||
primary = Purple200,
|
||||
primaryVariant = Purple700,
|
||||
secondary = Purple200
|
||||
)
|
||||
|
||||
private val LightColorPalette = lightColors(
|
||||
primary = Purple500,
|
||||
primaryVariant = Purple700,
|
||||
secondary = Purple500
|
||||
|
||||
/* Other default colors to override
|
||||
background = Color.White,
|
||||
surface = Color.White,
|
||||
onPrimary = Color.White,
|
||||
onSecondary = Color.Black,
|
||||
onBackground = Color.Black,
|
||||
onSurface = Color.Black,
|
||||
*/
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun QRCodeTransferTheme(
|
||||
darkTheme: Boolean = isSystemInDarkTheme(),
|
||||
content: @Composable () -> Unit
|
||||
) {
|
||||
val colors = if (darkTheme) {
|
||||
DarkColorPalette
|
||||
} else {
|
||||
LightColorPalette
|
||||
}
|
||||
|
||||
MaterialTheme(
|
||||
colors = colors,
|
||||
typography = Typography,
|
||||
shapes = Shapes,
|
||||
content = content
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
package org.fnives.android.qrcodetransfer.ui.theme
|
||||
|
||||
import androidx.compose.material.Typography
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.sp
|
||||
|
||||
// Set of Material typography styles to start with
|
||||
val Typography = Typography(
|
||||
body1 = TextStyle(
|
||||
fontFamily = FontFamily.Default,
|
||||
fontWeight = FontWeight.Normal,
|
||||
fontSize = 16.sp
|
||||
)
|
||||
/* Other default text styles to override
|
||||
button = TextStyle(
|
||||
fontFamily = FontFamily.Default,
|
||||
fontWeight = FontWeight.W500,
|
||||
fontSize = 14.sp
|
||||
),
|
||||
caption = TextStyle(
|
||||
fontFamily = FontFamily.Default,
|
||||
fontWeight = FontWeight.Normal,
|
||||
fontSize = 12.sp
|
||||
)
|
||||
*/
|
||||
)
|
||||
30
app/src/main/res/drawable-v24/ic_launcher_foreground.xml
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:aapt="http://schemas.android.com/aapt"
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:viewportWidth="108"
|
||||
android:viewportHeight="108">
|
||||
<path android:pathData="M31,63.928c0,0 6.4,-11 12.1,-13.1c7.2,-2.6 26,-1.4 26,-1.4l38.1,38.1L107,108.928l-32,-1L31,63.928z">
|
||||
<aapt:attr name="android:fillColor">
|
||||
<gradient
|
||||
android:endX="85.84757"
|
||||
android:endY="92.4963"
|
||||
android:startX="42.9492"
|
||||
android:startY="49.59793"
|
||||
android:type="linear">
|
||||
<item
|
||||
android:color="#44000000"
|
||||
android:offset="0.0" />
|
||||
<item
|
||||
android:color="#00000000"
|
||||
android:offset="1.0" />
|
||||
</gradient>
|
||||
</aapt:attr>
|
||||
</path>
|
||||
<path
|
||||
android:fillColor="#FFFFFF"
|
||||
android:fillType="nonZero"
|
||||
android:pathData="M65.3,45.828l3.8,-6.6c0.2,-0.4 0.1,-0.9 -0.3,-1.1c-0.4,-0.2 -0.9,-0.1 -1.1,0.3l-3.9,6.7c-6.3,-2.8 -13.4,-2.8 -19.7,0l-3.9,-6.7c-0.2,-0.4 -0.7,-0.5 -1.1,-0.3C38.8,38.328 38.7,38.828 38.9,39.228l3.8,6.6C36.2,49.428 31.7,56.028 31,63.928h46C76.3,56.028 71.8,49.428 65.3,45.828zM43.4,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2c-0.3,-0.7 -0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C45.3,56.528 44.5,57.328 43.4,57.328L43.4,57.328zM64.6,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2s-0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C66.5,56.528 65.6,57.328 64.6,57.328L64.6,57.328z"
|
||||
android:strokeWidth="1"
|
||||
android:strokeColor="#00000000" />
|
||||
</vector>
|
||||
170
app/src/main/res/drawable/ic_launcher_background.xml
Normal file
|
|
@ -0,0 +1,170 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:viewportWidth="108"
|
||||
android:viewportHeight="108">
|
||||
<path
|
||||
android:fillColor="#3DDC84"
|
||||
android:pathData="M0,0h108v108h-108z" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M9,0L9,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,0L19,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M29,0L29,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M39,0L39,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M49,0L49,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M59,0L59,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M69,0L69,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M79,0L79,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M89,0L89,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M99,0L99,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,9L108,9"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,19L108,19"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,29L108,29"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,39L108,39"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,49L108,49"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,59L108,59"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,69L108,69"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,79L108,79"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,89L108,89"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,99L108,99"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,29L89,29"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,39L89,39"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,49L89,49"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,59L89,59"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,69L89,69"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,79L89,79"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M29,19L29,89"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M39,19L39,89"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M49,19L49,89"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M59,19L59,89"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M69,19L69,89"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M79,19L79,89"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
</vector>
|
||||
5
app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@drawable/ic_launcher_background" />
|
||||
<foreground android:drawable="@drawable/ic_launcher_foreground" />
|
||||
</adaptive-icon>
|
||||
5
app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@drawable/ic_launcher_background" />
|
||||
<foreground android:drawable="@drawable/ic_launcher_foreground" />
|
||||
</adaptive-icon>
|
||||
6
app/src/main/res/mipmap-anydpi-v33/ic_launcher.xml
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@drawable/ic_launcher_background" />
|
||||
<foreground android:drawable="@drawable/ic_launcher_foreground" />
|
||||
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
|
||||
</adaptive-icon>
|
||||
BIN
app/src/main/res/mipmap-hdpi/ic_launcher.webp
Normal file
|
After Width: | Height: | Size: 1.4 KiB |
BIN
app/src/main/res/mipmap-hdpi/ic_launcher_round.webp
Normal file
|
After Width: | Height: | Size: 2.8 KiB |
BIN
app/src/main/res/mipmap-mdpi/ic_launcher.webp
Normal file
|
After Width: | Height: | Size: 982 B |
BIN
app/src/main/res/mipmap-mdpi/ic_launcher_round.webp
Normal file
|
After Width: | Height: | Size: 1.7 KiB |
BIN
app/src/main/res/mipmap-xhdpi/ic_launcher.webp
Normal file
|
After Width: | Height: | Size: 1.9 KiB |
BIN
app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp
Normal file
|
After Width: | Height: | Size: 3.8 KiB |
BIN
app/src/main/res/mipmap-xxhdpi/ic_launcher.webp
Normal file
|
After Width: | Height: | Size: 2.8 KiB |
BIN
app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp
Normal file
|
After Width: | Height: | Size: 5.8 KiB |
BIN
app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp
Normal file
|
After Width: | Height: | Size: 3.8 KiB |
BIN
app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp
Normal file
|
After Width: | Height: | Size: 7.6 KiB |
22
app/src/main/res/values/strings.xml
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
<resources>
|
||||
<string name="app_name">QRCodeTransfer</string>
|
||||
|
||||
<string name="previous">Previous</string>
|
||||
<string name="next">Next</string>
|
||||
<string name="create">Create</string>
|
||||
<string name="version_number">Version %1$d</string>
|
||||
<string name="selected_version_number">%1$d (Selected)</string>
|
||||
<string name="recommended_max">%1$d (Recommended Max)</string>
|
||||
<string name="encode_base64">Encode in Base64</string>
|
||||
<string name="camera_required">Camera Permission is required!</string>
|
||||
<string name="open_settings">Open Settings</string>
|
||||
<string name="allow">Allow</string>
|
||||
<string name="content_read_partial">Content read so far:</string>
|
||||
<string name="content_read">Content read:</string>
|
||||
<string name="next_qr_code">Navigate to %1$s QR Code</string>
|
||||
<string name="qr_code_content">QRCode Content</string>
|
||||
<string name="copy">Copy</string>
|
||||
<string name="open">Open</string>
|
||||
<string name="read_qr_code">Scan QR Code</string>
|
||||
<string name="create_qr_code">Create QR Code</string>
|
||||
</resources>
|
||||
6
app/src/main/res/values/themes.xml
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
|
||||
<style name="Theme.QRCodeTransfer" parent="android:Theme.Material.Light.NoActionBar">
|
||||
</style>
|
||||
</resources>
|
||||
13
app/src/main/res/xml/backup_rules.xml
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
<?xml version="1.0" encoding="utf-8"?><!--
|
||||
Sample backup rules file; uncomment and customize as necessary.
|
||||
See https://developer.android.com/guide/topics/data/autobackup
|
||||
for details.
|
||||
Note: This file is ignored for devices older that API 31
|
||||
See https://developer.android.com/about/versions/12/backup-restore
|
||||
-->
|
||||
<full-backup-content>
|
||||
<!--
|
||||
<include domain="sharedpref" path="."/>
|
||||
<exclude domain="sharedpref" path="device.xml"/>
|
||||
-->
|
||||
</full-backup-content>
|
||||
19
app/src/main/res/xml/data_extraction_rules.xml
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
<?xml version="1.0" encoding="utf-8"?><!--
|
||||
Sample data extraction rules file; uncomment and customize as necessary.
|
||||
See https://developer.android.com/about/versions/12/backup-restore#xml-changes
|
||||
for details.
|
||||
-->
|
||||
<data-extraction-rules>
|
||||
<cloud-backup>
|
||||
<!-- TODO: Use <include> and <exclude> to control what is backed up.
|
||||
<include .../>
|
||||
<exclude .../>
|
||||
-->
|
||||
</cloud-backup>
|
||||
<!--
|
||||
<device-transfer>
|
||||
<include .../>
|
||||
<exclude .../>
|
||||
</device-transfer>
|
||||
-->
|
||||
</data-extraction-rules>
|
||||