commit e714fbcb611cbb912969bd98a182821b8c55f332 Author: William Brawner Date: Thu Jul 8 18:15:24 2021 -0600 Initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..aa724b7 --- /dev/null +++ b/.gitignore @@ -0,0 +1,15 @@ +*.iml +.gradle +/local.properties +/.idea/caches +/.idea/libraries +/.idea/modules.xml +/.idea/workspace.xml +/.idea/navEditor.xml +/.idea/assetWizardSettings.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/.name b/.idea/.name new file mode 100644 index 0000000..fafc5db --- /dev/null +++ b/.idea/.name @@ -0,0 +1 @@ +Skerge \ No newline at end of file diff --git a/.idea/codeStyles/Project.xml b/.idea/codeStyles/Project.xml new file mode 100644 index 0000000..7643783 --- /dev/null +++ b/.idea/codeStyles/Project.xml @@ -0,0 +1,123 @@ + + + + + + + + + + \ No newline at end of file diff --git a/.idea/codeStyles/codeStyleConfig.xml b/.idea/codeStyles/codeStyleConfig.xml new file mode 100644 index 0000000..79ee123 --- /dev/null +++ b/.idea/codeStyles/codeStyleConfig.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file 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/deploymentTargetDropDown.xml b/.idea/deploymentTargetDropDown.xml new file mode 100644 index 0000000..b121649 --- /dev/null +++ b/.idea/deploymentTargetDropDown.xml @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/.idea/gradle.xml b/.idea/gradle.xml new file mode 100644 index 0000000..526b4c2 --- /dev/null +++ b/.idea/gradle.xml @@ -0,0 +1,20 @@ + + + + + + + \ 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..2842237 --- /dev/null +++ b/.idea/inspectionProfiles/Project_Default.xml @@ -0,0 +1,20 @@ + + + + \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml new file mode 100644 index 0000000..6199cc2 --- /dev/null +++ b/.idea/misc.xml @@ -0,0 +1,9 @@ + + + + + + + + \ 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/README.md b/README.md new file mode 100644 index 0000000..25ea1e8 --- /dev/null +++ b/README.md @@ -0,0 +1,17 @@ +# Skerge + +Skerge is an app I wrote to make scanning multiple pages into a single file less painful with my +scanner. I wrote a [blog post](https://wbrawner.com/2021/07/08/skerge/) about it in case you're +interested in reading more. + +## Screenshots + +![The main view of the app](./screenshots/main.png) + +![Scanning a document](./screenshots/scanning.png) + +![Previewing a document](./screenshots/preview.png) + +![Scanning an additional document](./screenshots/additional_scan.png) + +![Sharing a combined document](./screenshots/share.png) 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.kts b/app/build.gradle.kts new file mode 100644 index 0000000..df11045 --- /dev/null +++ b/app/build.gradle.kts @@ -0,0 +1,68 @@ +plugins { + id("com.android.application") + id("kotlin-android") +} + +android { + compileSdk = 30 + + defaultConfig { + applicationId = "com.wbrawner.skerge" + minSdk = 26 + targetSdk = 30 + versionCode = 1 + versionName = "1.0" + + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + vectorDrawables { + useSupportLibrary = true + } + } + + packagingOptions { + resources.pickFirsts.addAll(listOf("META-INF/AL2.0", "META-INF/LGPL2.1")) + } + + buildTypes { + release { + isMinifyEnabled = 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 = rootProject.extra["compose_version"] as String + } +} + +dependencies { + + implementation("androidx.core:core-ktx:1.5.0") + implementation("androidx.appcompat:appcompat:1.3.0") + implementation("com.google.android.material:material:1.3.0") + implementation("androidx.compose.ui:ui:${rootProject.extra["compose_version"]}") + implementation("androidx.compose.material:material:${rootProject.extra["compose_version"]}") + implementation("androidx.compose.ui:ui-tooling:${rootProject.extra["compose_version"]}") + implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.3.1") + implementation("androidx.activity:activity-compose:1.3.0-beta02") + val ktorVersion = "1.6.0" + implementation("io.ktor:ktor-client-cio:$ktorVersion") + implementation("io.ktor:ktor-client-android:$ktorVersion") + androidTestImplementation("io.ktor:ktor-client-mock:$ktorVersion") + testImplementation("junit:junit:4.13.2") + androidTestImplementation("androidx.test.ext:junit:1.1.2") + androidTestImplementation("androidx.test.espresso:espresso-core:3.3.0") + androidTestImplementation("androidx.compose.ui:ui-test-junit4:${rootProject.extra["compose_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/com/wbrawner/skerge/HpScannerServiceTest.kt b/app/src/androidTest/java/com/wbrawner/skerge/HpScannerServiceTest.kt new file mode 100644 index 0000000..664cabd --- /dev/null +++ b/app/src/androidTest/java/com/wbrawner/skerge/HpScannerServiceTest.kt @@ -0,0 +1,39 @@ +package com.wbrawner.skerge + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import io.ktor.client.* +import io.ktor.client.engine.mock.* +import io.ktor.http.* +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class HpScannerServiceTest { + @Test + fun requestScanTest() { + val service = HpScannerService(HttpClient(MockEngine) { + engine { + addHandler { request -> + when (request.url.encodedPath) { + "/eSCL/ScanJobs" -> respond( + "", status = HttpStatusCode.Created, headers = headersOf( + "Server" to listOf("nginx"), + "Date" to listOf("Thu, 27 Feb 2014 20:09:56 GMT"), + "Content-Length" to listOf("0"), + "Connection" to listOf("keep-alive"), + "Location" to listOf("http://brawner.print/eSCL/ScanJobs/799f6753-3ebe-40dc-8ffa-1dc83cc7da1d"), + "Cache-Control" to listOf("must-revalidate", "max-age=0"), + "Pragma" to listOf("no-cache"), + ) + ) + else -> error("Unhandled request ${request.url}") + } + } + } + }) + val scanId = runBlocking { service.requestScan() } + assertEquals("799f6753-3ebe-40dc-8ffa-1dc83cc7da1d", scanId) + } +} \ No newline at end of file diff --git a/app/src/androidTest/java/com/wbrawner/skerge/XmlSerializationTest.kt b/app/src/androidTest/java/com/wbrawner/skerge/XmlSerializationTest.kt new file mode 100644 index 0000000..aade45e --- /dev/null +++ b/app/src/androidTest/java/com/wbrawner/skerge/XmlSerializationTest.kt @@ -0,0 +1,109 @@ +package com.wbrawner.skerge + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import org.junit.Assert.assertEquals +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class XmlSerializationTest { + @Test + fun parseScannerStatusTest() { + val scannerStatus = ScannerStatus( + version = "2.63", + state = "Processing", + jobs = listOf( + JobInfo( + jobUri = "/eSCL/ScanJobs/60d1ddd4-4862-40c0-8519-4e743ecaa0a4", + jobUuid = "60d1ddd4-4862-40c0-8519-4e743ecaa0a4", + age = 6, + imagesCompleted = 0, + imagesToTransfer = 1, + jobState = "Processing", + jobStateReasons = listOf("JobScanning") + ), + JobInfo( + jobUri = "/eSCL/ScanJobs/168f23f8-d8e5-496b-a6b3-52bf5ff6c348", + jobUuid = "168f23f8-d8e5-496b-a6b3-52bf5ff6c348", + age = 11933504, + imagesCompleted = 1, + imagesToTransfer = 0, + jobState = "Completed", + jobStateReasons = listOf("JobCompletedSuccessfully") + ) + ) + ) + val parsedScannerStatus = ScannerStatus.fromXml(scannerStatusXml) + assertEquals(scannerStatus, parsedScannerStatus) + } + + @Test + fun serializeScanSettingsTest() { + val scanSettings = ScanSettings( + scanRegions = listOf(ScanRegion()) + ) + assertEquals(scanSettingsXml, scanSettings.toXml()) + } +} + +private val scanSettingsXml = """ + + 2.1 + Document + + + 3300 + 2550 + 0 + 0 + + + Platen + application/pdf + 300 + 300 + RGB24 + 25 + 1000 + 1000 + +""".trim().replace(Regex("\\s{2,}"), "").replace("\n", "").replace("\"x", "\" x") + +private val scannerStatusXml = """ + + + + 2.63 + Processing + + + /eSCL/ScanJobs/60d1ddd4-4862-40c0-8519-4e743ecaa0a4 + 60d1ddd4-4862-40c0-8519-4e743ecaa0a4 + 6 + 0 + 1 + Processing + + JobScanning + + + + /eSCL/ScanJobs/168f23f8-d8e5-496b-a6b3-52bf5ff6c348 + 168f23f8-d8e5-496b-a6b3-52bf5ff6c348 + 11933504 + 1 + 0 + Completed + + JobCompletedSuccessfully + + + + +""".trim().replace(Regex("\\s{2,}"), "").replace("\n", "") diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..e392ea1 --- /dev/null +++ b/app/src/main/AndroidManifest.xml @@ -0,0 +1,37 @@ + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/java/com/wbrawner/skerge/MainActivity.kt b/app/src/main/java/com/wbrawner/skerge/MainActivity.kt new file mode 100644 index 0000000..5540bc7 --- /dev/null +++ b/app/src/main/java/com/wbrawner/skerge/MainActivity.kt @@ -0,0 +1,170 @@ +package com.wbrawner.skerge + +import android.graphics.Bitmap +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.activity.viewModels +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.material.* +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Share +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.lifecycle.lifecycleScope +import com.wbrawner.skerge.ui.theme.SkergeTheme +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.launch +import java.io.File + +class MainActivity : ComponentActivity() { + private val viewModel: MainViewModel by viewModels() + private val scansDirectory: File by lazy { File(cacheDir, "scans") } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContent { + SkergeTheme { + ScanScreen( + addButtonClicked = { viewModel.requestScan(scansDirectory) }, + shareButtonClicked = { sharePdf(viewModel.pages.replayCache.first()) }, + pagesFlow = viewModel.pages + ) + } + } + } + + private fun sharePdf(pages: List) { + // TODO: Show loading dialog for this + lifecycleScope.launch { + val file = if (pages.size == 1) { + pages.first().file ?: return@launch + } else { + pages.merge() + } + startActivity(file.buildShareIntent(this@MainActivity)) + } + } +} + +@Composable +fun ScanScreen( + addButtonClicked: () -> Unit, + shareButtonClicked: () -> Unit, + pagesFlow: SharedFlow> +) { + val pages = pagesFlow.collectAsState(initial = emptyList()) + Scaffold( + modifier = Modifier.fillMaxSize(), + topBar = { + TopAppBar( + title = { Text("Skerge") }, + actions = { + IconButton(onClick = shareButtonClicked) { + Icon(imageVector = Icons.Default.Share, contentDescription = "Share") + } + } + ) + }, + floatingActionButton = { + if (pages.value.none { it.file == null }) { + FloatingActionButton(onClick = addButtonClicked) { + Icon(imageVector = Icons.Default.Add, contentDescription = "Add") + } + } + } + ) { + if (pages.value.isEmpty()) { + EmptyDocumentView() + } else { + PageList(pages.value) + } + } +} + +@Composable +fun EmptyDocumentView() { + Column( + modifier = Modifier + .fillMaxSize() + .padding(16.dp), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + text = "Place your document on the scanner, then press the add button to scan. Repeat " + + "the process for each page you want to scan then press the Share button to " + + "combine the pages into a single PDF to send to another app.", + textAlign = TextAlign.Center + ) + } +} + +@Composable +fun PageList(pages: List) { + LazyColumn(modifier = Modifier.fillMaxSize()) { + itemsIndexed(items = pages) { index, page -> + val topPadding = if (index == 0) 16.dp else 8.dp + val bottomPadding = if (index == pages.size - 1) 16.dp else 8.dp + Card( + modifier = Modifier + .fillMaxWidth() + .padding(start = 16.dp, end = 16.dp, top = topPadding, bottom = bottomPadding) + .aspectRatio(8.5f / 11), + ) { + PagePreview(page = page) + } + } + } +} + +@Composable +fun PagePreview(page: Page) { + val (pageBitmap, setPageBitmap) = remember { mutableStateOf(null) } + LaunchedEffect(page.file) { + setPageBitmap(page.loadBitmap().first) + } + pageBitmap?.let { + Image( + modifier = Modifier.fillMaxSize(), + bitmap = it.asImageBitmap(), + contentDescription = null + ) + } ?: LoadingPage() +} + +@Composable +fun LoadingPage() { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator() + } +} + +@Preview(showBackground = true) +@Composable +fun DefaultPreview() { + SkergeTheme { + ScanScreen({}, {}, MutableSharedFlow()) + } +} + +@Preview(showBackground = true) +@Composable +fun LoadingPagePreview() { + SkergeTheme { + LoadingPage() + } +} \ No newline at end of file diff --git a/app/src/main/java/com/wbrawner/skerge/MainViewModel.kt b/app/src/main/java/com/wbrawner/skerge/MainViewModel.kt new file mode 100644 index 0000000..6815351 --- /dev/null +++ b/app/src/main/java/com/wbrawner/skerge/MainViewModel.kt @@ -0,0 +1,115 @@ +package com.wbrawner.skerge + +import android.util.Log +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.* +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asSharedFlow +import java.io.File +import java.io.IOException +import java.util.* + +class MainViewModel(private val scannerService: ScannerService = HpScannerService()) : ViewModel() { + private val _pages = MutableStateFlow>(emptyList()) + val pages = _pages.asSharedFlow() + + fun requestScan(fileDirectory: File) { + val page = Page() + _pages.value = _pages.value.toMutableList().apply { + add(page) + } + viewModelScope.launch { + if (!fileDirectory.exists()) { + fileDirectory.mkdirs() + } + val pageId = try { + scannerService.requestScan() + } catch (e: Exception) { + updatePage(null) { + it.copy(id = UUID.randomUUID().toString(), error = e) + } + return@launch + } + updatePage(null) { + it.copy(id = pageId) + } + val downloadJob = async { + // We have to open the connection to download the file before it's even ready, + // because otherwise the scanner will cancel the scan job since it thinks you no + // longer want the scan + val pdf = File(fileDirectory, "${pageId}.pdf") + Log.v("MainViewModel", "Downloading file for job $pageId") + scannerService.downloadFile(pageId, pdf) { downloaded, size -> + Log.v("MainViewModel", "Progress for job ${pageId}, $downloaded of $size") + updatePage(pageId) { + it.copy(downloaded = downloaded, size = size) + } + } + Log.v("MainViewModel", "Job $pageId complete") + updatePage(pageId) { + it.copy(file = pdf) + } + } + var timeElapsed = 0 + while (coroutineContext.isActive) { + delay(3000) + timeElapsed += 3000 + Log.v("MainViewModel", "Checking scan status for job $pageId") + val scanStatus = scannerService.getScanStatus(pageId).jobs + .firstOrNull { it.jobUuid == pageId } + ?: continue + Log.v( + "MainViewModel", + "Job $pageId status: ${scanStatus.jobState} Reasons: ${ + scanStatus.jobStateReasons.joinToString(", ") + }" + ) + if (scanStatus.completed) break + if (scanStatus.aborted) { + updatePage(pageId) { + it.copy(error = Exception("Scan job aborted: ${scanStatus.jobStateReasons.first()}")) + } + downloadJob.cancel() + return@launch + } + if (timeElapsed >= 60_000) { + // If it's been more than a minute then something is wrong, abort + updatePage(pageId) { + it.copy(error = IOException("Scan timeout")) + } + downloadJob.cancel() + return@launch + } + } + downloadJob.await() + } + } + + fun removePage(page: Page) { + updatePage(page.id) { null } + } + + /** + * Update the page with the given id using the given function and publish the results + * @param id The id of the page to update + * @param updates A function to call on the page to update it. Return null to remove the page + * from the list + */ + private fun updatePage(id: String?, updates: (Page) -> Page?) { + val updatedPages = _pages.value.toMutableList() + val pageIndex = updatedPages.indexOfFirst { it.id == id } + updates(updatedPages.removeAt(pageIndex))?.let { + updatedPages.add(pageIndex, it) + } + _pages.value = updatedPages + } +} + +data class Page( + val id: String? = null, + val downloaded: Long = 0, + val size: Long = 0, + val file: File? = null, + val error: Exception? = null +) diff --git a/app/src/main/java/com/wbrawner/skerge/PdfUtils.kt b/app/src/main/java/com/wbrawner/skerge/PdfUtils.kt new file mode 100644 index 0000000..51f1568 --- /dev/null +++ b/app/src/main/java/com/wbrawner/skerge/PdfUtils.kt @@ -0,0 +1,77 @@ +package com.wbrawner.skerge + +import android.content.Context +import android.content.Intent +import android.graphics.Bitmap +import android.graphics.Matrix +import android.graphics.pdf.PdfDocument +import android.graphics.pdf.PdfRenderer +import android.os.ParcelFileDescriptor +import androidx.core.content.FileProvider +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import java.io.File +import java.time.Instant +import kotlin.math.round + +suspend fun Page.loadBitmap(renderMode: Int = PdfRenderer.Page.RENDER_MODE_FOR_DISPLAY): Triple = + withContext(Dispatchers.IO) { + if (file == null) return@withContext Triple(null, 0, 0) + PdfRenderer(ParcelFileDescriptor.open(file, ParcelFileDescriptor.MODE_READ_ONLY)) + .openPage(0) + .use { page -> + Triple( + Bitmap.createBitmap( + page.width.toPx(), + page.height.toPx(), + Bitmap.Config.ARGB_8888 + ).apply { + page.render(this, null, null, renderMode) + }, + page.width, + page.height + ) + } + } + +fun File.buildShareIntent(context: Context): Intent = + Intent.createChooser(Intent(Intent.ACTION_SEND).apply { + val uri = FileProvider.getUriForFile( + context, + "com.wbrawner.skerge.pdfprovider", + this@buildShareIntent + ) + data = uri + putExtra(Intent.EXTRA_STREAM, uri) + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + }, name) + +private const val SCALE_FACTOR = 0.25f + +suspend fun List.merge(): File { + val pdf = PdfDocument() + var pageCount = 0 + var scansDirectory: File? = null + for (page in this) { + scansDirectory = page.file?.parentFile + val (bitmap, width, height) = page.loadBitmap(PdfRenderer.Page.RENDER_MODE_FOR_PRINT) + if (bitmap == null) continue + val pageInfo = PdfDocument.PageInfo.Builder(width, height, pageCount++).create() + pdf.startPage(pageInfo).apply { + canvas.drawBitmap(bitmap, Matrix().apply { setScale(SCALE_FACTOR, SCALE_FACTOR) }, null) + bitmap.recycle() + pdf.finishPage(this) + } + } + val file = File(scansDirectory, "Scan ${Instant.now().toEpochMilli()}.pdf") + file.outputStream().use { + pdf.writeTo(it) + } + pdf.close() + return file +} + +/** + * Convert an Int from PostScript Points to pixels + */ +fun Int.toPx() = round(this / SCALE_FACTOR).toInt() diff --git a/app/src/main/java/com/wbrawner/skerge/ScanSettings.kt b/app/src/main/java/com/wbrawner/skerge/ScanSettings.kt new file mode 100644 index 0000000..4736c8a --- /dev/null +++ b/app/src/main/java/com/wbrawner/skerge/ScanSettings.kt @@ -0,0 +1,74 @@ +package com.wbrawner.skerge + +import org.xmlpull.v1.XmlPullParserFactory +import org.xmlpull.v1.XmlSerializer +import java.io.StringWriter + +data class ScanSettings( + val version: String = "2.1", + val intent: String = "Document", + val scanRegions: List = listOf(ScanRegion()), + val inputSource: String = "Platen", + val documentFormatExt: String = "application/pdf", + val xResolution: Int = 300, + val yResolution: Int = 300, + val colorMode: String = "RGB24", + val compressionFactor: Int = 25, + val brightness: Int = 1000, + val contrast: Int = 1000 +) { + fun toXml(): String { + val writer = StringWriter() + XmlPullParserFactory.newInstance().newSerializer().apply { + setOutput(writer) + startDocument(null, null) + for (prefix in Prefix.values()) { + setPrefix(prefix.prefix, prefix.namespace) + } + writeTag("ScanSettings", Prefix.SCAN) { + writeTag("Version", Prefix.PWG, version) + writeTag("Intent", Prefix.SCAN, intent) + writeTag("ScanRegions", Prefix.PWG) { + scanRegions.forEach { scanRegion -> + scanRegion.toXml(this) + } + } + writeTag("InputSource", Prefix.PWG, inputSource) + writeTag("DocumentFormatExt", Prefix.SCAN, documentFormatExt) + writeTag("XResolution", Prefix.SCAN, xResolution) + writeTag("YResolution", Prefix.SCAN, yResolution) + writeTag("ColorMode", Prefix.SCAN, colorMode) + writeTag("CompressionFactor", Prefix.SCAN, compressionFactor) + writeTag("Brightness", Prefix.SCAN, brightness) + writeTag("Contrast", Prefix.SCAN, contrast) + } + endDocument() + } + // We have to remove the xml document header since that's how the web app sends it + return writer.toString().replace("", "") + } +} + +data class ScanRegion( + val height: Int = 3300, + val width: Int = 2550, + val xOffset: Int = 0, + val yOffset: Int = 0 +) { + fun toXml(serializer: XmlSerializer) = serializer.apply { + writeTag("ScanRegion", Prefix.PWG) { + writeTag("Height", Prefix.PWG) { + text(height.toString()) + } + writeTag("Width", Prefix.PWG) { + text(width.toString()) + } + writeTag("XOffset", Prefix.PWG) { + text(xOffset.toString()) + } + writeTag("YOffset", Prefix.PWG) { + text(yOffset.toString()) + } + } + } +} diff --git a/app/src/main/java/com/wbrawner/skerge/ScannerService.kt b/app/src/main/java/com/wbrawner/skerge/ScannerService.kt new file mode 100644 index 0000000..2877d9f --- /dev/null +++ b/app/src/main/java/com/wbrawner/skerge/ScannerService.kt @@ -0,0 +1,63 @@ +package com.wbrawner.skerge + +import io.ktor.client.* +import io.ktor.client.call.* +import io.ktor.client.engine.cio.* +import io.ktor.client.features.* +import io.ktor.client.request.* +import io.ktor.client.statement.* +import io.ktor.client.utils.* +import io.ktor.http.* +import java.io.File +import java.io.IOException + +interface ScannerService { + suspend fun requestScan(scanSettings: ScanSettings = ScanSettings()): String + suspend fun getScanStatus(scanId: String): ScannerStatus + suspend fun downloadFile( + uuid: String, + destination: File, + onProgress: (downloaded: Long, size: Long) -> Unit + ) +} + +// TODO: It would be cool to be able to autodiscover the printer(s) or manually enter the details +const val SCANNER_URL = "http://brawner.print" + +class HpScannerService( + private val client: HttpClient = HttpClient(CIO) { + buildHeaders { + append("Content-Type", "text/xml") + } + } +) : ScannerService { + override suspend fun requestScan(scanSettings: ScanSettings): String { + val url = URLBuilder(SCANNER_URL).path("eSCL", "ScanJobs").build() + val response: HttpResponse = client.post(url) { + body = scanSettings.toXml() + } + val location = response.headers["Location"] + ?: throw IOException("Scanner didn't return location") + return location.replace("${url}/", "") + } + + override suspend fun getScanStatus(scanId: String): ScannerStatus = ScannerStatus.fromXml( + client.get(URLBuilder(SCANNER_URL).path("eSCL", "ScannerStatus").build()) + ) + + override suspend fun downloadFile( + uuid: String, + destination: File, + onProgress: (downloaded: Long, size: Long) -> Unit + ) { + val httpResponse: HttpResponse = client.get( + URLBuilder(SCANNER_URL).path("eSCL", "ScanJobs", uuid, "NextDocument").build() + ) { + onDownload { bytesSentTotal, contentLength -> + onProgress(bytesSentTotal, contentLength) + } + } + val responseBody: ByteArray = httpResponse.receive() + destination.writeBytes(responseBody) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/wbrawner/skerge/ScannerStatus.kt b/app/src/main/java/com/wbrawner/skerge/ScannerStatus.kt new file mode 100644 index 0000000..3820b11 --- /dev/null +++ b/app/src/main/java/com/wbrawner/skerge/ScannerStatus.kt @@ -0,0 +1,86 @@ +package com.wbrawner.skerge + +import org.xmlpull.v1.XmlPullParser +import org.xmlpull.v1.XmlPullParser.* +import org.xmlpull.v1.XmlPullParserFactory +import java.io.StringReader + +data class ScannerStatus( + val version: String, + val state: String, + val jobs: List +) { + companion object { + fun fromXml(xml: String): ScannerStatus { + var version = "" + var state = "" + val jobs = mutableListOf() + XmlPullParserFactory.newInstance().newPullParser().apply { + setInput(StringReader(xml)) + while (eventType != END_DOCUMENT) { + when (eventType) { + START_TAG -> { + when (name) { + "pwg:Version" -> version = nextText() + "pwg:State" -> state = nextText() + "scan:JobInfo" -> jobs.add(JobInfo.fromXml(this)) + } + } + } + next() + } + } + return ScannerStatus(version, state, jobs) + } + } +} + +data class JobInfo( + val jobUri: String, + val jobUuid: String, + val age: Int, + val imagesCompleted: Int, + val imagesToTransfer: Int, + val jobState: String, + val jobStateReasons: List +) { + val completed: Boolean + get() = jobState == "Completed" + + val aborted: Boolean + get() = jobState == "Aborted" + + companion object { + fun fromXml(xmlPullParser: XmlPullParser): JobInfo { + var uri = "" + var uuid = "" + var age = 0 + var imagesCompleted = 0 + var imagesToTransfer = 0 + var jobState = "" + val reasons = mutableListOf() + xmlPullParser.apply { + while (eventType != END_DOCUMENT) { + when (eventType) { + START_TAG -> { + when (name) { + "pwg:JobUri" -> uri = nextText() + "pwg:JobUuid" -> uuid = nextText() + "scan:Age" -> age = nextText().toInt() + "pwg:ImagesCompleted" -> imagesCompleted = nextText().toInt() + "pwg:ImagesToTransfer" -> imagesToTransfer = nextText().toInt() + "pwg:JobState" -> jobState = nextText() + "pwg:JobStateReason" -> reasons.add(nextText()) + } + } + END_TAG -> { + if (name == "scan:JobInfo") break + } + } + next() + } + } + return JobInfo(uri, uuid, age, imagesCompleted, imagesToTransfer, jobState, reasons) + } + } +} diff --git a/app/src/main/java/com/wbrawner/skerge/XmlUtils.kt b/app/src/main/java/com/wbrawner/skerge/XmlUtils.kt new file mode 100644 index 0000000..d89d013 --- /dev/null +++ b/app/src/main/java/com/wbrawner/skerge/XmlUtils.kt @@ -0,0 +1,30 @@ +package com.wbrawner.skerge + +import org.xmlpull.v1.XmlSerializer + +enum class Prefix(val prefix: String, val namespace: String) { + COPY("copy", "http://www.hp.com/schemas/imaging/con/copy/2008/07/07"), + DD("dd", "http://www.hp.com/schemas/imaging/con/dictionaries/1.0/"), + DD3("dd3", "http://www.hp.com/schemas/imaging/con/dictionaries/2009/04/06"), + FW("fw", "http://www.hp.com/schemas/imaging/con/firewall/2011/01/05"), + PWG("pwg", "http://www.pwg.org/schemas/2010/12/sm"), + SCAN("scan", "http://schemas.hp.com/imaging/escl/2011/05/03"), + SCC("scc", "http://schemas.hp.com/imaging/escl/2011/05/03") +} + +fun XmlSerializer.writeTag( + name: String, + prefix: Prefix? = null, + content: XmlSerializer.() -> Unit +) { + val tagName = prefix?.let { "${it.prefix}:${name}" } ?: name + // XmlPullParser doesn't seem to like having multiple prefixes for the same namespace + startTag(null, tagName) + content() + endTag(null, tagName) +} + +fun XmlSerializer.writeTag(name: String, prefix: Prefix? = null, text: Any) = + writeTag(name, prefix) { + text(text.toString()) + } \ No newline at end of file diff --git a/app/src/main/java/com/wbrawner/skerge/ui/theme/Color.kt b/app/src/main/java/com/wbrawner/skerge/ui/theme/Color.kt new file mode 100644 index 0000000..16dcdd9 --- /dev/null +++ b/app/src/main/java/com/wbrawner/skerge/ui/theme/Color.kt @@ -0,0 +1,5 @@ +package com.wbrawner.skerge.ui.theme + +import androidx.compose.ui.graphics.Color + +val SkergeBlue = Color(0xFF0096d6) diff --git a/app/src/main/java/com/wbrawner/skerge/ui/theme/Shape.kt b/app/src/main/java/com/wbrawner/skerge/ui/theme/Shape.kt new file mode 100644 index 0000000..e70be22 --- /dev/null +++ b/app/src/main/java/com/wbrawner/skerge/ui/theme/Shape.kt @@ -0,0 +1,11 @@ +package com.wbrawner.skerge.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/com/wbrawner/skerge/ui/theme/Theme.kt b/app/src/main/java/com/wbrawner/skerge/ui/theme/Theme.kt new file mode 100644 index 0000000..496b032 --- /dev/null +++ b/app/src/main/java/com/wbrawner/skerge/ui/theme/Theme.kt @@ -0,0 +1,35 @@ +package com.wbrawner.skerge.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 = SkergeBlue, + primaryVariant = SkergeBlue, + secondary = SkergeBlue +) + +private val LightColorPalette = lightColors( + primary = SkergeBlue, + primaryVariant = SkergeBlue, + secondary = SkergeBlue +) + +@Composable +fun SkergeTheme(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/com/wbrawner/skerge/ui/theme/Type.kt b/app/src/main/java/com/wbrawner/skerge/ui/theme/Type.kt new file mode 100644 index 0000000..b2aaf29 --- /dev/null +++ b/app/src/main/java/com/wbrawner/skerge/ui/theme/Type.kt @@ -0,0 +1,15 @@ +package com.wbrawner.skerge.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 + +val Typography = Typography( + body1 = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Normal, + fontSize = 16.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-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-night/themes.xml b/app/src/main/res/values-night/themes.xml new file mode 100644 index 0000000..331ac0c --- /dev/null +++ b/app/src/main/res/values-night/themes.xml @@ -0,0 +1,16 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml new file mode 100644 index 0000000..998377d --- /dev/null +++ b/app/src/main/res/values/colors.xml @@ -0,0 +1,6 @@ + + + #FF0096d6 + #FF000000 + #FFFFFFFF + \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..ee94688 --- /dev/null +++ b/app/src/main/res/values/strings.xml @@ -0,0 +1,3 @@ + + Skerge + \ 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..8409e8a --- /dev/null +++ b/app/src/main/res/values/themes.xml @@ -0,0 +1,25 @@ + + + + + + +