diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e6bc980 --- /dev/null +++ b/.gitignore @@ -0,0 +1,17 @@ +*.iml +.gradle +/local.properties +/.idea/caches +/.idea/libraries +/.idea/modules.xml +/.idea/workspace.xml +/.idea/navEditor.xml +/.idea/assetWizardSettings.xml +/.idea/deploymentTargetDropDown.xml +/.idea/androidTestResultsUserPreferences.xml +.DS_Store +/build +/captures +.externalNativeBuild +.cxx +local.properties diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..26d3352 --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,3 @@ +# Default ignored files +/shelf/ +/workspace.xml diff --git a/.idea/compiler.xml b/.idea/compiler.xml new file mode 100644 index 0000000..fb7f4a8 --- /dev/null +++ b/.idea/compiler.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/.idea/gradle.xml b/.idea/gradle.xml new file mode 100644 index 0000000..a2d7c21 --- /dev/null +++ b/.idea/gradle.xml @@ -0,0 +1,19 @@ + + + + + + + \ No newline at end of file diff --git a/.idea/inspectionProfiles/Project_Default.xml b/.idea/inspectionProfiles/Project_Default.xml new file mode 100644 index 0000000..103e00c --- /dev/null +++ b/.idea/inspectionProfiles/Project_Default.xml @@ -0,0 +1,32 @@ + + + + \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml new file mode 100644 index 0000000..bdd9278 --- /dev/null +++ b/.idea/misc.xml @@ -0,0 +1,10 @@ + + + + + + + + + \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 0000000..94a25f7 --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/app/.gitignore b/app/.gitignore new file mode 100644 index 0000000..42afabf --- /dev/null +++ b/app/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/app/build.gradle b/app/build.gradle new file mode 100644 index 0000000..17785dd --- /dev/null +++ b/app/build.gradle @@ -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" +} \ No newline at end of file diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro new file mode 100644 index 0000000..481bb43 --- /dev/null +++ b/app/proguard-rules.pro @@ -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 \ No newline at end of file diff --git a/app/src/androidTest/java/org/fnives/android/qrcodetransfer/QRCodeTest.kt b/app/src/androidTest/java/org/fnives/android/qrcodetransfer/QRCodeTest.kt new file mode 100644 index 0000000..881c758 --- /dev/null +++ b/app/src/androidTest/java/org/fnives/android/qrcodetransfer/QRCodeTest.kt @@ -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() + + 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) + } + } +} \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..a0e80fd --- /dev/null +++ b/app/src/main/AndroidManifest.xml @@ -0,0 +1,29 @@ + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/java/org/fnives/android/qrcodetransfer/MainActivity.kt b/app/src/main/java/org/fnives/android/qrcodetransfer/MainActivity.kt new file mode 100644 index 0000000..4f33ea8 --- /dev/null +++ b/app/src/main/java/org/fnives/android/qrcodetransfer/MainActivity.kt @@ -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)) }, + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/org/fnives/android/qrcodetransfer/SequenceProtocol.kt b/app/src/main/java/org/fnives/android/qrcodetransfer/SequenceProtocol.kt new file mode 100644 index 0000000..3812c59 --- /dev/null +++ b/app/src/main/java/org/fnives/android/qrcodetransfer/SequenceProtocol.kt @@ -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() + 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 { + 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 + } +} \ No newline at end of file diff --git a/app/src/main/java/org/fnives/android/qrcodetransfer/Util.kt b/app/src/main/java/org/fnives/android/qrcodetransfer/Util.kt new file mode 100644 index 0000000..8123c77 --- /dev/null +++ b/app/src/main/java/org/fnives/android/qrcodetransfer/Util.kt @@ -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) { + + } +} \ No newline at end of file diff --git a/app/src/main/java/org/fnives/android/qrcodetransfer/create/Base64EncodeCheckbox.kt b/app/src/main/java/org/fnives/android/qrcodetransfer/create/Base64EncodeCheckbox.kt new file mode 100644 index 0000000..82d3bd2 --- /dev/null +++ b/app/src/main/java/org/fnives/android/qrcodetransfer/create/Base64EncodeCheckbox.kt @@ -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)) + } +} \ No newline at end of file diff --git a/app/src/main/java/org/fnives/android/qrcodetransfer/create/CreateQRCode.kt b/app/src/main/java/org/fnives/android/qrcodetransfer/create/CreateQRCode.kt new file mode 100644 index 0000000..7784ef1 --- /dev/null +++ b/app/src/main/java/org/fnives/android/qrcodetransfer/create/CreateQRCode.kt @@ -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()) } + 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, + 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, + setLoading: (Boolean) -> Unit, + setBitmaps: (List) -> 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)) + } + } +} diff --git a/app/src/main/java/org/fnives/android/qrcodetransfer/create/QRCodeVersionNumberDropdown.kt b/app/src/main/java/org/fnives/android/qrcodetransfer/create/QRCodeVersionNumberDropdown.kt new file mode 100644 index 0000000..9580760 --- /dev/null +++ b/app/src/main/java/org/fnives/android/qrcodetransfer/create/QRCodeVersionNumberDropdown.kt @@ -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) + } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/fnives/android/qrcodetransfer/read/ActionRow.kt b/app/src/main/java/org/fnives/android/qrcodetransfer/read/ActionRow.kt new file mode 100644 index 0000000..9204b9c --- /dev/null +++ b/app/src/main/java/org/fnives/android/qrcodetransfer/read/ActionRow.kt @@ -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)) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/fnives/android/qrcodetransfer/read/CameraView.kt b/app/src/main/java/org/fnives/android/qrcodetransfer/read/CameraView.kt new file mode 100644 index 0000000..1e424ed --- /dev/null +++ b/app/src/main/java/org/fnives/android/qrcodetransfer/read/CameraView.kt @@ -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(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, + processImage: PreviewProcessor +) { + DisposableEffect(processImage) { + val processScope = CoroutineScope(Dispatchers.IO) + processScope.launch { + bitmapStream + .filterNotNull() + .collectLatest { + processImage.process(it) + } + } + + onDispose { + processScope.cancel() + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/fnives/android/qrcodetransfer/read/PageInfo.kt b/app/src/main/java/org/fnives/android/qrcodetransfer/read/PageInfo.kt new file mode 100644 index 0000000..c88bc17 --- /dev/null +++ b/app/src/main/java/org/fnives/android/qrcodetransfer/read/PageInfo.kt @@ -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)) + } +} \ No newline at end of file diff --git a/app/src/main/java/org/fnives/android/qrcodetransfer/read/PermissionRequester.kt b/app/src/main/java/org/fnives/android/qrcodetransfer/read/PermissionRequester.kt new file mode 100644 index 0000000..8795e7b --- /dev/null +++ b/app/src/main/java/org/fnives/android/qrcodetransfer/read/PermissionRequester.kt @@ -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)) + } +} \ No newline at end of file diff --git a/app/src/main/java/org/fnives/android/qrcodetransfer/read/ReadQRCode.kt b/app/src/main/java/org/fnives/android/qrcodetransfer/read/ReadQRCode.kt new file mode 100644 index 0000000..c76d3b6 --- /dev/null +++ b/app/src/main/java/org/fnives/android/qrcodetransfer/read/ReadQRCode.kt @@ -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(null) } + var encodeBase64 by remember { mutableStateOf(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 +} \ No newline at end of file diff --git a/app/src/main/java/org/fnives/android/qrcodetransfer/read/ReadState.kt b/app/src/main/java/org/fnives/android/qrcodetransfer/read/ReadState.kt new file mode 100644 index 0000000..f4eff79 --- /dev/null +++ b/app/src/main/java/org/fnives/android/qrcodetransfer/read/ReadState.kt @@ -0,0 +1,15 @@ +package org.fnives.android.qrcodetransfer.read + +data class ReadState( + val length: Int, + val parts: Map, +) + +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 } \ No newline at end of file diff --git a/app/src/main/java/org/fnives/android/qrcodetransfer/ui/theme/Color.kt b/app/src/main/java/org/fnives/android/qrcodetransfer/ui/theme/Color.kt new file mode 100644 index 0000000..9613f8c --- /dev/null +++ b/app/src/main/java/org/fnives/android/qrcodetransfer/ui/theme/Color.kt @@ -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) \ No newline at end of file diff --git a/app/src/main/java/org/fnives/android/qrcodetransfer/ui/theme/Shape.kt b/app/src/main/java/org/fnives/android/qrcodetransfer/ui/theme/Shape.kt new file mode 100644 index 0000000..1b2b6f5 --- /dev/null +++ b/app/src/main/java/org/fnives/android/qrcodetransfer/ui/theme/Shape.kt @@ -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) +) \ No newline at end of file diff --git a/app/src/main/java/org/fnives/android/qrcodetransfer/ui/theme/Theme.kt b/app/src/main/java/org/fnives/android/qrcodetransfer/ui/theme/Theme.kt new file mode 100644 index 0000000..d9919c3 --- /dev/null +++ b/app/src/main/java/org/fnives/android/qrcodetransfer/ui/theme/Theme.kt @@ -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 + ) +} \ No newline at end of file diff --git a/app/src/main/java/org/fnives/android/qrcodetransfer/ui/theme/Type.kt b/app/src/main/java/org/fnives/android/qrcodetransfer/ui/theme/Type.kt new file mode 100644 index 0000000..0a001b1 --- /dev/null +++ b/app/src/main/java/org/fnives/android/qrcodetransfer/ui/theme/Type.kt @@ -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 + ) + */ +) \ No newline at end of file diff --git a/app/src/main/res/drawable-v24/ic_launcher_foreground.xml b/app/src/main/res/drawable-v24/ic_launcher_foreground.xml new file mode 100644 index 0000000..2b068d1 --- /dev/null +++ b/app/src/main/res/drawable-v24/ic_launcher_foreground.xml @@ -0,0 +1,30 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_launcher_background.xml b/app/src/main/res/drawable/ic_launcher_background.xml new file mode 100644 index 0000000..07d5da9 --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_background.xml @@ -0,0 +1,170 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 0000000..eca70cf --- /dev/null +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml new file mode 100644 index 0000000..eca70cf --- /dev/null +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/mipmap-anydpi-v33/ic_launcher.xml b/app/src/main/res/mipmap-anydpi-v33/ic_launcher.xml new file mode 100644 index 0000000..6f3b755 --- /dev/null +++ b/app/src/main/res/mipmap-anydpi-v33/ic_launcher.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher.webp b/app/src/main/res/mipmap-hdpi/ic_launcher.webp new file mode 100644 index 0000000..c209e78 Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp new file mode 100644 index 0000000..b2dfe3d Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher.webp b/app/src/main/res/mipmap-mdpi/ic_launcher.webp new file mode 100644 index 0000000..4f0f1d6 Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp new file mode 100644 index 0000000..62b611d Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher.webp b/app/src/main/res/mipmap-xhdpi/ic_launcher.webp new file mode 100644 index 0000000..948a307 Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp new file mode 100644 index 0000000..1b9a695 Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp b/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp new file mode 100644 index 0000000..28d4b77 Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp new file mode 100644 index 0000000..9287f50 Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp new file mode 100644 index 0000000..aa7d642 Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp new file mode 100644 index 0000000..9126ae3 Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..e6e6a95 --- /dev/null +++ b/app/src/main/res/values/strings.xml @@ -0,0 +1,22 @@ + + QRCodeTransfer + + Previous + Next + Create + Version %1$d + %1$d (Selected) + %1$d (Recommended Max) + Encode in Base64 + Camera Permission is required! + Open Settings + Allow + Content read so far: + Content read: + Navigate to %1$s QR Code + QRCode Content + Copy + Open + Scan QR Code + Create QR Code + \ No newline at end of file diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml new file mode 100644 index 0000000..fe05651 --- /dev/null +++ b/app/src/main/res/values/themes.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/xml/backup_rules.xml b/app/src/main/res/xml/backup_rules.xml new file mode 100644 index 0000000..fa0f996 --- /dev/null +++ b/app/src/main/res/xml/backup_rules.xml @@ -0,0 +1,13 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/xml/data_extraction_rules.xml b/app/src/main/res/xml/data_extraction_rules.xml new file mode 100644 index 0000000..9ee9997 --- /dev/null +++ b/app/src/main/res/xml/data_extraction_rules.xml @@ -0,0 +1,19 @@ + + + + + + + \ No newline at end of file diff --git a/build.gradle b/build.gradle new file mode 100644 index 0000000..de8cc3a --- /dev/null +++ b/build.gradle @@ -0,0 +1,9 @@ +buildscript { + ext { + } +}// Top-level build file where you can add configuration options common to all sub-projects/modules. +plugins { + id 'com.android.application' version '7.4.2' apply false + id 'com.android.library' version '7.4.2' apply false + id 'org.jetbrains.kotlin.android' version '1.9.20' apply false +} \ No newline at end of file diff --git a/gradle.properties b/gradle.properties new file mode 100644 index 0000000..3c5031e --- /dev/null +++ b/gradle.properties @@ -0,0 +1,23 @@ +# Project-wide Gradle settings. +# IDE (e.g. Android Studio) users: +# Gradle settings configured through the IDE *will override* +# any settings specified in this file. +# For more details on how to configure your build environment visit +# http://www.gradle.org/docs/current/userguide/build_environment.html +# Specifies the JVM arguments used for the daemon process. +# The setting is particularly useful for tweaking memory settings. +org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 +# When configured, Gradle will run in incubating parallel mode. +# This option should only be used with decoupled projects. More details, visit +# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects +# org.gradle.parallel=true +# AndroidX package structure to make it clearer which packages are bundled with the +# Android operating system, and which are packaged with your app's APK +# https://developer.android.com/topic/libraries/support-library/androidx-rn +android.useAndroidX=true +# Kotlin code style for this project: "official" or "obsolete": +kotlin.code.style=official +# Enables namespacing of each library's R class so that its R class includes only the +# resources declared in the library itself and none from the library's dependencies, +# thereby reducing the size of the R class for that library +android.nonTransitiveRClass=true \ No newline at end of file diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..e708b1c Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..a504daa --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,6 @@ +#Sat Nov 18 08:19:02 EET 2023 +distributionBase=GRADLE_USER_HOME +distributionUrl=https\://services.gradle.org/distributions/gradle-7.5-bin.zip +distributionPath=wrapper/dists +zipStorePath=wrapper/dists +zipStoreBase=GRADLE_USER_HOME diff --git a/gradlew b/gradlew new file mode 100755 index 0000000..4f906e0 --- /dev/null +++ b/gradlew @@ -0,0 +1,185 @@ +#!/usr/bin/env sh + +# +# Copyright 2015 the original author or authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +############################################################################## +## +## Gradle start up script for UN*X +## +############################################################################## + +# Attempt to set APP_HOME +# Resolve links: $0 may be a link +PRG="$0" +# Need this for relative symlinks. +while [ -h "$PRG" ] ; do + ls=`ls -ld "$PRG"` + link=`expr "$ls" : '.*-> \(.*\)$'` + if expr "$link" : '/.*' > /dev/null; then + PRG="$link" + else + PRG=`dirname "$PRG"`"/$link" + fi +done +SAVED="`pwd`" +cd "`dirname \"$PRG\"`/" >/dev/null +APP_HOME="`pwd -P`" +cd "$SAVED" >/dev/null + +APP_NAME="Gradle" +APP_BASE_NAME=`basename "$0"` + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD="maximum" + +warn () { + echo "$*" +} + +die () { + echo + echo "$*" + echo + exit 1 +} + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "`uname`" in + CYGWIN* ) + cygwin=true + ;; + Darwin* ) + darwin=true + ;; + MINGW* ) + msys=true + ;; + NONSTOP* ) + nonstop=true + ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD="$JAVA_HOME/jre/sh/java" + else + JAVACMD="$JAVA_HOME/bin/java" + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD="java" + which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." +fi + +# Increase the maximum file descriptors if we can. +if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then + MAX_FD_LIMIT=`ulimit -H -n` + if [ $? -eq 0 ] ; then + if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then + MAX_FD="$MAX_FD_LIMIT" + fi + ulimit -n $MAX_FD + if [ $? -ne 0 ] ; then + warn "Could not set maximum file descriptor limit: $MAX_FD" + fi + else + warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" + fi +fi + +# For Darwin, add options to specify how the application appears in the dock +if $darwin; then + GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" +fi + +# For Cygwin or MSYS, switch paths to Windows format before running java +if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then + APP_HOME=`cygpath --path --mixed "$APP_HOME"` + CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` + + JAVACMD=`cygpath --unix "$JAVACMD"` + + # We build the pattern for arguments to be converted via cygpath + ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` + SEP="" + for dir in $ROOTDIRSRAW ; do + ROOTDIRS="$ROOTDIRS$SEP$dir" + SEP="|" + done + OURCYGPATTERN="(^($ROOTDIRS))" + # Add a user-defined pattern to the cygpath arguments + if [ "$GRADLE_CYGPATTERN" != "" ] ; then + OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" + fi + # Now convert the arguments - kludge to limit ourselves to /bin/sh + i=0 + for arg in "$@" ; do + CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` + CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option + + if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition + eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` + else + eval `echo args$i`="\"$arg\"" + fi + i=`expr $i + 1` + done + case $i in + 0) set -- ;; + 1) set -- "$args0" ;; + 2) set -- "$args0" "$args1" ;; + 3) set -- "$args0" "$args1" "$args2" ;; + 4) set -- "$args0" "$args1" "$args2" "$args3" ;; + 5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; + 6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; + 7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; + 8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; + 9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; + esac +fi + +# Escape application args +save () { + for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done + echo " " +} +APP_ARGS=`save "$@"` + +# Collect all arguments for the java command, following the shell quoting and substitution rules +eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" + +exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 0000000..107acd3 --- /dev/null +++ b/gradlew.bat @@ -0,0 +1,89 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem + +@if "%DEBUG%" == "" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%" == "" set DIRNAME=. +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if "%ERRORLEVEL%" == "0" goto execute + +echo. +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:end +@rem End local scope for the variables with windows NT shell +if "%ERRORLEVEL%"=="0" goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 +exit /b 1 + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/settings.gradle b/settings.gradle new file mode 100644 index 0000000..2ad9d22 --- /dev/null +++ b/settings.gradle @@ -0,0 +1,16 @@ +pluginManagement { + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} +dependencyResolutionManagement { + repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) + repositories { + google() + mavenCentral() + } +} +rootProject.name = "QRCodeTransfer" +include ':app'