diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 006e4ab0..e4774544 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -167,6 +167,7 @@ dependencies { implementation(Libs.jodaTime) implementation(Libs.eventBus) implementation(Libs.keyboardVisibility) + implementation("com.google.android.gms:play-services-code-scanner:16.1.0") } diff --git a/app/src/main/java/org/zotero/android/uicomponents/addbyidentifier/AddByIdentifierViewModel.kt b/app/src/main/java/org/zotero/android/uicomponents/addbyidentifier/AddByIdentifierViewModel.kt index 50010c7d..92a6bcfd 100644 --- a/app/src/main/java/org/zotero/android/uicomponents/addbyidentifier/AddByIdentifierViewModel.kt +++ b/app/src/main/java/org/zotero/android/uicomponents/addbyidentifier/AddByIdentifierViewModel.kt @@ -1,6 +1,8 @@ package org.zotero.android.uicomponents.addbyidentifier +import android.content.Context import androidx.lifecycle.viewModelScope +import com.google.mlkit.vision.codescanner.GmsBarcodeScanning import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach @@ -16,6 +18,7 @@ import org.zotero.android.database.objects.FieldKeys import org.zotero.android.files.FileStore import org.zotero.android.sync.LibraryIdentifier import org.zotero.android.sync.SchemaController +import org.zotero.android.uicomponents.addbyidentifier.data.ISBNParser import org.zotero.android.uicomponents.addbyidentifier.data.LookupRow import org.zotero.android.uicomponents.addbyidentifier.data.LookupRowItem import timber.log.Timber @@ -28,8 +31,12 @@ internal class AddByIdentifierViewModel @Inject constructor( private val attachmentDownloaderEventStream: RemoteAttachmentDownloaderEventStream, private val schemaController: SchemaController, private val remoteFileDownloader: RemoteAttachmentDownloader, + private val context: Context, ) : BaseViewModel2(AddByIdentifierViewState()) { + private val scannerPatternRegex = + "10.\\d{4,9}\\/[-._;()\\/:a-zA-Z0-9]+" + fun init() = initOnce { setupAttachmentObserving() val collectionKeys = @@ -125,6 +132,45 @@ internal class AddByIdentifierViewModel @Inject constructor( updateLookupState(State.waitingInput) } + fun process(scannedText: String) { + val identifiers = Regex(scannerPatternRegex).findAll(scannedText).map { it.value }.toMutableList() + val isbns = ISBNParser.isbns(scannedText) + if (isbns.isNotEmpty()) { + identifiers.addAll(isbns) + } + + if (identifiers.isEmpty()) { + return + } + + val scannedText = identifiers.joinToString(", ") + + var newText = viewState.identifierText + if (newText.isEmpty()) { + newText = scannedText + } else { + newText += ", " + scannedText + } + updateState { + copy(identifierText = newText) + } + } + + fun onScanText() { + val scanner = GmsBarcodeScanning.getClient(context) + scanner.startScan() + .addOnSuccessListener { barcode -> + val scannedString = barcode.rawValue ?: "" + process(scannedString) + } + .addOnCanceledListener { + // Task canceled + } + .addOnFailureListener { e -> + Timber.e(e, "Barcode scanning failed") + } + } + fun onLookup() { val identifier = viewState.identifierText.trim() if (identifier.isBlank()) { diff --git a/app/src/main/java/org/zotero/android/uicomponents/addbyidentifier/data/ISBNParser.kt b/app/src/main/java/org/zotero/android/uicomponents/addbyidentifier/data/ISBNParser.kt new file mode 100644 index 00000000..d132bf3d --- /dev/null +++ b/app/src/main/java/org/zotero/android/uicomponents/addbyidentifier/data/ISBNParser.kt @@ -0,0 +1,70 @@ +package org.zotero.android.uicomponents.addbyidentifier.data + +object ISBNParser { + + private val isbnRegexPattern = "\\b(?:97[89]\\s*(?:\\d\\s*){9}\\d|(?:\\d\\s*){9}[\\dX])\\b" + + fun isbns(string: String): List { + val cleanedString = string.replace(Regex("[\\x2D\\xAD\\u2010-\\u2015\\u2043\\u2212]+"), "") + val matches = + Regex(isbnRegexPattern).findAll(cleanedString).map { it.value }.toMutableList() + + val isbns = mutableListOf() + + for (match in matches) { + val isbn = match.replace(Regex("\\s+"), "") + + if (if (isbn.length == 10) validate10(isbn) else validate13(isbn)) { + isbns.add(isbn) + } + } + + return isbns + } + + private fun validate10(isbn: String): Boolean { + var sum = 0 + + for (idx in 0..<10) { + val startIndex = idx + val endIndex = idx + 1 + val character = isbn.substring(startIndex, endIndex) + + val intValue = character.toIntOrNull() + if (intValue != null) { + sum += intValue * (10 - idx) + } else if (idx == 9 && character == "X") { + sum += 10 + } else { + sum = 1 + break + } + } + + return sum % 11 == 0 + } + + private fun validate13(isbn: String): Boolean { + var sum = 0 + + for (idx in 0..<13) { + val startIndex = idx + val endIndex = idx + 1 + val character = isbn.substring(startIndex, endIndex) + + val intValue = character.toIntOrNull() + if (intValue == null) { + sum = 1 + break + } + + if (idx % 2 == 0) { + sum += intValue + } else { + sum += intValue * 3 + } + } + + return sum % 10 == 0 + } +} \ No newline at end of file diff --git a/app/src/main/java/org/zotero/android/uicomponents/addbyidentifier/ui/AddByIdentifierUiParts.kt b/app/src/main/java/org/zotero/android/uicomponents/addbyidentifier/ui/AddByIdentifierUiParts.kt index a1be7bd9..7bdb751a 100644 --- a/app/src/main/java/org/zotero/android/uicomponents/addbyidentifier/ui/AddByIdentifierUiParts.kt +++ b/app/src/main/java/org/zotero/android/uicomponents/addbyidentifier/ui/AddByIdentifierUiParts.kt @@ -1,16 +1,21 @@ package org.zotero.android.uicomponents.addbyidentifier.ui import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyListScope import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.CircularProgressIndicator +import androidx.compose.material.Icon import androidx.compose.material.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -18,8 +23,10 @@ import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp +import org.zotero.android.uicomponents.Drawables import org.zotero.android.uicomponents.Strings import org.zotero.android.uicomponents.addbyidentifier.AddByIdentifierViewModel import org.zotero.android.uicomponents.addbyidentifier.AddByIdentifierViewState @@ -41,6 +48,10 @@ internal fun LazyListScope.addByIdentifierTitleEditFieldAndError( identifierText = viewState.identifierText, onIdentifierTextChange = viewModel::onIdentifierTextChange, ) + + Spacer(modifier = Modifier.height(20.dp)) + ScanTextButton(onClick = viewModel::onScanText) + if (failedState != null) { val errorText = when (failedState.error) { is AddByIdentifierViewModel.Error.noIdentifiersDetectedAndNoLookupData -> { @@ -121,3 +132,33 @@ internal fun LazyListScope.addByIdentifierLoadingIndicator() { } } } + +@Composable +internal fun ScanTextButton(onClick: () -> Unit) { + Column(modifier = Modifier.fillMaxWidth()) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null, + onClick = onClick + ) + .align(Alignment.CenterHorizontally) + ) { + Icon( + modifier = Modifier.size(24.dp), + painter = painterResource(id = Drawables.baseline_qr_code_scanner_24), + contentDescription = null, + tint = CustomTheme.colors.zoteroDefaultBlue, + ) + Spacer(modifier = Modifier.width(12.dp)) + Text( + text = stringResource(id = Strings.scan_text), + color = CustomTheme.colors.zoteroDefaultBlue, + style = CustomTheme.typography.newBody, + ) + } + + } +} diff --git a/app/src/main/res/drawable/baseline_qr_code_scanner_24.xml b/app/src/main/res/drawable/baseline_qr_code_scanner_24.xml new file mode 100644 index 00000000..5ab50c71 --- /dev/null +++ b/app/src/main/res/drawable/baseline_qr_code_scanner_24.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/buildSrc/src/main/kotlin/BuildConfig.kt b/buildSrc/src/main/kotlin/BuildConfig.kt index 55bbd444..9f93bdbe 100644 --- a/buildSrc/src/main/kotlin/BuildConfig.kt +++ b/buildSrc/src/main/kotlin/BuildConfig.kt @@ -4,7 +4,7 @@ object BuildConfig { const val compileSdkVersion = 34 const val targetSdk = 33 - val versionCode = 60 // Must be updated on every build + val versionCode = 61 // Must be updated on every build val version = Version( major = 1, minor = 0,