├── .github └── workflows │ └── codeql.yml ├── .gitignore ├── LICENSE ├── README.md ├── app ├── .gitignore ├── build.gradle.kts ├── proguard-rules.pro ├── schemas │ └── net.phbwt.paperwork.data.AppDatabase │ │ ├── 1.json │ │ └── 2.json └── src │ ├── androidTest │ └── java │ │ └── net │ │ └── phbwt │ │ └── paperwork │ │ └── ExampleInstrumentedTest.kt │ ├── main │ ├── AndroidManifest.xml │ ├── java │ │ └── net │ │ │ └── phbwt │ │ │ └── paperwork │ │ │ ├── OpenPaperViewApplication.kt │ │ │ ├── data │ │ │ ├── AppDatabase.kt │ │ │ ├── Repository.kt │ │ │ ├── background │ │ │ │ └── DownloadWorker.kt │ │ │ ├── dao │ │ │ │ ├── DocumentDao.kt │ │ │ │ ├── DocumentQueryBuilder.kt │ │ │ │ ├── DownloadDao.kt │ │ │ │ └── LabelDao.kt │ │ │ ├── entity │ │ │ │ └── db │ │ │ │ │ ├── Document.kt │ │ │ │ │ ├── DocumentFts.kt │ │ │ │ │ ├── DocumentFull.kt │ │ │ │ │ ├── DocumentText.kt │ │ │ │ │ ├── Download.kt │ │ │ │ │ ├── Label.kt │ │ │ │ │ ├── LabelType.kt │ │ │ │ │ └── Part.kt │ │ │ ├── helper │ │ │ │ └── LocalFetcher.kt │ │ │ └── settings │ │ │ │ └── Settings.kt │ │ │ ├── helper │ │ │ ├── FileProvider.kt │ │ │ ├── FlowExtensions.kt │ │ │ ├── GestureHelper.kt │ │ │ ├── MiscHelper.kt │ │ │ ├── ResultExtensions.kt │ │ │ ├── TextHelper.kt │ │ │ └── hilt │ │ │ │ └── CoroutineScopeModule.kt │ │ │ └── ui │ │ │ ├── MainActivity.kt │ │ │ ├── about │ │ │ ├── AboutScreen.kt │ │ │ └── AboutVM.kt │ │ │ ├── doclist │ │ │ ├── DocListScreen.kt │ │ │ └── DocListVM.kt │ │ │ ├── downloadlist │ │ │ ├── DownloadListScreen.kt │ │ │ └── DownloadListVM.kt │ │ │ ├── main │ │ │ ├── Dest.kt │ │ │ ├── MainScreen.kt │ │ │ └── MainVM.kt │ │ │ ├── pagelist │ │ │ ├── PageListContentImages.kt │ │ │ ├── PageListContentPdf.kt │ │ │ ├── PageListScreen.kt │ │ │ ├── PageListVM.kt │ │ │ └── PdfRendererWrapper.kt │ │ │ ├── settings │ │ │ ├── SettingsScreen.kt │ │ │ └── SettingsVM.kt │ │ │ ├── settingscheck │ │ │ ├── SettingsCheckScreen.kt │ │ │ └── SettingsCheckVM.kt │ │ │ └── theme │ │ │ ├── Color.kt │ │ │ ├── Theme.kt │ │ │ └── Type.kt │ └── res │ │ ├── drawable-anydpi-v26 │ │ └── ic_launcher.xml │ │ ├── drawable │ │ ├── ic_baseline_download_24.xml │ │ ├── ic_cloud_queue_24.xml │ │ ├── ic_error_outline_24.xml │ │ ├── ic_launcher.xml │ │ ├── ic_launcher_background.xml │ │ ├── ic_launcher_foreground.xml │ │ ├── ic_launcher_monochrome.xml │ │ └── splash_anim.xml │ │ ├── values-fr │ │ └── strings.xml │ │ ├── values-night │ │ └── colors.xml │ │ ├── values │ │ ├── colors.xml │ │ ├── strings.xml │ │ └── themes.xml │ │ └── xml │ │ ├── file_provider_paths.xml │ │ ├── locales_config.xml │ │ └── network_security_config.xml │ └── test │ └── java │ └── net │ └── phbwt │ └── paperwork │ └── data │ └── dao │ └── DocumentQueryBuilderTest.kt ├── build.gradle.kts ├── gradle.properties ├── gradle ├── libs.versions.toml └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── gradlew ├── gradlew.bat ├── metadata ├── en-US │ ├── changelogs │ │ ├── 1000001.txt │ │ ├── 1000007.txt │ │ ├── 1000011.txt │ │ ├── 1000014.txt │ │ ├── 1000019.txt │ │ ├── 1001000.txt │ │ ├── 1001006.txt │ │ ├── 1001007.txt │ │ ├── 1001008.txt │ │ ├── 1001012.txt │ │ ├── 1001013.txt │ │ └── 1002000.txt │ ├── full_description.txt │ ├── images │ │ ├── icon.png │ │ ├── phoneScreenshots │ │ │ ├── 1.png │ │ │ ├── 2.png │ │ │ ├── 3.png │ │ │ ├── 4.png │ │ │ ├── 5.png │ │ │ ├── 6.png │ │ │ └── 7.png │ │ └── tenInchScreenshots │ │ │ └── 1.png │ ├── short_description.txt │ └── title.txt └── fr │ ├── full_description.txt │ ├── short_description.txt │ └── title.txt ├── settings.gradle.kts └── tools ├── create_viewer_cb.config └── create_viewer_cb.py /.github/workflows/codeql.yml: -------------------------------------------------------------------------------- 1 | name: "CodeQL" 2 | 3 | on: 4 | workflow_dispatch: 5 | push: 6 | branches: [ "main" ] 7 | pull_request: 8 | branches: [ "main" ] 9 | schedule: 10 | - cron: '32 20 * * 2' 11 | 12 | jobs: 13 | analyze: 14 | name: Analyze (${{ matrix.language }}) 15 | runs-on: ubuntu-latest 16 | timeout-minutes: 360 17 | permissions: 18 | security-events: write 19 | packages: read 20 | actions: read 21 | contents: read 22 | 23 | strategy: 24 | fail-fast: false 25 | matrix: 26 | include: 27 | - language: java-kotlin 28 | build-mode: manual 29 | - language: python 30 | build-mode: none 31 | 32 | steps: 33 | - name: Checkout repository 34 | uses: actions/checkout@v4 35 | 36 | - name: Setup Java 37 | if: matrix.build-mode == 'manual' 38 | uses: actions/setup-java@v4 39 | with: 40 | java-version: '17' 41 | distribution: 'temurin' 42 | cache: 'gradle' 43 | 44 | - name: Initialize CodeQL 45 | uses: github/codeql-action/init@v3 46 | with: 47 | languages: ${{ matrix.language }} 48 | queries: +security-extended 49 | build-mode: ${{ matrix.build-mode }} 50 | 51 | - name: Build 52 | if: matrix.build-mode == 'manual' 53 | run: ./gradlew assembleDebug 54 | 55 | - name: Perform CodeQL Analysis 56 | uses: github/codeql-action/analyze@v3 57 | with: 58 | category: "/language:${{matrix.language}}" 59 | 60 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.iml 2 | .gradle/ 3 | /local.properties 4 | /.idea/ 5 | build/ 6 | /captures 7 | local.properties 8 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## OpenPaperView 2 | 3 | An [OpenPaper.work](https://openpaper.work) mobile companion. 4 | 5 | [Get it on F-Droid](https://f-droid.org/packages/net.phbwt.paperwork/) 8 | [Get it on Google Play](https://play.google.com/store/apps/details?id=net.phbwt.paperwork) 11 | 12 | Or download the latest APK from the [Releases Section](https://github.com/bwt/OpenPaperView/releases/latest). 13 | 14 | ----- 15 | 16 | Disclaimer 1 : This Android application only works with OpenPaper.work. It also requires a lot of setup. 17 | If you don't want to spend hours preparing a server (only to be disappointed because the application is not what you expected) 18 | you can use the demo mode of the application (and be disappointed right away) 19 | 20 | Disclaimer 2 : This is a very niche project. You may well be the first to try to understand the following instructions. Please report errors, omissions, inaccuracies. 21 | 22 | The whole system consists of 4 parts : 23 | 24 | - The OpenPaper.work installation : provides the documents and the OCR. 25 | - A Python script : builds an SQLite database used by the viewer. 26 | - An HTTPS server : queried by the viewer to get the DB and the documents. 27 | - The viewer Android app. 28 | 29 | The basic idea is to build an SQLite database from the data collected by Paperwork 30 | and serve that database (and the actual scans) to the viewer over HTTPS. 31 | 32 | 33 | ### Features 34 | 35 | - Filter on Paperwork labels (inclusive or exclusive). 36 | 37 | - Offline full text search. 38 | 39 | - Documents can be downloaded, so they are available offline. 40 | Downloaded documents are stored in the internal storage of the application. 41 | 42 | - Automatic download of documents selected by labels. 43 | 44 | - Any static HTTP server can be used, as long as it supports client authentication with certificates and cache control. 45 | 46 | - Documents can be given a title (through Paperwork's *extra keywords* feature). 47 | 48 | - Material Design 3. This ensures that every screen looks absolutely stunning despite my limited UI design skills. 49 | 50 | 51 | ### Limitations 52 | 53 | - Only tested on Linux (Fedora for Paperwork, Debian for NGINX). 54 | 55 | - Everything is readonly, the viewer's function is to search and retrieve papers. 56 | No edits are possible, there are no plans to add any. 57 | 58 | - Image scans can be viewed online, but PDF must be downloaded first. 59 | 60 | - Modifications on PDFs (done with Paperwork) are ignored. Only the original PDF is used. 61 | 62 | - The internal PDF viewer is quite crude, *e.g.* there is no re-rendering when the zoom level changes. 63 | You can alternatively visualize the pdf with an external app. 64 | 65 | 66 | ### Installation 67 | 68 | 69 | #### An OpenPaper.work installation 70 | 71 | This is probably the easiest part. You need to locate : 72 | 73 | - The papers content directory (the directory containing a lot of `YYYYMMDD_HHMM_NN` directories) 74 | 75 | - The database containing the result of the OCR. It is named `doc_tracking.db` and is located inside the Paperwork work directory. 76 | 77 | 78 | #### The Python script 79 | 80 | The `tools/create_viewer_cb.py` script must be executed periodically. 81 | It scans the papers directory, adds the OCRed text from the Paperwork database and create an SQLite database. 82 | It would be nice to be able to integrate it into OpenPaper.work. 83 | If you have the required skills, please help with [this feature request](https://gitlab.gnome.org/World/OpenPaperwork/paperwork/-/issues/1062) 84 | 85 | The only dependency I remember is PyPDF2 1.x (Fedora package `python3-PyPDF2`) 86 | 87 | Parameters, like the input and output paths are defined in `create_viewer_cb.config`. 88 | 89 | By default the full text of the documents is indexed *and* stored. 90 | The index is used for full text search, the text itself is used to show search result snippets. 91 | 92 | In my case, each document increases the size of the database by about 10 kb : 93 | 94 | - A few hundred bytes for the basic data 95 | 96 | - 3 kb for the text index 97 | 98 | - about 7 kb for the full text 99 | 100 | To keep the DB small, it is possible to omit documents, partially or completely. 101 | See the *labels* section of the config file for more details. 102 | 103 | 104 | #### An HTTP server 105 | 106 | The server sends the document data and the SQLite file to the viewer. 107 | 108 | It should support : 109 | 110 | - Client authentication with certificate. You don't want your documents to be publicly accessible. 111 | 112 | - Cache control. The viewer periodically checks if a new DB is available. 113 | 114 | It needs access to the papers content and to the database built by the script. 115 | You may need to adjust the access right of the files generated by PaperWork. 116 | 117 | 118 | ##### Certificate creation 119 | 120 | Server authentication is quite standard, and is not covered here. 121 | 122 | Client authentication is less common, I used OpenSSL to create the necessary files. 123 | 124 | I am not, by far, an OpenSSL expert. **Please** report mistakes, inaccuracies or bad practices. 125 | 126 | The basic idea is to create an authority and use it to sign certificates. 127 | The authority's certificate will then be installed on the server, 128 | while a signed certificate (with corresponding private key) will be imported into the viewer. 129 | 130 | 1. Create the CA's private key. This should be kept in a secure place. 131 | 132 | ```bash 133 | openssl genrsa -out ca_private.key 4096 134 | ``` 135 | 136 | 1. Create the CA's (self signed) certificate. This is the file to be installed on the server. 137 | 138 | ```bash 139 | openssl req -new -x509 -days 3660 -key ca_private.key -out ca.crt 140 | ``` 141 | 142 | Then for each client : 143 | 144 | 1. Create the private key : 145 | 146 | ```bash 147 | openssl genrsa -out client_private.key 4096 148 | ``` 149 | 150 | 1. Create a certificate request. You will be asked for a `Common Name`, it can be anything as long as it is not empty : 151 | 152 | ```bash 153 | openssl req -new -key client_private.key -out client_request.csr 154 | ``` 155 | 156 | 1. Sign the client's request with the CA's key, creating a certificate with a 10 years validity, the serial should be different for each certificate : 157 | 158 | ```bash 159 | openssl x509 -req -days 3650 -in client_request.csr -CA ca.crt -CAkey ca_private.key -set_serial 1 -out client.crt 160 | ``` 161 | 162 | 1. Create the PEM file to be imported in the viewer app : 163 | 164 | ```bash 165 | cat client.crt client_private.key >client_full.pem 166 | ``` 167 | 168 | 169 | ##### Configuration 170 | 171 | A sample configuration for NGINX : 172 | 173 | ```NGINX 174 | server { 175 | # compress the sqlite DB file 176 | gzip on; 177 | gzip_types application/octet-stream; 178 | 179 | # SSL configuration 180 | listen 443 ssl http2 default_server; 181 | listen [::]:443 ssl http2 default_server; 182 | 183 | # Server authentication : 184 | # The server's certificate and private key 185 | ssl_certificate certs/server.crt; 186 | ssl_certificate_key private/server.key; 187 | 188 | # Client authentication : 189 | # The CA signing the client's certificate 190 | ssl_client_certificate certs/ca.crt; 191 | 192 | # make verification optional, so we can display a 403 message to those 193 | # who fail authentication 194 | ssl_verify_client optional; 195 | 196 | root /var/www/; 197 | 198 | index index.html index.htm; 199 | 200 | server_name _; 201 | 202 | location / { 203 | deny all; 204 | } 205 | 206 | # this is the viewer's base URL 207 | # where it expects to find : 208 | # papers.sqlite 209 | # papers/ 210 | location /papers_base_dir/ { 211 | # if the client-side certificate failed to authenticate, show a 403 212 | # message to the client 213 | if ($ssl_client_verify != SUCCESS) { 214 | return 403; 215 | } 216 | 217 | try_files $uri =404; 218 | } 219 | ``` 220 | 221 | 222 | #### OpenPaperView settings 223 | 224 | 225 | ##### Base URL 226 | 227 | The viewer downloads the sqlite DB, the document images and pdf. 228 | For example if the base URL is `https:example.com/paperwork/base` the viewer will query : 229 | 230 | The database : 231 | 232 | - `https:example.com/paperwork/base/papers.sqlite` 233 | 234 | The documents thumbnail, images and pdf : 235 | 236 | - `https:example.com/paperwork/base/papers/some_paper_id/doc.pdf` 237 | - `https:example.com/paperwork/base/papers/some_paper_id/paper.1.jpg` 238 | - `https:example.com/paperwork/base/papers/some_paper_id/paper.1.thumb.jpg` 239 | 240 | 241 | ##### Auto download labels 242 | 243 | Every time the database is updated, the documents having one of the labels will be downloaded. 244 | If manually deleted, they will be re-downloaded with the next update. 245 | 246 | 247 | ##### Authentication 248 | 249 | Authentication is done through HTTPS with mutual authentication. 250 | 251 | To authenticate itself on the server, the viewer needs a certificate **and** the corresponding private key. 252 | It expects a PEM file containing exactly one certificate and one private key. 253 | This typically looks like : 254 | 255 | ```pem 256 | some optional description 257 | -----BEGIN CERTIFICATE----- 258 | Base64 encoded content 259 | -----END CERTIFICATE----- 260 | 261 | -----BEGIN PRIVATE KEY----- 262 | more Base64 content 263 | -----END PRIVATE KEY----- 264 | ``` 265 | 266 | You can optionaly add a certificate for a custom certification authority. 267 | This is used to authenticate the *server* and is only necessary if the server's certificate is not signed by a well known CA.
268 | If provided, it will be the only CA trusted by the viewer. 269 | If not, Android's system (built-in) CAs will be trusted.
270 | In any case Android's *user* CAs (i.e. manually imported on the device) are **not** trusted. 271 | 272 | 273 | ### Extension 274 | 275 | I found that with small screens it is not very practical to identify documents based on the thumbnail. 276 | Having a title is much more comfortable. 277 | 278 | If the first line of Paperwork's *extra keywords* starts with a `#` the line is used as a title for the document. 279 | 280 | 281 | ### License 282 | 283 | Copyright (C) 2024 Philippe Banwarth 284 | 285 | This program is free software: you can redistribute it and/or modify it 286 | under the terms of the GNU General Public License as published by the Free Software Foundation, 287 | either version 3 of the License, or (at your option) any later version. 288 | 289 | This program is distributed in the hope that it will be useful, 290 | but WITHOUT ANY WARRANTY; 291 | without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. 292 | See the GNU General Public License for more details. 293 | 294 | You should have received a copy of the GNU General Public License along with this program. 295 | If not, see . 296 | 297 | 298 | 299 | 300 | 301 | -------------------------------------------------------------------------------- /app/.gitignore: -------------------------------------------------------------------------------- 1 | /build -------------------------------------------------------------------------------- /app/build.gradle.kts: -------------------------------------------------------------------------------- 1 | plugins { 2 | alias(libs.plugins.androidApplication) 3 | alias(libs.plugins.jetbrainsKotlinAndroid) 4 | alias(libs.plugins.ksp) 5 | alias(libs.plugins.hilt) 6 | alias(libs.plugins.parcelize) 7 | alias(libs.plugins.compose.compiler) 8 | } 9 | 10 | android { 11 | namespace = "net.phbwt.paperwork" 12 | compileSdk = 35 13 | // FDroid can not verify APK produced with build-tools 35 14 | // https://f-droid.org/docs/Reproducible_Builds/#apksigner-from-build-tools--3500-rc1-outputs-unverifiable-apks 15 | // https://gitlab.com/fdroid/fdroiddata/-/issues/3299 16 | buildToolsVersion = "34.0.0" 17 | 18 | defaultConfig { 19 | applicationId = "net.phbwt.paperwork" 20 | minSdk = 24 21 | targetSdk = 35 22 | versionCode = 1002000 23 | versionName = "1.2.0" 24 | 25 | resourceConfigurations += arrayOf("en", "fr") 26 | 27 | testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" 28 | 29 | vectorDrawables { 30 | useSupportLibrary = true 31 | } 32 | 33 | setProperty("archivesBaseName", "${applicationId}_${versionName}") 34 | } 35 | 36 | signingConfigs { 37 | create("release") { 38 | storeFile = file(File(System.getenv("ANDROID_KEYSTORE") ?: "")) 39 | storePassword = System.getenv("ANDROID_PASSWORD") 40 | keyAlias = "OpenPaperView" 41 | keyPassword = System.getenv("ANDROID_PASSWORD") 42 | } 43 | } 44 | 45 | buildTypes { 46 | release { 47 | signingConfig = signingConfigs.getByName("release") 48 | isMinifyEnabled = true 49 | isShrinkResources = true 50 | vcsInfo.include = false 51 | proguardFiles( 52 | getDefaultProguardFile("proguard-android-optimize.txt"), 53 | "proguard-rules.pro" 54 | ) 55 | } 56 | } 57 | 58 | compileOptions { 59 | sourceCompatibility = JavaVersion.VERSION_17 60 | targetCompatibility = JavaVersion.VERSION_17 61 | } 62 | 63 | kotlinOptions { 64 | jvmTarget = "17" 65 | } 66 | 67 | buildFeatures { 68 | buildConfig = true 69 | } 70 | 71 | // composeCompiler { 72 | // enableStrongSkippingMode = true 73 | // } 74 | 75 | ksp { 76 | arg("room.schemaLocation", "$projectDir/schemas") 77 | arg("room.generateKotlin", "true") 78 | } 79 | 80 | packaging { 81 | resources { 82 | excludes += "/META-INF/{AL2.0,LGPL2.1}" 83 | } 84 | } 85 | } 86 | 87 | dependencies { 88 | 89 | implementation(libs.kotlinx.collections.immutable) 90 | 91 | implementation(libs.androidx.activity.compose) 92 | implementation(libs.androidx.core.ktx) 93 | implementation(libs.androidx.core.splashscreen) 94 | implementation(libs.androidx.datastore.preferences) 95 | 96 | // Saved state module for ViewModel 97 | // https://developer.android.com/jetpack/androidx/releases/lifecycle#groovy 98 | implementation(libs.androidx.lifecycle.runtime.compose) 99 | implementation(libs.androidx.lifecycle.viewmodel.ktx) 100 | implementation(libs.androidx.lifecycle.viewmodel.compose) 101 | implementation(libs.androidx.lifecycle.runtime.ktx) 102 | implementation(libs.androidx.lifecycle.viewmodel.savedstate) 103 | 104 | // compose 105 | // https://developer.android.com/jetpack/compose/bom/bom-mapping 106 | implementation(platform(libs.compose.bom)) 107 | implementation(libs.compose.ui.tooling.preview) 108 | implementation(libs.compose.ui) 109 | implementation(libs.compose.material3) 110 | implementation(libs.compose.material.icons.extended) 111 | 112 | // navigation 113 | implementation(libs.hilt.navigation.compose) 114 | // Provided by Compose Destinations 115 | // implementation(libs.androidx.navigation.compose) 116 | 117 | // Coil 118 | implementation(libs.coil.compose) 119 | implementation(libs.coil.okhttp) 120 | 121 | // Compose Destinations 122 | implementation(libs.compose.destinations.core) 123 | ksp(libs.compose.destinations.compiler) 124 | 125 | // Room 126 | implementation(libs.room.runtime) 127 | implementation(libs.room.ktx) 128 | ksp(libs.room.compiler) 129 | 130 | // Hilt 131 | implementation(libs.dagger.hilt) 132 | // both hilt-compiler are required 133 | // cf https://github.com/google/dagger/issues/4058#issuecomment-1739045490 134 | ksp(libs.dagger.compiler) 135 | ksp(libs.hilt.compiler) 136 | 137 | // OkHttp 138 | implementation(platform(libs.okhttp.bom)) 139 | implementation(libs.okhttp.okhttp) 140 | implementation(libs.okhttp.tls) 141 | implementation(libs.okhttp.logging.interceptor) 142 | 143 | // workmanager + hilt and coroutine integration 144 | implementation(libs.workmanager.runtime) 145 | implementation(libs.hilt.workmanager) 146 | 147 | // https://github.com/gildor/kotlin-coroutines-okhttp/blob/master/CHANGELOG.md 148 | implementation(libs.gildor.coroutines.okhttp) 149 | 150 | testImplementation(libs.junit) 151 | androidTestImplementation(libs.androidx.junit) 152 | androidTestImplementation(libs.androidx.espresso.core) 153 | androidTestImplementation(platform(libs.compose.bom)) 154 | androidTestImplementation(libs.compose.ui.test.junit4) 155 | debugImplementation(libs.compose.ui.tooling) 156 | debugImplementation(libs.compose.ui.test.manifest) 157 | } 158 | -------------------------------------------------------------------------------- /app/proguard-rules.pro: -------------------------------------------------------------------------------- 1 | # Add project specific ProGuard rules here. 2 | # You can control the set of applied configuration files using the 3 | # proguardFiles setting in build.gradle. 4 | # 5 | # For more details, see 6 | # http://developer.android.com/guide/developing/tools/proguard.html 7 | 8 | # If your project uses WebView with JS, uncomment the following 9 | # and specify the fully qualified class name to the JavaScript interface 10 | # class: 11 | #-keepclassmembers class fqcn.of.javascript.interface.for.webview { 12 | # public *; 13 | #} 14 | 15 | # Uncomment this to preserve the line number information for 16 | # debugging stack traces. 17 | #-keepattributes SourceFile,LineNumberTable 18 | 19 | # If you keep the line number information, uncomment this to 20 | # hide the original source file name. 21 | #-renamesourcefileattribute SourceFile 22 | -------------------------------------------------------------------------------- /app/schemas/net.phbwt.paperwork.data.AppDatabase/1.json: -------------------------------------------------------------------------------- 1 | { 2 | "formatVersion": 1, 3 | "database": { 4 | "version": 1, 5 | "identityHash": "1afcf8b518ede8e61a73f72b783ce03f", 6 | "entities": [ 7 | { 8 | "tableName": "Document", 9 | "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`documentId` INTEGER NOT NULL, `name` TEXT NOT NULL, `title` TEXT, `thumb` TEXT, `pageCount` INTEGER NOT NULL, `date` INTEGER NOT NULL, `mtime` INTEGER NOT NULL, `size` INTEGER NOT NULL, PRIMARY KEY(`documentId`))", 10 | "fields": [ 11 | { 12 | "fieldPath": "documentId", 13 | "columnName": "documentId", 14 | "affinity": "INTEGER", 15 | "notNull": true 16 | }, 17 | { 18 | "fieldPath": "name", 19 | "columnName": "name", 20 | "affinity": "TEXT", 21 | "notNull": true 22 | }, 23 | { 24 | "fieldPath": "title", 25 | "columnName": "title", 26 | "affinity": "TEXT", 27 | "notNull": false 28 | }, 29 | { 30 | "fieldPath": "thumb", 31 | "columnName": "thumb", 32 | "affinity": "TEXT", 33 | "notNull": false 34 | }, 35 | { 36 | "fieldPath": "pageCount", 37 | "columnName": "pageCount", 38 | "affinity": "INTEGER", 39 | "notNull": true 40 | }, 41 | { 42 | "fieldPath": "date", 43 | "columnName": "date", 44 | "affinity": "INTEGER", 45 | "notNull": true 46 | }, 47 | { 48 | "fieldPath": "mtime", 49 | "columnName": "mtime", 50 | "affinity": "INTEGER", 51 | "notNull": true 52 | }, 53 | { 54 | "fieldPath": "size", 55 | "columnName": "size", 56 | "affinity": "INTEGER", 57 | "notNull": true 58 | } 59 | ], 60 | "primaryKey": { 61 | "columnNames": [ 62 | "documentId" 63 | ], 64 | "autoGenerate": false 65 | }, 66 | "indices": [], 67 | "foreignKeys": [] 68 | }, 69 | { 70 | "tableName": "Part", 71 | "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`partId` INTEGER NOT NULL, `documentId` INTEGER NOT NULL, `name` TEXT NOT NULL, `downloadStatus` INTEGER NOT NULL, `downloadError` TEXT, PRIMARY KEY(`partId`), FOREIGN KEY(`documentId`) REFERENCES `Document`(`documentId`) ON UPDATE NO ACTION ON DELETE NO ACTION )", 72 | "fields": [ 73 | { 74 | "fieldPath": "partId", 75 | "columnName": "partId", 76 | "affinity": "INTEGER", 77 | "notNull": true 78 | }, 79 | { 80 | "fieldPath": "documentId", 81 | "columnName": "documentId", 82 | "affinity": "INTEGER", 83 | "notNull": true 84 | }, 85 | { 86 | "fieldPath": "name", 87 | "columnName": "name", 88 | "affinity": "TEXT", 89 | "notNull": true 90 | }, 91 | { 92 | "fieldPath": "downloadStatus", 93 | "columnName": "downloadStatus", 94 | "affinity": "INTEGER", 95 | "notNull": true 96 | }, 97 | { 98 | "fieldPath": "downloadError", 99 | "columnName": "downloadError", 100 | "affinity": "TEXT", 101 | "notNull": false 102 | } 103 | ], 104 | "primaryKey": { 105 | "columnNames": [ 106 | "partId" 107 | ], 108 | "autoGenerate": false 109 | }, 110 | "indices": [ 111 | { 112 | "name": "Part_documentId", 113 | "unique": false, 114 | "columnNames": [ 115 | "documentId" 116 | ], 117 | "orders": [], 118 | "createSql": "CREATE INDEX IF NOT EXISTS `Part_documentId` ON `${TABLE_NAME}` (`documentId`)" 119 | }, 120 | { 121 | "name": "Part_downloadStatus", 122 | "unique": false, 123 | "columnNames": [ 124 | "downloadStatus" 125 | ], 126 | "orders": [], 127 | "createSql": "CREATE INDEX IF NOT EXISTS `Part_downloadStatus` ON `${TABLE_NAME}` (`downloadStatus`)" 128 | } 129 | ], 130 | "foreignKeys": [ 131 | { 132 | "table": "Document", 133 | "onDelete": "NO ACTION", 134 | "onUpdate": "NO ACTION", 135 | "columns": [ 136 | "documentId" 137 | ], 138 | "referencedColumns": [ 139 | "documentId" 140 | ] 141 | } 142 | ] 143 | }, 144 | { 145 | "tableName": "Label", 146 | "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`labelId` INTEGER NOT NULL, `documentId` INTEGER NOT NULL, `name` TEXT NOT NULL, `color` TEXT, PRIMARY KEY(`labelId`), FOREIGN KEY(`documentId`) REFERENCES `Document`(`documentId`) ON UPDATE NO ACTION ON DELETE NO ACTION )", 147 | "fields": [ 148 | { 149 | "fieldPath": "labelId", 150 | "columnName": "labelId", 151 | "affinity": "INTEGER", 152 | "notNull": true 153 | }, 154 | { 155 | "fieldPath": "documentId", 156 | "columnName": "documentId", 157 | "affinity": "INTEGER", 158 | "notNull": true 159 | }, 160 | { 161 | "fieldPath": "name", 162 | "columnName": "name", 163 | "affinity": "TEXT", 164 | "notNull": true 165 | }, 166 | { 167 | "fieldPath": "color", 168 | "columnName": "color", 169 | "affinity": "TEXT", 170 | "notNull": false 171 | } 172 | ], 173 | "primaryKey": { 174 | "columnNames": [ 175 | "labelId" 176 | ], 177 | "autoGenerate": false 178 | }, 179 | "indices": [ 180 | { 181 | "name": "Label_documentId", 182 | "unique": false, 183 | "columnNames": [ 184 | "documentId" 185 | ], 186 | "orders": [], 187 | "createSql": "CREATE INDEX IF NOT EXISTS `Label_documentId` ON `${TABLE_NAME}` (`documentId`)" 188 | }, 189 | { 190 | "name": "Label_name", 191 | "unique": false, 192 | "columnNames": [ 193 | "name" 194 | ], 195 | "orders": [], 196 | "createSql": "CREATE INDEX IF NOT EXISTS `Label_name` ON `${TABLE_NAME}` (`name`)" 197 | } 198 | ], 199 | "foreignKeys": [ 200 | { 201 | "table": "Document", 202 | "onDelete": "NO ACTION", 203 | "onUpdate": "NO ACTION", 204 | "columns": [ 205 | "documentId" 206 | ], 207 | "referencedColumns": [ 208 | "documentId" 209 | ] 210 | } 211 | ] 212 | }, 213 | { 214 | "tableName": "DocumentText", 215 | "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`documentId` INTEGER NOT NULL, `main` TEXT NOT NULL, `additional` TEXT, PRIMARY KEY(`documentId`), FOREIGN KEY(`documentId`) REFERENCES `Document`(`documentId`) ON UPDATE NO ACTION ON DELETE NO ACTION )", 216 | "fields": [ 217 | { 218 | "fieldPath": "documentId", 219 | "columnName": "documentId", 220 | "affinity": "INTEGER", 221 | "notNull": true 222 | }, 223 | { 224 | "fieldPath": "main", 225 | "columnName": "main", 226 | "affinity": "TEXT", 227 | "notNull": true 228 | }, 229 | { 230 | "fieldPath": "additional", 231 | "columnName": "additional", 232 | "affinity": "TEXT", 233 | "notNull": false 234 | } 235 | ], 236 | "primaryKey": { 237 | "columnNames": [ 238 | "documentId" 239 | ], 240 | "autoGenerate": false 241 | }, 242 | "indices": [], 243 | "foreignKeys": [ 244 | { 245 | "table": "Document", 246 | "onDelete": "NO ACTION", 247 | "onUpdate": "NO ACTION", 248 | "columns": [ 249 | "documentId" 250 | ], 251 | "referencedColumns": [ 252 | "documentId" 253 | ] 254 | } 255 | ] 256 | }, 257 | { 258 | "ftsVersion": "FTS4", 259 | "ftsOptions": { 260 | "tokenizer": "unicode61", 261 | "tokenizerArgs": [], 262 | "contentTable": "DocumentText", 263 | "languageIdColumnName": "", 264 | "matchInfo": "FTS4", 265 | "notIndexedColumns": [], 266 | "prefixSizes": [], 267 | "preferredOrder": "ASC" 268 | }, 269 | "contentSyncTriggers": [ 270 | "CREATE TRIGGER IF NOT EXISTS room_fts_content_sync_DocumentFts_BEFORE_UPDATE BEFORE UPDATE ON `DocumentText` BEGIN DELETE FROM `DocumentFts` WHERE `docid`=OLD.`rowid`; END", 271 | "CREATE TRIGGER IF NOT EXISTS room_fts_content_sync_DocumentFts_BEFORE_DELETE BEFORE DELETE ON `DocumentText` BEGIN DELETE FROM `DocumentFts` WHERE `docid`=OLD.`rowid`; END", 272 | "CREATE TRIGGER IF NOT EXISTS room_fts_content_sync_DocumentFts_AFTER_UPDATE AFTER UPDATE ON `DocumentText` BEGIN INSERT INTO `DocumentFts`(`docid`, `main`, `additional`) VALUES (NEW.`rowid`, NEW.`main`, NEW.`additional`); END", 273 | "CREATE TRIGGER IF NOT EXISTS room_fts_content_sync_DocumentFts_AFTER_INSERT AFTER INSERT ON `DocumentText` BEGIN INSERT INTO `DocumentFts`(`docid`, `main`, `additional`) VALUES (NEW.`rowid`, NEW.`main`, NEW.`additional`); END" 274 | ], 275 | "tableName": "DocumentFts", 276 | "createSql": "CREATE VIRTUAL TABLE IF NOT EXISTS `${TABLE_NAME}` USING FTS4(`main` TEXT NOT NULL, `additional` TEXT, tokenize=unicode61, content=`DocumentText`)", 277 | "fields": [ 278 | { 279 | "fieldPath": "main", 280 | "columnName": "main", 281 | "affinity": "TEXT", 282 | "notNull": true 283 | }, 284 | { 285 | "fieldPath": "additional", 286 | "columnName": "additional", 287 | "affinity": "TEXT", 288 | "notNull": false 289 | } 290 | ], 291 | "primaryKey": { 292 | "columnNames": [ 293 | "rowid" 294 | ], 295 | "autoGenerate": true 296 | }, 297 | "indices": [], 298 | "foreignKeys": [] 299 | } 300 | ], 301 | "views": [], 302 | "setupQueries": [ 303 | "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", 304 | "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '1afcf8b518ede8e61a73f72b783ce03f')" 305 | ] 306 | } 307 | } -------------------------------------------------------------------------------- /app/src/androidTest/java/net/phbwt/paperwork/ExampleInstrumentedTest.kt: -------------------------------------------------------------------------------- 1 | package net.phbwt.paperwork 2 | 3 | import androidx.test.platform.app.InstrumentationRegistry 4 | import androidx.test.ext.junit.runners.AndroidJUnit4 5 | 6 | import org.junit.Test 7 | import org.junit.runner.RunWith 8 | 9 | import org.junit.Assert.* 10 | 11 | /** 12 | * Instrumented test, which will execute on an Android device. 13 | * 14 | * See [testing documentation](http://d.android.com/tools/testing). 15 | */ 16 | @RunWith(AndroidJUnit4::class) 17 | class ExampleInstrumentedTest { 18 | @Test 19 | fun useAppContext() { 20 | // Context of the app under test. 21 | val appContext = InstrumentationRegistry.getInstrumentation().targetContext 22 | assertEquals("net.phbwt.paperwork", appContext.packageName) 23 | } 24 | } -------------------------------------------------------------------------------- /app/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 5 | 6 | 7 | 8 | 9 | 11 | 14 | 15 | 17 | 20 | 23 | 24 | 34 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 52 | 55 | 56 | 57 | 59 | 64 | 68 | 69 | 70 | 71 | -------------------------------------------------------------------------------- /app/src/main/java/net/phbwt/paperwork/OpenPaperViewApplication.kt: -------------------------------------------------------------------------------- 1 | package net.phbwt.paperwork 2 | 3 | import android.app.Application 4 | import android.os.StrictMode 5 | import android.os.StrictMode.ThreadPolicy 6 | import android.os.StrictMode.VmPolicy 7 | import androidx.hilt.work.HiltWorkerFactory 8 | import androidx.work.Configuration 9 | import dagger.hilt.android.HiltAndroidApp 10 | import net.phbwt.paperwork.data.background.DownloadWorker 11 | import javax.inject.Inject 12 | 13 | 14 | @HiltAndroidApp 15 | class OpenPaperViewApplication : Application(), Configuration.Provider { 16 | 17 | @Inject 18 | lateinit var workerFactory: HiltWorkerFactory 19 | 20 | // Default initializer disabled in manifest 21 | // cf https://developer.android.com/training/dependency-injection/hilt-jetpack#workmanager 22 | override val workManagerConfiguration: Configuration 23 | get() = Configuration.Builder() 24 | .setWorkerFactory(workerFactory) 25 | .build() 26 | 27 | override fun onCreate() { 28 | super.onCreate() 29 | 30 | if (BuildConfig.DEBUG) { 31 | StrictMode.setThreadPolicy( 32 | ThreadPolicy.Builder() 33 | .detectAll() 34 | // .detectDiskReads() 35 | // .detectDiskWrites() 36 | // .detectNetwork() // or .detectAll() for all detectable problems 37 | // .penaltyLog() 38 | .permitDiskReads() 39 | .build() 40 | ) 41 | StrictMode.setVmPolicy( 42 | VmPolicy.Builder() 43 | .detectAll() 44 | // .detectLeakedSqlLiteObjects() 45 | // .detectLeakedClosableObjects() 46 | // .penaltyLog() 47 | // .penaltyDeath() 48 | .build() 49 | ) 50 | } 51 | 52 | 53 | DownloadWorker.createNotificationChannel(this) 54 | // check db updates 55 | DownloadWorker.enqueueLoad(this) 56 | } 57 | 58 | } -------------------------------------------------------------------------------- /app/src/main/java/net/phbwt/paperwork/data/AppDatabase.kt: -------------------------------------------------------------------------------- 1 | package net.phbwt.paperwork.data 2 | 3 | import androidx.room.AutoMigration 4 | import androidx.room.Database 5 | import androidx.room.RoomDatabase 6 | import net.phbwt.paperwork.data.dao.DocumentDao 7 | import net.phbwt.paperwork.data.dao.DownloadDao 8 | import net.phbwt.paperwork.data.dao.LabelDao 9 | import net.phbwt.paperwork.data.entity.db.Document 10 | import net.phbwt.paperwork.data.entity.db.DocumentFts 11 | import net.phbwt.paperwork.data.entity.db.DocumentText 12 | import net.phbwt.paperwork.data.entity.db.Label 13 | import net.phbwt.paperwork.data.entity.db.Part 14 | 15 | @Database( 16 | version = 2, 17 | entities = [ 18 | Document::class, 19 | Part::class, 20 | Label::class, 21 | DocumentText::class, 22 | DocumentFts::class, 23 | ], 24 | autoMigrations = [ 25 | AutoMigration(1, 2), 26 | ], 27 | exportSchema = true, 28 | ) 29 | abstract class AppDatabase : RoomDatabase() { 30 | abstract fun docDao(): DocumentDao 31 | abstract fun downloadDao(): DownloadDao 32 | abstract fun labelDao(): LabelDao 33 | } 34 | 35 | -------------------------------------------------------------------------------- /app/src/main/java/net/phbwt/paperwork/data/Repository.kt: -------------------------------------------------------------------------------- 1 | @file:OptIn(DelicateCoilApi::class) 2 | 3 | package net.phbwt.paperwork.data 4 | 5 | import android.content.Context 6 | import android.util.Log 7 | import androidx.compose.runtime.Immutable 8 | import androidx.room.Room 9 | import coil3.ImageLoader 10 | import coil3.SingletonImageLoader 11 | import coil3.Uri 12 | import coil3.annotation.DelicateCoilApi 13 | import coil3.disk.DiskCache 14 | import coil3.map.Mapper 15 | import coil3.network.okhttp.OkHttpNetworkFetcherFactory 16 | import coil3.toUri 17 | import coil3.util.DebugLogger 18 | import dagger.hilt.android.qualifiers.ApplicationContext 19 | import kotlinx.coroutines.CoroutineScope 20 | import kotlinx.coroutines.Dispatchers 21 | import kotlinx.coroutines.flow.* 22 | import kotlinx.coroutines.launch 23 | import kotlinx.coroutines.withContext 24 | import net.phbwt.paperwork.BuildConfig 25 | import net.phbwt.paperwork.data.helper.LocalFetcher 26 | import net.phbwt.paperwork.data.settings.Settings 27 | import net.phbwt.paperwork.helper.combineResultFlows 28 | import net.phbwt.paperwork.helper.firstThenDebounce 29 | import net.phbwt.paperwork.helper.mapResultFlow 30 | import okhttp3.Cache 31 | import okhttp3.HttpUrl 32 | import okhttp3.OkHttpClient 33 | import okhttp3.logging.HttpLoggingInterceptor 34 | import okhttp3.tls.HandshakeCertificates 35 | import okhttp3.tls.HeldCertificate 36 | import okio.Path.Companion.toOkioPath 37 | import java.io.File 38 | import java.security.cert.X509Certificate 39 | import javax.inject.Inject 40 | import javax.inject.Singleton 41 | 42 | private const val TAG = "Repository" 43 | 44 | @Singleton 45 | class Repository @Inject constructor( 46 | @ApplicationContext private val applicationContext: Context, 47 | private val settings: Settings, 48 | private val externalScope: CoroutineScope, 49 | ) { 50 | fun dbBuilder(name: String) = Room.databaseBuilder( 51 | applicationContext, 52 | AppDatabase::class.java, 53 | name, 54 | ) 55 | 56 | val db = dbBuilder(applicationContext.currentDbName(true)) 57 | .build() 58 | 59 | // HTTP client used to download content (thumbnails, images, PDF) 60 | // no cache (will be handled by Coil) 61 | val contentHttpClient: Flow> by lazy { 62 | combineResultFlows(settings.clientPem, settings.serverCa, ::buildOkHttpClientWithoutCache) 63 | .stateIn(externalScope, SharingStarted.Eagerly, null) 64 | .filterNotNull() 65 | } 66 | 67 | // HTTP client used only to download the DB 68 | // build upon the content client + cache 69 | val dbHttpClient: Flow> by lazy { 70 | // we only add a cache to let OkHttp handle Etag, Last-Modified, ... HTTP headers 71 | contentHttpClient 72 | .mapResultFlow { it.withHttpCache(settings.dbCacheDir) } 73 | .stateIn(externalScope, SharingStarted.Eagerly, null) 74 | .filterNotNull() 75 | } 76 | 77 | val dbUpdateStatus = MutableStateFlow(NoUpdate) 78 | 79 | fun dbUpdateReady() { 80 | dbUpdateStatus.value = UpdateAvailable 81 | } 82 | 83 | fun dbUpdateFailed(ex: Throwable) { 84 | dbUpdateStatus.value = UpdateError(ex) 85 | } 86 | 87 | fun dbUpdateAcknowledged() { 88 | dbUpdateStatus.value = NoUpdate 89 | } 90 | 91 | init { 92 | externalScope.launch { 93 | combineResultFlows( 94 | contentHttpClient, 95 | // we need the first value quickly 96 | // but we don't want to rebuild on each keystroke in the settings screen 97 | settings.contentBaseUrl.firstThenDebounce(1000), 98 | ) { client, baseUrl -> 99 | buildImageLoader(client, baseUrl) 100 | }.collect { ilr -> 101 | ilr.onSuccess { 102 | SingletonImageLoader.setUnsafe(it) 103 | Log.d(TAG, "Coil init done") 104 | }.onFailure { ex -> 105 | Log.w(TAG, "Cannot init Coil", ex) 106 | } 107 | } 108 | } 109 | } 110 | 111 | // reuse the same cache when rebuilding 112 | private val coilDiskCache: DiskCache by lazy { 113 | DiskCache.Builder() 114 | .directory(settings.imageCacheDir.toOkioPath()) 115 | .build() 116 | } 117 | 118 | private fun buildImageLoader( 119 | okhttp: OkHttpClient, 120 | baseUrl: HttpUrl 121 | ): ImageLoader { 122 | Log.d(TAG, "Build ImageLoader") 123 | val b = ImageLoader.Builder(applicationContext) 124 | .diskCache(coilDiskCache) 125 | .components { 126 | // data is a relative path, to which we add the base URL 127 | add(Mapper { data, _ -> baseUrl.newBuilder().addEncodedPathSegments(data).build().toString().toUri() }) 128 | // try to load from the local documents 129 | add(LocalFetcher.Factory(settings.localPartsDir)) 130 | add(OkHttpNetworkFetcherFactory( 131 | callFactory = { okhttp } 132 | )) 133 | } 134 | // .crossfade(true) 135 | 136 | if (BuildConfig.DEBUG) { 137 | b.logger(DebugLogger()) 138 | } 139 | 140 | return b.build() 141 | } 142 | 143 | suspend fun purgeCache() = withContext(Dispatchers.IO) { 144 | 145 | // image caches 146 | SingletonImageLoader.get(applicationContext).run { 147 | memoryCache?.clear() 148 | diskCache?.clear() 149 | } 150 | 151 | // db cache 152 | dbHttpClient.first().getOrNull()?.cache?.evictAll() 153 | 154 | // not necessary 155 | // triggers 2 db downloads (no sure why) 156 | // settings.dbCache.deleteRecursively() 157 | // settings.imageCache.deleteRecursively() 158 | } 159 | 160 | suspend fun purgeDownloaded() = withContext(Dispatchers.IO) { 161 | settings.localPartsDir.deleteRecursively() 162 | db.downloadDao().purgeDownloads() 163 | } 164 | } 165 | 166 | fun buildOkHttpClientWithoutCache( 167 | clientPem: HeldCertificate?, 168 | serverCa: X509Certificate?, 169 | ): OkHttpClient { 170 | Log.d(TAG, "Build ClientCertificates") 171 | val clientCertificates = HandshakeCertificates.Builder().apply { 172 | if (clientPem != null) { 173 | heldCertificate(clientPem) 174 | } else { 175 | Log.w(TAG, "No client certificate") 176 | } 177 | if (serverCa != null) { 178 | Log.i(TAG, "Custom CA") 179 | addTrustedCertificate(serverCa) 180 | } else { 181 | addPlatformTrustedCertificates() 182 | } 183 | }.build() 184 | 185 | Log.d(TAG, "Build OkHttpClient WITHOUT cache") 186 | return OkHttpClient.Builder().apply { 187 | sslSocketFactory(clientCertificates.sslSocketFactory(), clientCertificates.trustManager) 188 | // cache handled by Coil 189 | cache(null) 190 | if (BuildConfig.DEBUG) { 191 | addNetworkInterceptor(HttpLoggingInterceptor().setLevel(HttpLoggingInterceptor.Level.HEADERS)) 192 | } 193 | }.build() 194 | } 195 | 196 | fun OkHttpClient.withHttpCache( 197 | cacheDir: File, 198 | ): OkHttpClient { 199 | Log.d(TAG, "Adding cache to OkHttpClient") 200 | cacheDir.mkdirs() 201 | return newBuilder() 202 | .cache(Cache(cacheDir, 256 * 1024 * 1024L)) 203 | .build() 204 | } 205 | 206 | private const val DB_PREFIX = "documents_" 207 | private const val DB_SUFFIX = ".sqlite" 208 | 209 | fun Context.currentDbName(purgeOlder: Boolean = false): String { 210 | val names = databaseList().filter { 211 | it.startsWith(DB_PREFIX) && it.endsWith(DB_SUFFIX) 212 | }.sorted() 213 | 214 | if (purgeOlder) { 215 | for (n in names.dropLast(1)) { 216 | Log.d(TAG, "Deleting old DB : '$n'") 217 | deleteDatabase(n) 218 | } 219 | } 220 | return names.lastOrNull() ?: getDbName(0) 221 | } 222 | 223 | private fun Context.currentDbNumber() = currentDbName() 224 | .removePrefix(DB_PREFIX) 225 | .removeSuffix(DB_SUFFIX) 226 | .toInt() 227 | 228 | private fun getDbName(count: Int) = "%1\$s%2\$05d%3\$s".format(DB_PREFIX, count, DB_SUFFIX) 229 | fun Context.newDbName() = getDbName(currentDbNumber() + 1) 230 | 231 | 232 | @Immutable 233 | sealed interface DbUpdateStatus 234 | data object NoUpdate : DbUpdateStatus 235 | data object UpdateAvailable : DbUpdateStatus 236 | data class UpdateError(val error: Throwable) : DbUpdateStatus 237 | 238 | 239 | -------------------------------------------------------------------------------- /app/src/main/java/net/phbwt/paperwork/data/dao/DocumentDao.kt: -------------------------------------------------------------------------------- 1 | package net.phbwt.paperwork.data.dao 2 | 3 | import androidx.room.* 4 | import androidx.sqlite.db.SupportSQLiteQuery 5 | import kotlinx.coroutines.flow.Flow 6 | import net.phbwt.paperwork.data.entity.db.DNL_NONE 7 | import net.phbwt.paperwork.data.entity.db.DocumentFull 8 | import net.phbwt.paperwork.data.entity.db.Part 9 | 10 | @Dao 11 | interface DocumentDao { 12 | 13 | @Transaction 14 | @RawQuery(observedEntities = [Part::class]) 15 | fun searchImpl(query: SupportSQLiteQuery): Flow> 16 | 17 | fun search( 18 | includedLabels: List, 19 | excludedLabels: List, 20 | baseSearch: String, 21 | ): Flow> = searchImpl( 22 | DocumentQueryBuilder() 23 | .addIncludedLabels(includedLabels) 24 | .addExcludedLabels(excludedLabels) 25 | .addFts(baseSearch) 26 | .build() 27 | ) 28 | 29 | @SuppressWarnings(RoomWarnings.CURSOR_MISMATCH) 30 | @Transaction 31 | @Query("select * from Document where documentId in (select documentId from Part where downloadStatus != ${DNL_NONE}) order by documentId limit 500") 32 | fun withDownloads(): Flow> 33 | 34 | @SuppressWarnings(RoomWarnings.CURSOR_MISMATCH) 35 | @Transaction 36 | @Query("select * from Document where documentId = :id") 37 | fun loadDocument(id: Int): Flow 38 | } 39 | 40 | private const val TAG = "DocumentDao" 41 | 42 | 43 | 44 | 45 | 46 | -------------------------------------------------------------------------------- /app/src/main/java/net/phbwt/paperwork/data/dao/DocumentQueryBuilder.kt: -------------------------------------------------------------------------------- 1 | package net.phbwt.paperwork.data.dao 2 | 3 | import android.util.Log 4 | import androidx.sqlite.db.SimpleSQLiteQuery 5 | import androidx.sqlite.db.SupportSQLiteQuery 6 | import net.phbwt.paperwork.BuildConfig 7 | 8 | class DocumentQueryBuilder { 9 | 10 | private val ctes = mutableListOf() 11 | private val cteArgs = mutableListOf() 12 | private var selects = mutableListOf("d.*") 13 | private val joins = mutableListOf() 14 | private var wheres = mutableListOf() 15 | private val whereArgs = mutableListOf() 16 | 17 | fun addIncludedLabels(labels: List) = apply { 18 | 19 | if (labels.isNotEmpty()) { 20 | val c = """ 21 | l(documentId) as ( 22 | select documentId 23 | from Label 24 | where name in (${labels.joinToString(", ") { "?" }}) 25 | group by documentId 26 | having count(*) = ? 27 | )""" 28 | ctes.add(c) 29 | joins.add("join l on d.documentId = l.documentId") 30 | cteArgs.addAll(labels) 31 | cteArgs.add(labels.size) 32 | } 33 | 34 | } 35 | 36 | fun addExcludedLabels(labels: List) = apply { 37 | if (labels.isNotEmpty()) { 38 | wheres.add("(select count(*) from Label l where l.documentId = d.documentId and l.name in (${labels.joinToString(", ") { "?" }})) = 0") 39 | whereArgs.addAll(labels) 40 | } 41 | } 42 | 43 | fun addFts(words: String) = apply { 44 | val ftsQuery = prepareFtsQuery(words) 45 | 46 | if (BuildConfig.DEBUG) { 47 | Log.i(TAG, "'$words' --> '$ftsQuery'") 48 | } 49 | 50 | if (ftsQuery.isNotEmpty()) { 51 | // match on main and additional (i.e. title), 52 | // extract snippet only from main 53 | ctes.add( 54 | """ 55 | t(documentId, snippet) as ( 56 | select rowid, snippet(DocumentFts, '$S$R', '$S', '$S...$S', 0, 15) 57 | from DocumentFts 58 | where DocumentFts match ? 59 | )""" 60 | ) 61 | selects.add("snippet") 62 | joins.add("join t on d.documentId = t.documentId") 63 | cteArgs.add(ftsQuery) 64 | } 65 | } 66 | 67 | fun build(): SupportSQLiteQuery { 68 | val query = """ 69 | ${if (ctes.isNotEmpty()) ctes.joinToString(", ", " with ") else ""} 70 | select ${selects.joinToString(", ")} 71 | from Document d 72 | ${joins.joinToString("\n")} 73 | ${if (wheres.isNotEmpty()) wheres.joinToString(" and ", " where ") else ""} 74 | order by d.date DESC 75 | limit 150 76 | """ 77 | 78 | val args = cteArgs + whereArgs 79 | 80 | if (BuildConfig.DEBUG) { 81 | Log.i(TAG, "QUERY=${query}\nARGS=$args") 82 | } 83 | 84 | return SimpleSQLiteQuery(query, args.toTypedArray()) 85 | } 86 | 87 | // public for tests 88 | fun prepareFtsQuery(baseWords: String): String { 89 | val words = baseWords.replace(CLEANER_RE, " ") 90 | var pos = 0 91 | 92 | fun next(block: (Boolean, String) -> Unit): Boolean { 93 | 94 | if (pos > words.lastIndex) { 95 | block(false, "") 96 | return false 97 | } 98 | 99 | val p = words.indexOf('"', pos) 100 | val found = p != -1 101 | 102 | if (found) { 103 | block(true, words.substring(pos, p)) 104 | pos = p + 1 105 | } else { 106 | block(false, words.substring(pos)) 107 | pos = words.length 108 | } 109 | 110 | return found 111 | } 112 | 113 | val res = StringBuilder(words.length + 16) 114 | 115 | do { 116 | val hasMore = next { found, str -> 117 | // out of quoted part 118 | for (v in maybeAsPrefix(found, str).split(" ").filter { it.isNotBlank() }) { 119 | res.append(" \"").append(v).append("\"") 120 | } 121 | } && next { found, str -> 122 | // in quoted part 123 | if (str.isNotBlank()) { 124 | res.append(" \"").append(maybeAsPrefix(found, str)).append("\"") 125 | } 126 | } 127 | } while (hasMore) 128 | 129 | return res.trim().toString() 130 | } 131 | 132 | private fun maybeAsPrefix(hasMore: Boolean, str: String) = when { 133 | hasMore -> str 134 | str.endsWith(' ') -> str 135 | str.endsWith('*') -> str 136 | str.isBlank() -> str 137 | else -> "$str*" 138 | } 139 | 140 | } 141 | 142 | private const val TAG = "DocumentQueryHelper" 143 | 144 | 145 | val CLEANER_RE = Regex("\\s+") 146 | const val SNIPPET_SPLIT = "|" 147 | private const val S = SNIPPET_SPLIT 148 | const val SNIPPET_RESULT = "§" 149 | private const val R = SNIPPET_RESULT 150 | -------------------------------------------------------------------------------- /app/src/main/java/net/phbwt/paperwork/data/dao/DownloadDao.kt: -------------------------------------------------------------------------------- 1 | package net.phbwt.paperwork.data.dao 2 | 3 | import android.util.Log 4 | import androidx.room.Dao 5 | import androidx.room.Query 6 | import androidx.room.RewriteQueriesToDropUnusedColumns 7 | import androidx.room.Transaction 8 | import kotlinx.coroutines.flow.Flow 9 | import kotlinx.coroutines.flow.flowOf 10 | import net.phbwt.paperwork.data.entity.db.* 11 | 12 | @Dao 13 | interface DownloadDao { 14 | 15 | @RewriteQueriesToDropUnusedColumns 16 | @Query( 17 | """ 18 | select p.*, d.name as documentName, d.thumb as documentThumb 19 | from Part p 20 | join Document d 21 | on d.documentId = p.documentId 22 | where p.downloadStatus in ($DNL_QUEUED) order by p.partId limit 1 23 | """ 24 | ) 25 | suspend fun loadFirstDownloadableImpl(): Download? 26 | 27 | @Transaction 28 | suspend fun loadFirstDownloadable(): Download? { 29 | val dnl = loadFirstDownloadableImpl() 30 | if (dnl != null) { 31 | val count = updateDnlStatusIfImpl(dnl.part.partId, DNL_QUEUED, DNL_DOWNLOADING, null) 32 | if (count != 1) { 33 | // should not happen 34 | throw IllegalStateException("Part ${dnl.part.partId} is not in state QUEUED : $dnl") 35 | } 36 | } 37 | return dnl 38 | } 39 | 40 | @Query("update Part set downloadStatus = $DNL_ERROR, downloadError = 'canceled' where downloadStatus = $DNL_DOWNLOADING") 41 | suspend fun clearStuckDownloads(): Int 42 | 43 | @Query("update Part set downloadStatus = $DNL_NONE, downloadError = null where downloadStatus != $DNL_NONE") 44 | suspend fun purgeDownloads(): Int 45 | 46 | @Query("update Part set downloadStatus = :status, downloadError = :error where partId = :partId") 47 | suspend fun updateDnlStatusImpl(partId: Int, status: Int, error: String?) 48 | 49 | @Query("update Part set downloadStatus = :newStatus, downloadError = :error where partId = :partId and downloadStatus = :oldStatus") 50 | suspend fun updateDnlStatusIfImpl(partId: Int, oldStatus: Int, newStatus: Int, error: String?): Int 51 | 52 | @Transaction 53 | suspend fun setPartFailed(partId: Int, error: String) { 54 | val count = updateDnlStatusIfImpl(partId, DNL_DOWNLOADING, DNL_ERROR, error) 55 | 56 | if (count != 1) { 57 | // not the expected state, reset 58 | Log.i(TAG, "setPartFailed : part $partId was not in state DOWNLOADING") 59 | updateDnlStatusImpl(partId, DNL_NONE, null) 60 | } 61 | } 62 | 63 | @Transaction 64 | suspend fun setPartDone(partId: Int): Boolean { 65 | val count = updateDnlStatusIfImpl(partId, DNL_DOWNLOADING, DNL_DONE, null) 66 | 67 | if (count != 1) { 68 | // not the expected state, probably because the processing took 69 | // too long and the user canceled it 70 | // anyway, reset the state, as we will delete the file 71 | Log.i(TAG, "setPartDone : part $partId was not in state DOWNLOADING") 72 | updateDnlStatusImpl(partId, DNL_NONE, null) 73 | } 74 | 75 | return count == 1 76 | } 77 | 78 | @Query( 79 | """ 80 | update Part 81 | set downloadStatus = $DNL_DONE 82 | where name = :partName 83 | and documentId = (select documentId from Document where name = :documentName) 84 | """ 85 | ) 86 | suspend fun setPartDone(documentName: String, partName: String): Int 87 | 88 | @Query("update Part set downloadStatus = $DNL_NONE, downloadError = null where documentId = :docId") 89 | suspend fun setDocumentCleared(docId: Int) 90 | 91 | @Query( 92 | """ 93 | update Part 94 | set downloadStatus = $DNL_QUEUED 95 | , downloadError = null 96 | where partId = :partId 97 | and downloadStatus not in ($DNL_QUEUED, $DNL_DOWNLOADING, $DNL_DONE) 98 | """ 99 | ) 100 | suspend fun restartPart(partId: Int) 101 | 102 | @Query( 103 | """ 104 | update Part 105 | set downloadStatus = $DNL_QUEUED 106 | , downloadError = null 107 | where documentId = :docId 108 | and downloadStatus not in ($DNL_QUEUED, $DNL_DOWNLOADING, $DNL_DONE) 109 | """ 110 | ) 111 | suspend fun queueDownloadForDocument(docId: Int) 112 | 113 | suspend fun queueAutoDownloads(labels: List): Int = when { 114 | labels.isEmpty() -> 0 115 | labels.any { it == "*" } -> queueAutoDownloadsAllImpl() 116 | else -> queueAutoDownloadsImpl(labels) 117 | } 118 | 119 | @Query( 120 | """ 121 | update Part 122 | set downloadStatus = $DNL_QUEUED 123 | , downloadError = null 124 | where downloadStatus not in ($DNL_QUEUED, $DNL_DOWNLOADING, $DNL_DONE) 125 | """ 126 | ) 127 | suspend fun queueAutoDownloadsAllImpl(): Int 128 | 129 | @Query( 130 | """ 131 | update Part 132 | set downloadStatus = $DNL_QUEUED 133 | , downloadError = null 134 | where documentId in (select distinct documentId from Label where name in (:labels)) 135 | and downloadStatus not in ($DNL_QUEUED, $DNL_DOWNLOADING, $DNL_DONE) 136 | """ 137 | ) 138 | suspend fun queueAutoDownloadsImpl(labels: List): Int 139 | 140 | fun countAutoDownloads(labels: List): Flow = when { 141 | labels.isEmpty() -> flowOf(AutoDownloadInfo(0, 0)) 142 | labels.any { it == "*" } -> countAutoDownloadsAllImpl() 143 | else -> countAutoDownloadsImpl(labels) 144 | } 145 | 146 | @Query( 147 | """ 148 | select count(distinct documentId) as total 149 | , count(distinct case downloadStatus when $DNL_DONE then null else documentId end) as todo 150 | from Part 151 | where documentId in (select distinct documentId from Label where name in (:labels)) 152 | """ 153 | ) 154 | fun countAutoDownloadsImpl(labels: List): Flow 155 | 156 | @Query( 157 | """ 158 | select count(distinct documentId) as total 159 | , count(distinct case downloadStatus when $DNL_DONE then null else documentId end) as todo 160 | from Part 161 | """ 162 | ) 163 | fun countAutoDownloadsAllImpl(): Flow 164 | 165 | @Query( 166 | """ 167 | with doc_parts as ( 168 | select p.documentId 169 | , d.size 170 | , count(*) as parts 171 | from Part p 172 | inner join Document d on d.documentId = p.documentId 173 | where p.downloadStatus = $DNL_DONE 174 | group by p.documentId 175 | ) 176 | select count(*) as documents 177 | , sum(parts) as parts 178 | , sum(size) as size 179 | from doc_parts 180 | """ 181 | ) 182 | fun stats(): Flow 183 | 184 | } 185 | 186 | data class DownloadStats( 187 | val documents: Int, 188 | val parts: Int, 189 | val size: Long, 190 | ) 191 | 192 | data class AutoDownloadInfo( 193 | val total: Int, 194 | val todo: Int, 195 | ) 196 | 197 | 198 | private const val TAG = "DownloadDao" -------------------------------------------------------------------------------- /app/src/main/java/net/phbwt/paperwork/data/dao/LabelDao.kt: -------------------------------------------------------------------------------- 1 | package net.phbwt.paperwork.data.dao 2 | 3 | import androidx.room.Dao 4 | import androidx.room.Query 5 | import kotlinx.coroutines.flow.Flow 6 | import net.phbwt.paperwork.data.entity.db.LabelType 7 | 8 | @Dao 9 | abstract class LabelDao { 10 | 11 | @Query("select distinct name from Label") 12 | abstract fun loadLabelTypes(): Flow> 13 | 14 | } -------------------------------------------------------------------------------- /app/src/main/java/net/phbwt/paperwork/data/entity/db/Document.kt: -------------------------------------------------------------------------------- 1 | package net.phbwt.paperwork.data.entity.db 2 | 3 | import androidx.compose.runtime.Immutable 4 | import androidx.room.Entity 5 | import androidx.room.Index 6 | import androidx.room.PrimaryKey 7 | 8 | @Entity( 9 | indices = [ 10 | Index("date", name = "Document_date"), 11 | ] 12 | ) 13 | @Immutable 14 | data class Document( 15 | @PrimaryKey 16 | val documentId: Int, 17 | 18 | val name: String, 19 | 20 | val title: String?, 21 | 22 | val thumb: String?, 23 | 24 | val pageCount: Int, 25 | 26 | val date: Long, 27 | 28 | val mtime: Long, 29 | 30 | val size: Long, 31 | ) { 32 | val titleOrName: String get() = title ?: name 33 | } 34 | 35 | fun makeDocumentThumbPathAndKey(documentName: String, thumb: String?) = "$documentName/$thumb" -------------------------------------------------------------------------------- /app/src/main/java/net/phbwt/paperwork/data/entity/db/DocumentFts.kt: -------------------------------------------------------------------------------- 1 | package net.phbwt.paperwork.data.entity.db 2 | 3 | import androidx.compose.runtime.Immutable 4 | import androidx.room.* 5 | 6 | @Fts4( 7 | tokenizer = FtsOptions.TOKENIZER_UNICODE61, 8 | contentEntity = DocumentText::class, 9 | ) 10 | @Entity 11 | @Immutable 12 | data class DocumentFts( 13 | @PrimaryKey 14 | @ColumnInfo(name = "rowid") 15 | val rowId: Int, 16 | 17 | // content, used to match, and in snippet 18 | val main: String, 19 | 20 | // title, used to match, but not in snippet 21 | val additional: String?, 22 | ) 23 | -------------------------------------------------------------------------------- /app/src/main/java/net/phbwt/paperwork/data/entity/db/DocumentFull.kt: -------------------------------------------------------------------------------- 1 | package net.phbwt.paperwork.data.entity.db 2 | 3 | import androidx.compose.runtime.Immutable 4 | import androidx.room.Embedded 5 | import androidx.room.Relation 6 | 7 | @Immutable 8 | data class DocumentFull( 9 | @Embedded 10 | val document: Document, 11 | 12 | // only present when searching via a FTS query 13 | val snippet: String?, 14 | 15 | @Relation( 16 | parentColumn = "documentId", 17 | entityColumn = "documentId", 18 | ) 19 | val parts: List, 20 | 21 | @Relation( 22 | entity = Label::class, 23 | parentColumn = "documentId", 24 | entityColumn = "documentId", 25 | projection = ["name"] 26 | ) 27 | val labelNames: List, 28 | ) { 29 | val docPath get() = document.name 30 | 31 | fun partPath(idx: Int) = partPath(parts[idx]) 32 | 33 | fun partPath(part: Part) = part.path(docPath) 34 | 35 | val isPdfDoc get() = parts.size == 1 && parts.first().isPdfPart 36 | 37 | val isImagesDoc get() = parts.isNotEmpty() && parts.first().isImagePart 38 | 39 | val canBeViewed 40 | get() = when { 41 | isImagesDoc -> true 42 | isPdfDoc && downloadStatus == DownloadState.LOCAL -> true 43 | else -> false 44 | } 45 | 46 | val downloadStatus 47 | get() = when { 48 | parts.all { it.isLocal } -> DownloadState.LOCAL 49 | parts.any { it.isFailed } -> DownloadState.FAILED 50 | parts.any { it.isInProgress } -> DownloadState.IN_PROGRESS 51 | parts.any { it.isQueued } -> DownloadState.QUEUED 52 | else -> DownloadState.DOWNLOADABLE 53 | } 54 | } 55 | 56 | enum class DownloadState { LOCAL, FAILED, QUEUED, IN_PROGRESS, DOWNLOADABLE } 57 | 58 | -------------------------------------------------------------------------------- /app/src/main/java/net/phbwt/paperwork/data/entity/db/DocumentText.kt: -------------------------------------------------------------------------------- 1 | package net.phbwt.paperwork.data.entity.db 2 | 3 | import androidx.compose.runtime.Immutable 4 | import androidx.room.* 5 | 6 | @Entity( 7 | foreignKeys = [ 8 | ForeignKey( 9 | entity = Document::class, 10 | parentColumns = arrayOf("documentId"), 11 | childColumns = arrayOf("documentId"), 12 | onDelete = ForeignKey.CASCADE, 13 | ), 14 | ], 15 | ) 16 | @Immutable 17 | data class DocumentText( 18 | @PrimaryKey 19 | val documentId: Int, 20 | 21 | val main: String, 22 | 23 | val additional: String?, 24 | ) 25 | -------------------------------------------------------------------------------- /app/src/main/java/net/phbwt/paperwork/data/entity/db/Download.kt: -------------------------------------------------------------------------------- 1 | package net.phbwt.paperwork.data.entity.db 2 | 3 | import androidx.compose.runtime.Immutable 4 | import androidx.room.Embedded 5 | import java.io.File 6 | 7 | @Immutable 8 | data class Download( 9 | val documentName: String, 10 | val documentThumb: String, 11 | 12 | @Embedded val part: Part, 13 | ) { 14 | fun partPathAndKey() = part.path(documentName) 15 | } 16 | 17 | fun File.isThumb() = this.isFile && this.name.contains(".thumb.", ignoreCase = true) -------------------------------------------------------------------------------- /app/src/main/java/net/phbwt/paperwork/data/entity/db/Label.kt: -------------------------------------------------------------------------------- 1 | package net.phbwt.paperwork.data.entity.db 2 | 3 | import androidx.compose.runtime.Immutable 4 | import androidx.room.* 5 | 6 | 7 | @Entity( 8 | foreignKeys = [ 9 | ForeignKey( 10 | entity = Document::class, 11 | parentColumns = arrayOf("documentId"), 12 | childColumns = arrayOf("documentId"), 13 | onDelete = ForeignKey.CASCADE, 14 | ), 15 | ], 16 | indices = [ 17 | Index("documentId", name = "Label_documentId"), 18 | Index("name", name = "Label_name"), 19 | ] 20 | ) 21 | @Immutable 22 | data class Label( 23 | @PrimaryKey 24 | val labelId: Int, 25 | 26 | val documentId: Int, 27 | 28 | val name: String, 29 | 30 | val color: String?, 31 | ) 32 | 33 | -------------------------------------------------------------------------------- /app/src/main/java/net/phbwt/paperwork/data/entity/db/LabelType.kt: -------------------------------------------------------------------------------- 1 | package net.phbwt.paperwork.data.entity.db 2 | 3 | import androidx.compose.runtime.Immutable 4 | import androidx.room.Ignore 5 | import java.text.Normalizer 6 | import java.util.* 7 | 8 | /** 9 | * A label, with the original text 10 | * and a lowercase version without diacritic 11 | */ 12 | @Immutable 13 | data class LabelType( 14 | val name: String, 15 | ) { 16 | @Ignore 17 | val normalizedName: String = name.asFilter() 18 | } 19 | 20 | private val REMOVE_DIACRITICS = "\\p{Mn}+".toRegex() 21 | 22 | fun String.asFilter() = Normalizer 23 | .normalize(this, Normalizer.Form.NFKD) 24 | .replace(REMOVE_DIACRITICS, "") 25 | // not the invariant Locale, for once 26 | .lowercase(Locale.getDefault()) 27 | -------------------------------------------------------------------------------- /app/src/main/java/net/phbwt/paperwork/data/entity/db/Part.kt: -------------------------------------------------------------------------------- 1 | package net.phbwt.paperwork.data.entity.db 2 | 3 | import androidx.compose.runtime.Immutable 4 | import androidx.room.* 5 | 6 | @Entity( 7 | foreignKeys = [ 8 | ForeignKey( 9 | entity = Document::class, 10 | parentColumns = arrayOf("documentId"), 11 | childColumns = arrayOf("documentId"), 12 | onDelete = ForeignKey.CASCADE, 13 | ), 14 | ], 15 | indices = [ 16 | Index("documentId", name = "Part_documentId"), 17 | Index("downloadStatus", name = "Part_downloadStatus"), 18 | ] 19 | ) 20 | @Immutable 21 | data class Part( 22 | @PrimaryKey 23 | val partId: Int, 24 | 25 | val documentId: Int, 26 | 27 | val name: String, 28 | 29 | val downloadStatus: Int = 0, 30 | val downloadError: String? = null, 31 | ) { 32 | fun path(documentName: String) = "${documentName}/${name}" 33 | 34 | val isQueued get() = downloadStatus == DNL_QUEUED 35 | 36 | val isInProgress get() = downloadStatus == DNL_DOWNLOADING 37 | 38 | val isLocal get() = downloadStatus == DNL_DONE 39 | 40 | val isFailed get() = downloadStatus == DNL_ERROR 41 | 42 | val isIn get() = downloadStatus != DNL_NONE && downloadStatus != DNL_DONE 43 | 44 | val isPdfPart: Boolean get() = name.endsWith(".pdf") 45 | 46 | val isImagePart: Boolean get() = IMAGE_EXTENSIONS.contains(name.substringAfterLast('.')) 47 | } 48 | 49 | const val DNL_NONE = 100 50 | const val DNL_QUEUED = 1 51 | const val DNL_DOWNLOADING = 2 52 | const val DNL_DONE = 3 53 | const val DNL_ERROR = 4 54 | 55 | private val IMAGE_EXTENSIONS = listOf("png", "jpg") 56 | -------------------------------------------------------------------------------- /app/src/main/java/net/phbwt/paperwork/data/helper/LocalFetcher.kt: -------------------------------------------------------------------------------- 1 | package net.phbwt.paperwork.data.helper 2 | 3 | import android.util.Log 4 | import android.webkit.MimeTypeMap 5 | import coil3.ImageLoader 6 | import coil3.Uri 7 | import coil3.decode.DataSource 8 | import coil3.decode.ImageSource 9 | import coil3.fetch.FetchResult 10 | import coil3.fetch.Fetcher 11 | import coil3.fetch.SourceFetchResult 12 | import coil3.request.Options 13 | import okio.FileSystem 14 | import okio.Path.Companion.toOkioPath 15 | import java.io.File 16 | 17 | /** 18 | * Coil fetcher looking in the downloaded documents 19 | */ 20 | class LocalFetcher(private val data: File) : Fetcher { 21 | 22 | override suspend fun fetch(): FetchResult { 23 | return SourceFetchResult( 24 | source = ImageSource(file = data.toOkioPath(), FileSystem.SYSTEM), 25 | mimeType = MimeTypeMap.getSingleton().getMimeTypeFromExtension(data.extension), 26 | dataSource = DataSource.DISK 27 | ) 28 | } 29 | 30 | class Factory(private val baseDir: File) : Fetcher.Factory { 31 | override fun create(data: Uri, options: Options, imageLoader: ImageLoader): Fetcher? { 32 | val k = options.diskCacheKey 33 | return if (k == null) { 34 | null 35 | } else { 36 | // FIXME what if the document is currently being downloaded ? 37 | // currently we only check if the file has not modified recently 38 | val p = File(baseDir, k) 39 | 40 | // 0 is the file does not exists 41 | val lastModified = p.lastModified() 42 | 43 | if (0 < lastModified && lastModified < System.currentTimeMillis() - CACHE_DELAY_MILLIS) { 44 | Log.d(TAG, "Found '$k' in local data") 45 | LocalFetcher(p) 46 | } else { 47 | null 48 | } 49 | } 50 | } 51 | } 52 | 53 | companion object { 54 | private const val TAG = "LocalFetcher" 55 | private const val CACHE_DELAY_MILLIS = 60 * 1000L 56 | } 57 | } -------------------------------------------------------------------------------- /app/src/main/java/net/phbwt/paperwork/data/settings/Settings.kt: -------------------------------------------------------------------------------- 1 | package net.phbwt.paperwork.data.settings 2 | 3 | import android.content.Context 4 | import androidx.datastore.core.DataStore 5 | import androidx.datastore.preferences.core.Preferences 6 | import androidx.datastore.preferences.core.edit 7 | import androidx.datastore.preferences.core.stringPreferencesKey 8 | import androidx.datastore.preferences.preferencesDataStore 9 | import dagger.hilt.android.qualifiers.ApplicationContext 10 | import kotlinx.coroutines.flow.Flow 11 | import kotlinx.coroutines.flow.distinctUntilChanged 12 | import kotlinx.coroutines.flow.map 13 | import net.phbwt.paperwork.helper.mapResultFlow 14 | import okhttp3.CacheControl 15 | import okhttp3.HttpUrl 16 | import okhttp3.HttpUrl.Companion.toHttpUrl 17 | import okhttp3.Request 18 | import okhttp3.tls.HeldCertificate 19 | import okhttp3.tls.decodeCertificatePem 20 | import java.io.File 21 | import java.security.cert.X509Certificate 22 | import java.util.concurrent.TimeUnit 23 | import javax.inject.Inject 24 | import javax.inject.Singleton 25 | 26 | val Context.dataStore: DataStore by preferencesDataStore(name = "settings") 27 | 28 | private val BASE_URL = stringPreferencesKey("base_url") 29 | private val AUTO_DOWNLOAD_LABELS = stringPreferencesKey("auto_download_labels") 30 | private val SERVER_CA = stringPreferencesKey("server_ca") 31 | private val CLIENT_PEM = stringPreferencesKey("client_pem") 32 | 33 | const val LABELS_SEPARATOR = ',' 34 | 35 | @Singleton 36 | class Settings @Inject constructor( 37 | @ApplicationContext private val ctxt: Context, 38 | ) { 39 | 40 | //region stored preferences 41 | 42 | val baseUrlStr: Flow = ctxt.dataStore.data 43 | .map { it[BASE_URL] ?: "" } 44 | .distinctUntilChanged() 45 | 46 | val autoDownloadLabelsStr: Flow = ctxt.dataStore.data 47 | .map { it[AUTO_DOWNLOAD_LABELS] ?: "" } 48 | .distinctUntilChanged() 49 | 50 | val clientPemStr: Flow = ctxt.dataStore.data 51 | .map { it[CLIENT_PEM] ?: "" } 52 | .distinctUntilChanged() 53 | 54 | val serverCaStr: Flow = ctxt.dataStore.data 55 | .map { it[SERVER_CA] ?: "" } 56 | .distinctUntilChanged() 57 | 58 | //endregion 59 | 60 | //region derived shared objects (as Result) 61 | 62 | val baseUrl: Flow> = baseUrlStr.map { 63 | runCatching { it.toHttpUrl() } 64 | } 65 | 66 | val contentBaseUrl: Flow> = baseUrl.mapResultFlow { 67 | it.newBuilder().addPathSegment("papers").build() 68 | } 69 | 70 | // Request (without body) are immutable, so we can reuse it 71 | val dbRequest: Flow> = baseUrl.mapResultFlow { baseUrl -> 72 | val dbUrl = baseUrl.newBuilder().addPathSegment("papers.sqlite").build() 73 | 74 | Request.Builder() 75 | .cacheControl( 76 | // we want to use the cache (not to download the db unnecessarily) 77 | // but also ignore max-age (to force a check on server) 78 | CacheControl.Builder() 79 | .maxAge(0, TimeUnit.SECONDS) 80 | .build() 81 | ) 82 | .url(dbUrl) 83 | .build() 84 | } 85 | 86 | val autoDownloadLabels: Flow>> = autoDownloadLabelsStr.map { 87 | runCatching { it.split(LABELS_SEPARATOR).map { it.trim() }.distinct() } 88 | } 89 | 90 | val clientPem: Flow> = clientPemStr.map { 91 | runCatching { if (it.isNotBlank()) HeldCertificate.decode(it.trim()) else null } 92 | } 93 | 94 | val serverCa: Flow> = serverCaStr.map { 95 | runCatching { if (it.isNotBlank()) it.trim().decodeCertificatePem() else null } 96 | } 97 | 98 | //endregion 99 | 100 | // Document's page images (not for PDF) may exist 101 | // in Coil's cache, in the downloaded data, or both. 102 | // The DownloadWorker first checks the cache : if available the image is 103 | // copied to the downloaded data. 104 | // On the other hand, a custom Coil fetcher try to use the downloaded 105 | // data if available (without copying it to the cache) 106 | 107 | // Coil image cache 108 | // contains documents thumbnails and pages images of non-PDF document 109 | val imageCacheDir: File = ctxt.cacheDir.resolve("image_cache") 110 | 111 | // OkHttp db cache 112 | // contains the SQLite file downloaded 113 | val dbCacheDir: File = ctxt.cacheDir.resolve("db_cache") 114 | 115 | // OkHttp settings checks cache 116 | // temporary cache used when checking the settings 117 | val checksCacheDir: File = ctxt.cacheDir.resolve("checks_cache") 118 | 119 | // Downloaded data : PDF and page images 120 | // also shared, @see xml/file_provider_paths.xml 121 | val localPartsDir: File = ctxt.filesDir.resolve("local_files/parts") 122 | 123 | suspend fun updateBaseUrl(newVal: String) = update(BASE_URL, newVal.take(MAX_VALUE_SIZE)) 124 | suspend fun updateAutoDownloadLabels(newVal: String) = update(AUTO_DOWNLOAD_LABELS, newVal.take(MAX_VALUE_SIZE)) 125 | suspend fun updateClientPem(newVal: String) = update(CLIENT_PEM, newVal.take(MAX_VALUE_SIZE)) 126 | suspend fun updateServerCa(newVal: String) = update(SERVER_CA, newVal.take(MAX_VALUE_SIZE)) 127 | 128 | private suspend fun update(key: Preferences.Key, newVal: String) = 129 | ctxt.dataStore.edit { settings -> 130 | settings[key] = newVal 131 | } 132 | 133 | } 134 | 135 | const val MAX_VALUE_SIZE = 65536 -------------------------------------------------------------------------------- /app/src/main/java/net/phbwt/paperwork/helper/FileProvider.kt: -------------------------------------------------------------------------------- 1 | package net.phbwt.paperwork.helper 2 | 3 | import androidx.core.content.FileProvider 4 | import net.phbwt.paperwork.R 5 | 6 | class FileProvider : FileProvider(R.xml.file_provider_paths) { 7 | } -------------------------------------------------------------------------------- /app/src/main/java/net/phbwt/paperwork/helper/FlowExtensions.kt: -------------------------------------------------------------------------------- 1 | @file:OptIn(FlowPreview::class) 2 | 3 | package net.phbwt.paperwork.helper 4 | 5 | import kotlinx.coroutines.CoroutineScope 6 | import kotlinx.coroutines.FlowPreview 7 | import kotlinx.coroutines.flow.* 8 | 9 | 10 | fun Flow.latestRelease(scope: CoroutineScope, initialValue: T): StateFlow = this.stateIn( 11 | scope = scope, 12 | started = SharingStarted.WhileSubscribed(5000), 13 | initialValue = initialValue, 14 | ) 15 | 16 | fun Flow.firstThenDebounce(timeoutMillis: Long = 1000): Flow { 17 | var isFirst = true 18 | val a = debounce { 19 | if (isFirst) { 20 | isFirst = false 21 | 0L 22 | } else { 23 | timeoutMillis 24 | } 25 | } 26 | return a 27 | } 28 | -------------------------------------------------------------------------------- /app/src/main/java/net/phbwt/paperwork/helper/GestureHelper.kt: -------------------------------------------------------------------------------- 1 | package net.phbwt.paperwork.helper 2 | 3 | import androidx.compose.animation.core.* 4 | import androidx.compose.foundation.gestures.* 5 | import androidx.compose.ui.geometry.Offset 6 | import androidx.compose.ui.input.pointer.PointerInputScope 7 | import androidx.compose.ui.input.pointer.positionChanged 8 | import kotlin.math.PI 9 | import kotlin.math.abs 10 | 11 | /** 12 | * Copy of PointerInputScope.detectTransformGestures 13 | * optionally not consuming the events 14 | * so that they may be handled by e.g. a pager 15 | */ 16 | suspend fun PointerInputScope.appDetectTransformGestures( 17 | panZoomLock: Boolean = false, 18 | onGesture: (centroid: Offset, pan: Offset, zoom: Float, rotation: Float) -> Boolean, 19 | ) { 20 | awaitEachGesture { 21 | var rotation = 0f 22 | var zoom = 1f 23 | var pan = Offset.Zero 24 | var pastTouchSlop = false 25 | val touchSlop = viewConfiguration.touchSlop 26 | var lockedToPanZoom = false 27 | 28 | awaitFirstDown(requireUnconsumed = false) 29 | do { 30 | val event = awaitPointerEvent() 31 | val canceled = event.changes.any { it.isConsumed } 32 | if (!canceled) { 33 | val zoomChange = event.calculateZoom() 34 | val rotationChange = event.calculateRotation() 35 | val panChange = event.calculatePan() 36 | 37 | if (!pastTouchSlop) { 38 | zoom *= zoomChange 39 | rotation += rotationChange 40 | pan += panChange 41 | 42 | val centroidSize = event.calculateCentroidSize(useCurrent = false) 43 | val zoomMotion = abs(1 - zoom) * centroidSize 44 | val rotationMotion = abs(rotation * PI.toFloat() * centroidSize / 180f) 45 | val panMotion = pan.getDistance() 46 | 47 | if (zoomMotion > touchSlop || 48 | rotationMotion > touchSlop || 49 | panMotion > touchSlop 50 | ) { 51 | pastTouchSlop = true 52 | lockedToPanZoom = panZoomLock && rotationMotion < touchSlop 53 | } 54 | } 55 | 56 | if (pastTouchSlop) { 57 | val centroid = event.calculateCentroid(useCurrent = false) 58 | val effectiveRotation = if (lockedToPanZoom) 0f else rotationChange 59 | val consume = if (effectiveRotation != 0f || 60 | zoomChange != 1f || 61 | panChange != Offset.Zero 62 | ) { 63 | onGesture(centroid, panChange, zoomChange, effectiveRotation) 64 | } else { 65 | true 66 | } 67 | // originally events where always consumed 68 | if (consume) { 69 | event.changes.forEach { 70 | if (it.positionChanged()) { 71 | it.consume() 72 | } 73 | } 74 | } 75 | } 76 | } 77 | } while (!canceled && event.changes.any { it.pressed }) 78 | } 79 | } 80 | 81 | /** 82 | * animatePanBy + animateZoomBy 83 | */ 84 | suspend fun TransformableState.animateBy( 85 | zoomChange: Float, 86 | panChange: Offset, 87 | animationSpec: AnimationSpec = SpringSpec(stiffness = Spring.StiffnessLow), 88 | ) { 89 | var previousValue = 0f 90 | var previousZoom = 1f 91 | transform { 92 | AnimationState(initialValue = previousValue).animateTo(1f, animationSpec) { 93 | val delta = this.value - previousValue 94 | val newZoom = 1 + (this.value * (zoomChange - 1)) 95 | 96 | transformBy( 97 | zoomChange = newZoom / previousZoom, 98 | panChange = panChange * delta, 99 | ) 100 | previousValue = this.value 101 | previousZoom = newZoom 102 | } 103 | } 104 | } 105 | 106 | 107 | -------------------------------------------------------------------------------- /app/src/main/java/net/phbwt/paperwork/helper/MiscHelper.kt: -------------------------------------------------------------------------------- 1 | package net.phbwt.paperwork.helper 2 | 3 | import android.content.Context 4 | import android.content.Intent 5 | import android.util.Log 6 | import androidx.compose.runtime.Immutable 7 | 8 | private const val TAG = "MiscHelper" 9 | 10 | fun Intent?.startActivitySafely(ctxt: Context) = this?.let { 11 | try { 12 | ctxt.startActivity(this) 13 | } catch (ex: Exception) { 14 | // TODO: show snackbar 15 | Log.w(TAG, "Could not start activity $this", ex) 16 | } 17 | } 18 | 19 | @Immutable 20 | data class ComposeImmutableList( 21 | private val internalList: List 22 | ) : List by internalList 23 | 24 | fun List.toComposeImmutable(): ComposeImmutableList = when (this) { 25 | is ComposeImmutableList -> this 26 | else -> ComposeImmutableList(this) 27 | } -------------------------------------------------------------------------------- /app/src/main/java/net/phbwt/paperwork/helper/ResultExtensions.kt: -------------------------------------------------------------------------------- 1 | package net.phbwt.paperwork.helper 2 | 3 | import kotlinx.coroutines.flow.Flow 4 | import kotlinx.coroutines.flow.combine 5 | import kotlinx.coroutines.flow.map 6 | 7 | 8 | // TODO : something less ugly 9 | fun combineResults( 10 | r1: Result, 11 | r2: Result, 12 | transform: (T1, T2) -> R, 13 | ): Result = when { 14 | r1.isFailure -> Result.failure(r1.exceptionOrNull()!!) 15 | r2.isFailure -> Result.failure(r2.exceptionOrNull()!!) 16 | else -> runCatching { transform(r1.getOrThrow(), r2.getOrThrow()) } 17 | } 18 | 19 | fun combineResultFlows( 20 | f1: Flow>, 21 | f2: Flow>, 22 | transform: (T1, T2) -> R, 23 | ): Flow> = combine(f1, f2) { r1, r2 -> 24 | combineResults(r1, r2, transform) 25 | } 26 | 27 | 28 | // map the flow / map the result 29 | fun Flow>.mapResultFlow(transform: (R) -> F): Flow> = 30 | this.map { it.mapCatching(transform) } -------------------------------------------------------------------------------- /app/src/main/java/net/phbwt/paperwork/helper/TextHelper.kt: -------------------------------------------------------------------------------- 1 | package net.phbwt.paperwork.helper 2 | 3 | import android.content.Context 4 | import android.text.format.DateUtils 5 | import androidx.compose.animation.AnimatedVisibility 6 | import androidx.compose.animation.fadeIn 7 | import androidx.compose.animation.fadeOut 8 | import androidx.compose.material.icons.Icons 9 | import androidx.compose.material.icons.outlined.Close 10 | import androidx.compose.material3.Icon 11 | import androidx.compose.material3.IconButton 12 | import androidx.compose.runtime.Composable 13 | 14 | 15 | fun Long.fmtDtm(context: Context): String { 16 | return when { 17 | this <= 0 -> "" 18 | this > System.currentTimeMillis() - 8 * 3600 * 1000 -> 19 | DateUtils.formatDateTime(context, this, DateUtils.FORMAT_SHOW_TIME) 20 | 21 | else -> 22 | DateUtils.formatDateTime(context, this, DateUtils.FORMAT_SHOW_DATE) 23 | } 24 | } 25 | 26 | fun Long.fmtDtmSec(context: Context) = (this * 1000).fmtDtm(context) 27 | 28 | fun Throwable?.desc(): String = if (this == null) { 29 | "" 30 | } else { 31 | val cn = this::class.java.simpleName 32 | if (this.message.isNullOrBlank()) { 33 | cn 34 | } else { 35 | "$cn : ${this.message}" 36 | } 37 | } 38 | 39 | fun Throwable?.msg(): String = if (this == null) { 40 | "" 41 | } else { 42 | val cn = this::class.java.simpleName 43 | if (this.message.isNullOrBlank()) { 44 | cn 45 | } else { 46 | this.message ?: "" 47 | } 48 | } 49 | 50 | 51 | @Composable 52 | fun TrailingClose(visible: Boolean, onClick: () -> Unit) = AnimatedVisibility( 53 | visible = visible, 54 | enter = fadeIn(), 55 | exit = fadeOut(), 56 | ) { 57 | IconButton(onClick = onClick) { 58 | Icon(Icons.Outlined.Close, null) 59 | } 60 | } 61 | 62 | -------------------------------------------------------------------------------- /app/src/main/java/net/phbwt/paperwork/helper/hilt/CoroutineScopeModule.kt: -------------------------------------------------------------------------------- 1 | package net.phbwt.paperwork.helper.hilt 2 | 3 | import dagger.Module 4 | import dagger.Provides 5 | import dagger.hilt.InstallIn 6 | import dagger.hilt.components.SingletonComponent 7 | import kotlinx.coroutines.CoroutineScope 8 | import kotlinx.coroutines.Dispatchers 9 | import kotlinx.coroutines.SupervisorJob 10 | import javax.inject.Singleton 11 | 12 | 13 | // cf Create an application CoroutineScope using Hilt, by Manuel Vivo 14 | // https://medium.com/androiddevelopers/create-an-application-coroutinescope-using-hilt-dd444e721528 15 | @InstallIn(SingletonComponent::class) 16 | @Module 17 | object CoroutinesScopesModule { 18 | 19 | @Singleton 20 | @Provides 21 | fun providesCoroutineScope(): CoroutineScope { 22 | // Run this code when providing an instance of CoroutineScope 23 | return CoroutineScope(SupervisorJob() + Dispatchers.Default) 24 | } 25 | } 26 | 27 | -------------------------------------------------------------------------------- /app/src/main/java/net/phbwt/paperwork/ui/MainActivity.kt: -------------------------------------------------------------------------------- 1 | package net.phbwt.paperwork.ui 2 | 3 | import android.os.Bundle 4 | import androidx.activity.ComponentActivity 5 | import androidx.activity.SystemBarStyle 6 | import androidx.activity.compose.setContent 7 | import androidx.activity.enableEdgeToEdge 8 | import androidx.compose.foundation.isSystemInDarkTheme 9 | import androidx.compose.runtime.Composable 10 | import androidx.compose.runtime.LaunchedEffect 11 | import androidx.compose.ui.graphics.toArgb 12 | import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen 13 | import dagger.hilt.android.AndroidEntryPoint 14 | import net.phbwt.paperwork.ui.main.MainScreen 15 | import net.phbwt.paperwork.ui.theme.AppTheme 16 | import net.phbwt.paperwork.ui.theme.darkScheme 17 | import net.phbwt.paperwork.ui.theme.lightScheme 18 | 19 | 20 | @AndroidEntryPoint 21 | class MainActivity : ComponentActivity() { 22 | 23 | override fun onCreate(savedInstanceState: Bundle?) { 24 | installSplashScreen() 25 | super.onCreate(savedInstanceState) 26 | 27 | setContent { 28 | val isDark = isSystemInDarkTheme() 29 | ChangeSystemBarsTheme(isDark) 30 | AppTheme(isDark, false) { 31 | MainScreen() 32 | } 33 | } 34 | } 35 | 36 | @Composable 37 | private fun ChangeSystemBarsTheme(isDark: Boolean) { 38 | 39 | LaunchedEffect(isDark) { 40 | val statusbarDark = darkScheme.primaryContainer.toArgb() 41 | val statusbarLight = lightScheme.primaryContainer.toArgb() 42 | // ignored on 29+ 43 | val navbarLight = lightScheme.primaryContainer.copy(alpha = .6f).toArgb() 44 | val navbarDark = darkScheme.primaryContainer.copy(alpha = .6f).toArgb() 45 | if (isDark) { 46 | enableEdgeToEdge( 47 | statusBarStyle = SystemBarStyle.dark(statusbarDark), 48 | navigationBarStyle = SystemBarStyle.auto(navbarLight, navbarDark), 49 | ) 50 | } else { 51 | enableEdgeToEdge( 52 | statusBarStyle = SystemBarStyle.light(statusbarLight, statusbarDark), 53 | navigationBarStyle = SystemBarStyle.auto(navbarLight, navbarDark), 54 | ) 55 | } 56 | } 57 | } 58 | } 59 | 60 | 61 | -------------------------------------------------------------------------------- /app/src/main/java/net/phbwt/paperwork/ui/about/AboutScreen.kt: -------------------------------------------------------------------------------- 1 | @file:OptIn(ExperimentalTextApi::class) 2 | 3 | package net.phbwt.paperwork.ui.about 4 | 5 | import android.content.Context 6 | import android.content.Intent 7 | import android.net.Uri 8 | import android.util.Log 9 | import androidx.compose.foundation.clickable 10 | import androidx.compose.foundation.layout.Column 11 | import androidx.compose.foundation.layout.Spacer 12 | import androidx.compose.foundation.layout.WindowInsets 13 | import androidx.compose.foundation.layout.navigationBars 14 | import androidx.compose.foundation.layout.padding 15 | import androidx.compose.foundation.layout.windowInsetsBottomHeight 16 | import androidx.compose.foundation.rememberScrollState 17 | import androidx.compose.foundation.verticalScroll 18 | import androidx.compose.material3.LocalContentColor 19 | import androidx.compose.material3.MaterialTheme 20 | import androidx.compose.material3.SnackbarHostState 21 | import androidx.compose.material3.Text 22 | import androidx.compose.runtime.Composable 23 | import androidx.compose.runtime.remember 24 | import androidx.compose.ui.ExperimentalComposeUiApi 25 | import androidx.compose.ui.Modifier 26 | import androidx.compose.ui.graphics.Color 27 | import androidx.compose.ui.platform.LocalContext 28 | import androidx.compose.ui.res.stringResource 29 | import androidx.compose.ui.text.AnnotatedString 30 | import androidx.compose.ui.text.ExperimentalTextApi 31 | import androidx.compose.ui.text.LinkAnnotation 32 | import androidx.compose.ui.text.SpanStyle 33 | import androidx.compose.ui.text.buildAnnotatedString 34 | import androidx.compose.ui.text.font.FontWeight 35 | import androidx.compose.ui.text.style.TextDecoration 36 | import androidx.compose.ui.text.withLink 37 | import androidx.compose.ui.text.withStyle 38 | import androidx.compose.ui.tooling.preview.Preview 39 | import androidx.compose.ui.unit.dp 40 | import androidx.hilt.navigation.compose.hiltViewModel 41 | import com.ramcosta.composedestinations.annotation.Destination 42 | import net.phbwt.paperwork.BuildConfig 43 | import net.phbwt.paperwork.R 44 | import net.phbwt.paperwork.ui.main.AppTransitions 45 | import net.phbwt.paperwork.ui.main.Dest 46 | import net.phbwt.paperwork.ui.main.WrappedScaffold 47 | import net.phbwt.paperwork.ui.theme.AppTheme 48 | 49 | @Destination(style = AppTransitions::class) 50 | @Composable 51 | fun AboutScreen( 52 | snackbarHostState: SnackbarHostState, 53 | onNavigationIcon: (Boolean) -> Unit, 54 | vm: AboutVM = hiltViewModel(), 55 | ) { 56 | val dbVersion = remember { vm.getDbVersion() } 57 | 58 | AboutContent( 59 | dbVersion, 60 | snackbarHostState, 61 | onNavigationIcon, 62 | ) 63 | } 64 | 65 | @Composable 66 | fun AboutContent( 67 | dbVersion: Int, 68 | snackbarHostState: SnackbarHostState = remember { SnackbarHostState() }, 69 | onNavigationIcon: (Boolean) -> Unit = {}, 70 | ) = WrappedScaffold( 71 | snackbarHostState, 72 | onNavigationIcon, 73 | Dest.About.labelRes, 74 | topLevel = true, 75 | ) { modifier -> 76 | Column( 77 | modifier = modifier 78 | .padding(horizontal = 8.dp) 79 | .verticalScroll(rememberScrollState()), 80 | ) { 81 | 82 | val appName = stringResource(R.string.app_name) 83 | 84 | Text( 85 | appName, 86 | modifier = Modifier.padding(8.dp), 87 | fontWeight = FontWeight.Bold, 88 | style = MaterialTheme.typography.bodyLarge, 89 | ) 90 | 91 | Text( 92 | "Version ${BuildConfig.VERSION_NAME}, database version $dbVersion", 93 | modifier = Modifier.padding(8.dp), 94 | style = MaterialTheme.typography.bodyMedium, 95 | ) 96 | 97 | Text( 98 | "Copyright 2024 Philippe Banwarth.", 99 | modifier = Modifier.padding(8.dp), 100 | style = MaterialTheme.typography.bodyMedium, 101 | ) 102 | 103 | LinkedText( 104 | str = buildAnnotatedString { 105 | appendNormal("This program comes with absolutely no warranty. See the ") 106 | appendLink( 107 | url = "https://www.gnu.org/licenses/gpl-3.0.html", 108 | text = "GNU General Public License, version 3 or later", 109 | ) 110 | appendNormal(" for details.") 111 | }, 112 | modifier = Modifier.padding(8.dp), 113 | ) 114 | 115 | LinkedText( 116 | str = buildAnnotatedString { 117 | appendNormal("$appName uses the following libraries, licensed under the ") 118 | appendLink( 119 | url = "https://www.apache.org/licenses/LICENSE-2.0", 120 | text = "Apache License, version 2.0", 121 | ) 122 | appendNormal(" :") 123 | }, 124 | modifier = Modifier.padding(8.dp), 125 | ) 126 | 127 | LibRow( 128 | "Accompanist", 129 | "https://github.com/google/accompanist", 130 | "A collection of extension libraries for Jetpack Compose.", 131 | ) 132 | 133 | LibRow( 134 | "Android Jetpack", 135 | "https://github.com/androidx/androidx", 136 | "Development environment for Android Jetpack extension libraries under the androidx namespace.", 137 | ) 138 | 139 | LibRow( 140 | "Coil", 141 | "https://github.com/coil-kt/coil", 142 | "Image loading for Android and Compose Multiplatform.", 143 | ) 144 | 145 | LibRow( 146 | "Compose Destinations", 147 | "https://github.com/raamcosta/compose-destinations", 148 | "Annotation processing library for type-safe Jetpack Compose navigation with no boilerplate.", 149 | ) 150 | 151 | LibRow( 152 | "Dagger", 153 | "https://github.com/google/dagger", 154 | "A fast dependency injector for Android and Java.", 155 | ) 156 | 157 | LibRow( 158 | "kotlin-coroutines-okhttp", 159 | "https://github.com/gildor/kotlin-coroutines-okhttp", 160 | "Kotlin Coroutines await() extension for OkHttp Call.", 161 | ) 162 | 163 | LibRow( 164 | "kotlinx.collections.immutable", 165 | "https://github.com/Kotlin/kotlinx.collections.immutable", 166 | "Immutable persistent collections for Kotlin.", 167 | ) 168 | 169 | LibRow( 170 | "OkHttp", 171 | "https://github.com/square/okhttp", 172 | "Square’s meticulous HTTP client for the JVM, Android, and GraalVM.", 173 | ) 174 | 175 | // edge2edge : bottom 176 | Spacer(Modifier.windowInsetsBottomHeight(WindowInsets.navigationBars)) 177 | } 178 | } 179 | 180 | 181 | @Composable 182 | fun LibRow(name: String, url: String, desc: String) { 183 | val context = LocalContext.current 184 | Column( 185 | modifier = Modifier.padding(8.dp), 186 | ) { 187 | Text( 188 | name, 189 | modifier = Modifier 190 | .clickable { context.showUrl(url) }, 191 | fontWeight = FontWeight.Bold, 192 | style = MaterialTheme.typography.bodyLarge, 193 | color = MaterialTheme.colorScheme.primary, 194 | textDecoration = TextDecoration.Underline, 195 | ) 196 | Text( 197 | desc, 198 | style = MaterialTheme.typography.bodyMedium, 199 | ) 200 | } 201 | } 202 | 203 | @Composable 204 | fun LinkedText( 205 | str: AnnotatedString, 206 | modifier: Modifier = Modifier, 207 | context: Context = LocalContext.current, 208 | ) = Text( 209 | str, 210 | modifier = modifier, 211 | style = MaterialTheme.typography.bodyMedium, 212 | ) 213 | 214 | 215 | @Composable 216 | fun AnnotatedString.Builder.appendLink( 217 | url: String, 218 | text: String, 219 | color: Color = MaterialTheme.colorScheme.primary, 220 | ) = withLink(LinkAnnotation.Url(url)) { 221 | withStyle( 222 | style = SpanStyle( 223 | color = color, 224 | textDecoration = TextDecoration.Underline, 225 | ), 226 | ) { append(text) } 227 | } 228 | 229 | @Composable 230 | fun AnnotatedString.Builder.appendNormal( 231 | text: String, 232 | color: Color = LocalContentColor.current, 233 | ) = withStyle(style = SpanStyle(color = color)) { append(text) } 234 | 235 | 236 | private fun Context.showUrl(url: String?) { 237 | if (url.isNullOrEmpty()) { 238 | Log.e(TAG, "No URL") 239 | } else { 240 | startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(url))) 241 | } 242 | } 243 | 244 | 245 | private const val TAG = "AboutScreen" 246 | 247 | //region preview 248 | 249 | @ExperimentalComposeUiApi 250 | @Preview(showBackground = true) 251 | @Composable 252 | fun DefaultPreview() { 253 | AppTheme { 254 | AboutContent(34) 255 | } 256 | } 257 | 258 | //endregion 259 | 260 | -------------------------------------------------------------------------------- /app/src/main/java/net/phbwt/paperwork/ui/about/AboutVM.kt: -------------------------------------------------------------------------------- 1 | package net.phbwt.paperwork.ui.about 2 | 3 | import android.app.Application 4 | import androidx.lifecycle.AndroidViewModel 5 | import androidx.lifecycle.SavedStateHandle 6 | import dagger.hilt.android.lifecycle.HiltViewModel 7 | import net.phbwt.paperwork.data.Repository 8 | import javax.inject.Inject 9 | 10 | 11 | @HiltViewModel 12 | class AboutVM @Inject constructor( 13 | application: Application, 14 | private val savedStateHandle: SavedStateHandle, 15 | private val repo: Repository, 16 | ) : AndroidViewModel(application) { 17 | 18 | fun getDbVersion() = repo.db.openHelper.readableDatabase.version 19 | } 20 | 21 | 22 | -------------------------------------------------------------------------------- /app/src/main/java/net/phbwt/paperwork/ui/downloadlist/DownloadListVM.kt: -------------------------------------------------------------------------------- 1 | package net.phbwt.paperwork.ui.downloadlist 2 | 3 | import android.app.Application 4 | import androidx.lifecycle.AndroidViewModel 5 | import androidx.lifecycle.SavedStateHandle 6 | import androidx.lifecycle.viewModelScope 7 | import dagger.hilt.android.lifecycle.HiltViewModel 8 | import kotlinx.coroutines.Dispatchers 9 | import kotlinx.coroutines.flow.combine 10 | import kotlinx.coroutines.withContext 11 | import net.phbwt.paperwork.data.Repository 12 | import net.phbwt.paperwork.data.background.DownloadWorker 13 | import net.phbwt.paperwork.data.dao.DownloadStats 14 | import net.phbwt.paperwork.data.entity.db.DocumentFull 15 | import net.phbwt.paperwork.data.entity.db.Part 16 | import net.phbwt.paperwork.data.settings.Settings 17 | import net.phbwt.paperwork.helper.latestRelease 18 | import net.phbwt.paperwork.helper.toComposeImmutable 19 | import net.phbwt.paperwork.ui.destinations.DownloadListScreenDestination 20 | import java.io.File 21 | import javax.inject.Inject 22 | 23 | @HiltViewModel 24 | class DownloadListVM @Inject constructor( 25 | application: Application, 26 | private val savedStateHandle: SavedStateHandle, 27 | private val settings: Settings, 28 | private val repo: Repository, 29 | ) : AndroidViewModel(application) { 30 | 31 | val navArgs = DownloadListScreenDestination.argsFrom(savedStateHandle) 32 | 33 | private val enterAnimDone = savedStateHandle.getStateFlow(ENTER_ANIM_DONE, false) 34 | 35 | val screenData = combine( 36 | repo.db.docDao().withDownloads(), 37 | repo.db.downloadDao().stats(), 38 | enterAnimDone, 39 | ) { d, s, e -> DownloadListData(d.toComposeImmutable(), s, e) } 40 | .latestRelease(viewModelScope, DownloadListData()) 41 | 42 | suspend fun restart(part: Part) { 43 | repo.db.downloadDao().restartPart(part.partId) 44 | DownloadWorker.enqueueLoad(getApplication()) 45 | } 46 | 47 | suspend fun clear(doc: DocumentFull) = withContext(Dispatchers.IO) { 48 | repo.db.downloadDao().setDocumentCleared(doc.document.documentId) 49 | File(settings.localPartsDir, doc.docPath).deleteRecursively() 50 | } 51 | 52 | fun setEnterFlashDone() { 53 | savedStateHandle[ENTER_ANIM_DONE] = true 54 | } 55 | } 56 | 57 | private const val ENTER_ANIM_DONE = "enter_anim_done" 58 | 59 | data class DownloadListData( 60 | val downloads: List = listOf(), 61 | val stats: DownloadStats? = null, 62 | val enterFlashDone: Boolean = false, 63 | ) 64 | -------------------------------------------------------------------------------- /app/src/main/java/net/phbwt/paperwork/ui/main/Dest.kt: -------------------------------------------------------------------------------- 1 | package net.phbwt.paperwork.ui.main 2 | 3 | import androidx.annotation.StringRes 4 | import androidx.compose.material.icons.Icons 5 | import androidx.compose.material.icons.automirrored.filled.FormatListBulleted 6 | import androidx.compose.material.icons.filled.Download 7 | import androidx.compose.material.icons.filled.Image 8 | import androidx.compose.material.icons.filled.Info 9 | import androidx.compose.material.icons.filled.Settings 10 | import androidx.compose.ui.graphics.vector.ImageVector 11 | import com.ramcosta.composedestinations.spec.Direction 12 | import net.phbwt.paperwork.R 13 | import net.phbwt.paperwork.ui.destinations.AboutScreenDestination 14 | import net.phbwt.paperwork.ui.destinations.Destination 15 | import net.phbwt.paperwork.ui.destinations.DocListScreenDestination 16 | import net.phbwt.paperwork.ui.destinations.DownloadListScreenDestination 17 | import net.phbwt.paperwork.ui.destinations.PageListScreenDestination 18 | import net.phbwt.paperwork.ui.destinations.SettingsCheckScreenDestination 19 | import net.phbwt.paperwork.ui.destinations.SettingsScreenDestination 20 | 21 | 22 | sealed class Dest( 23 | val destination: Destination, 24 | val topDirection: Direction, 25 | val icon: ImageVector, 26 | @StringRes val labelRes: Int, 27 | // Used to know which transition to use 28 | val transitionPosition: String, 29 | ) { 30 | 31 | data object DocList : Dest( 32 | DocListScreenDestination, 33 | DocListScreenDestination, 34 | icon = Icons.AutoMirrored.Filled.FormatListBulleted, 35 | labelRes = R.string.screen_docList, 36 | transitionPosition = "a", 37 | ) 38 | 39 | data object DownloadList : Dest( 40 | DownloadListScreenDestination, 41 | DownloadListScreenDestination(), 42 | icon = Icons.Filled.Download, 43 | labelRes = R.string.screen_downloadList, 44 | transitionPosition = "aa", 45 | ) 46 | 47 | data object Settings : Dest( 48 | SettingsScreenDestination, 49 | SettingsScreenDestination, 50 | icon = Icons.Filled.Settings, 51 | labelRes = R.string.screen_settings, 52 | transitionPosition = "b", 53 | ) 54 | 55 | data object SettingsCheck : Dest( 56 | SettingsCheckScreenDestination, 57 | SettingsCheckScreenDestination, 58 | icon = Icons.Filled.Settings, 59 | labelRes = R.string.screen_settingsCheck, 60 | transitionPosition = "ba", 61 | ) 62 | 63 | data object PageList : Dest( 64 | PageListScreenDestination, 65 | PageListScreenDestination(0), 66 | icon = Icons.Filled.Image, 67 | labelRes = R.string.screen_pageList, 68 | transitionPosition = "aaa", 69 | ) 70 | 71 | data object About : Dest( 72 | AboutScreenDestination, 73 | AboutScreenDestination, 74 | icon = Icons.Filled.Info, 75 | labelRes = R.string.screen_about, 76 | transitionPosition = "c", 77 | ) 78 | 79 | companion object { 80 | fun Destination.asDest() = ALL_DESTS.first { 81 | it.destination == this 82 | } 83 | } 84 | } 85 | 86 | val TLDS = listOf(Dest.DocList, Dest.DownloadList, Dest.Settings, Dest.About) 87 | val TEST_TLDS = listOf(Dest.DocList, Dest.DownloadList) 88 | val ALL_DESTS = listOf(Dest.DocList, Dest.PageList, Dest.DownloadList, Dest.Settings, Dest.SettingsCheck, Dest.About) 89 | 90 | 91 | -------------------------------------------------------------------------------- /app/src/main/java/net/phbwt/paperwork/ui/main/MainVM.kt: -------------------------------------------------------------------------------- 1 | package net.phbwt.paperwork.ui.main 2 | 3 | import android.app.Application 4 | import android.content.Intent 5 | import android.content.pm.PackageManager 6 | import androidx.lifecycle.AndroidViewModel 7 | import androidx.lifecycle.SavedStateHandle 8 | import androidx.lifecycle.viewModelScope 9 | import dagger.hilt.android.lifecycle.HiltViewModel 10 | import kotlinx.coroutines.delay 11 | import kotlinx.coroutines.flow.map 12 | import net.phbwt.paperwork.data.Repository 13 | import net.phbwt.paperwork.data.background.DownloadWorker 14 | import net.phbwt.paperwork.data.settings.Settings 15 | import net.phbwt.paperwork.helper.latestRelease 16 | import javax.inject.Inject 17 | 18 | 19 | @HiltViewModel 20 | class MainVM @Inject constructor( 21 | application: Application, 22 | private val savedStateHandle: SavedStateHandle, 23 | private val repo: Repository, 24 | private val settings: Settings, 25 | ) : AndroidViewModel(application) { 26 | 27 | val dbUpdates = repo.dbUpdateStatus 28 | 29 | val isConfigured = settings.baseUrlStr 30 | .map { it.isNotBlank() } 31 | .latestRelease(viewModelScope, true) 32 | 33 | fun clearDbUpdate() = repo.dbUpdateAcknowledged() 34 | 35 | suspend fun setDemoServer() { 36 | settings.updateBaseUrl("https://bwtdev.eu/OpenPaperViewDemo") 37 | settings.updateAutoDownloadLabels("label 2, some_other_label") 38 | // just in case of race condition 39 | delay(500) 40 | DownloadWorker.enqueueLoad(getApplication()) 41 | } 42 | 43 | // https://stackoverflow.com/questions/6609414/how-do-i-programmatically-restart-an-android-app 44 | fun restartApplication() { 45 | val context = getApplication() 46 | val packageManager: PackageManager = context.packageManager 47 | val componentName = packageManager.getLaunchIntentForPackage(context.packageName)!!.component 48 | val restartIntent = Intent.makeRestartActivityTask(componentName) 49 | context.startActivity(restartIntent) 50 | Runtime.getRuntime().exit(0) 51 | } 52 | 53 | // so that we can make the Play Store's Pre-launch report a bit more useful 54 | fun isRunningInTestLab() = android.provider.Settings.System.getString( 55 | getApplication().contentResolver, 56 | "firebase.test.lab", 57 | ).toBoolean() 58 | } 59 | -------------------------------------------------------------------------------- /app/src/main/java/net/phbwt/paperwork/ui/pagelist/PageListContentImages.kt: -------------------------------------------------------------------------------- 1 | @file:OptIn(ExperimentalFoundationApi::class) 2 | 3 | package net.phbwt.paperwork.ui.pagelist 4 | 5 | import androidx.compose.foundation.ExperimentalFoundationApi 6 | import androidx.compose.foundation.gestures.detectTapGestures 7 | import androidx.compose.foundation.gestures.rememberTransformableState 8 | import androidx.compose.foundation.layout.Box 9 | import androidx.compose.foundation.layout.Column 10 | import androidx.compose.foundation.layout.fillMaxSize 11 | import androidx.compose.foundation.layout.navigationBarsPadding 12 | import androidx.compose.foundation.layout.padding 13 | import androidx.compose.foundation.pager.HorizontalPager 14 | import androidx.compose.foundation.pager.rememberPagerState 15 | import androidx.compose.material3.ScrollableTabRow 16 | import androidx.compose.material3.Tab 17 | import androidx.compose.material3.Text 18 | import androidx.compose.runtime.Composable 19 | import androidx.compose.runtime.getValue 20 | import androidx.compose.runtime.mutableFloatStateOf 21 | import androidx.compose.runtime.mutableStateOf 22 | import androidx.compose.runtime.remember 23 | import androidx.compose.runtime.rememberCoroutineScope 24 | import androidx.compose.runtime.setValue 25 | import androidx.compose.ui.Modifier 26 | import androidx.compose.ui.draw.clip 27 | import androidx.compose.ui.geometry.Offset 28 | import androidx.compose.ui.graphics.RectangleShape 29 | import androidx.compose.ui.graphics.TransformOrigin 30 | import androidx.compose.ui.graphics.graphicsLayer 31 | import androidx.compose.ui.input.pointer.pointerInput 32 | import androidx.compose.ui.layout.ContentScale 33 | import androidx.compose.ui.platform.LocalContext 34 | import androidx.compose.ui.res.painterResource 35 | import androidx.compose.ui.res.stringResource 36 | import androidx.compose.ui.unit.dp 37 | import coil3.compose.AsyncImage 38 | import coil3.request.ImageRequest 39 | import kotlinx.coroutines.launch 40 | import net.phbwt.paperwork.R 41 | import net.phbwt.paperwork.data.entity.db.DocumentFull 42 | import net.phbwt.paperwork.helper.animateBy 43 | import net.phbwt.paperwork.helper.appDetectTransformGestures 44 | 45 | 46 | @Composable 47 | fun PageListContentImages( 48 | document: DocumentFull?, 49 | selectedIndex: Int, 50 | ) { 51 | if (document != null && document.parts.isNotEmpty()) { 52 | 53 | val pages = document.parts 54 | 55 | val pagerState = rememberPagerState(initialPage = selectedIndex) { pages.size } 56 | 57 | val scope = rememberCoroutineScope() 58 | 59 | Column( 60 | modifier = Modifier 61 | .fillMaxSize() 62 | ) { 63 | if (pages.size > 1) { 64 | ScrollableTabRow( 65 | // Our selected tab is our current page 66 | selectedTabIndex = pagerState.currentPage, 67 | ) { 68 | pages.forEachIndexed { index, _ -> 69 | Tab( 70 | modifier = Modifier.padding(horizontal = 16.dp), 71 | text = { Text(stringResource(R.string.pages_page_1, index + 1)) }, 72 | selected = pagerState.currentPage == index, 73 | onClick = { scope.launch { pagerState.scrollToPage(index) } }, 74 | ) 75 | } 76 | } 77 | } 78 | 79 | var scale by remember { mutableFloatStateOf(1f) } 80 | var offset by remember { mutableStateOf(Offset.Zero) } 81 | 82 | val transformableState = rememberTransformableState { zoomChange, offsetChange, _ -> 83 | scale *= zoomChange 84 | offset += offsetChange 85 | } 86 | 87 | HorizontalPager( 88 | state = pagerState, 89 | modifier = Modifier.fillMaxSize(), 90 | // .disabledHorizontalPointerInputScroll() 91 | key = { pages[it].partId }, 92 | // contentPadding = PaddingValues(horizontal = 25.dp), 93 | ) { index -> 94 | 95 | // another box : clipped zone includes navigation bar padding 96 | Box( 97 | modifier = Modifier 98 | .fillMaxSize() 99 | .clip(RectangleShape), 100 | ) { 101 | // image wrapped in a box : gesture not impacted by scale (especially touchSlop) 102 | Box( 103 | modifier = Modifier 104 | .matchParentSize() 105 | // edge2edge : bottom 106 | .navigationBarsPadding() 107 | .pointerInput(Unit) { 108 | detectTapGestures( 109 | onDoubleTap = { tapOffset -> 110 | if (scale > 1.00001f) { 111 | scope.launch { 112 | transformableState.animateBy(1 / scale, -offset) 113 | } 114 | } else { 115 | val zoomBy = 3f 116 | val o = tapOffset - ((tapOffset - offset) * zoomBy) 117 | scope.launch { 118 | transformableState.animateBy(zoomBy, o) 119 | } 120 | } 121 | } 122 | ) 123 | } 124 | .pointerInput(Unit) { 125 | appDetectTransformGestures(true) { centroid, pan, baseZoom, _ -> 126 | val newScale = minOf(99f, maxOf(1f, scale * baseZoom)) 127 | val zoom = newScale / scale 128 | scale = newScale 129 | 130 | val o = centroid - ((centroid - offset) * zoom) 131 | 132 | val mx = minOf(0f, maxOf(size.width * (1 - scale), pan.x + o.x)) 133 | val my = minOf(0f, maxOf(size.height * (1 - scale), pan.y + o.y)) 134 | 135 | offset = Offset(mx, my) 136 | // FIXME gesture end rebound 137 | scale > 1.05f 138 | } 139 | }, 140 | ) { 141 | val dataAndCacheKey = document.partPath(index) 142 | AsyncImage( 143 | model = ImageRequest.Builder(LocalContext.current) 144 | .data(dataAndCacheKey) 145 | .diskCacheKey(dataAndCacheKey) 146 | .build(), 147 | contentDescription = null, 148 | modifier = Modifier 149 | .fillMaxSize() 150 | .graphicsLayer { 151 | scaleX = scale 152 | scaleY = scale 153 | translationX = offset.x 154 | translationY = offset.y 155 | transformOrigin = TransformOrigin(0f, 0f) 156 | }, 157 | contentScale = ContentScale.Fit, 158 | placeholder = painterResource(R.drawable.ic_cloud_queue_24), 159 | error = painterResource(R.drawable.ic_error_outline_24), 160 | ) 161 | } 162 | } 163 | } 164 | } 165 | } 166 | } 167 | 168 | private const val TAG = "PageListContentImages" -------------------------------------------------------------------------------- /app/src/main/java/net/phbwt/paperwork/ui/pagelist/PageListContentPdf.kt: -------------------------------------------------------------------------------- 1 | @file:OptIn(ExperimentalFoundationApi::class) 2 | 3 | package net.phbwt.paperwork.ui.pagelist 4 | 5 | import android.util.Log 6 | import androidx.compose.foundation.ExperimentalFoundationApi 7 | import androidx.compose.foundation.gestures.detectTapGestures 8 | import androidx.compose.foundation.gestures.rememberTransformableState 9 | import androidx.compose.foundation.layout.Box 10 | import androidx.compose.foundation.layout.BoxWithConstraints 11 | import androidx.compose.foundation.layout.Column 12 | import androidx.compose.foundation.layout.fillMaxSize 13 | import androidx.compose.foundation.layout.navigationBarsPadding 14 | import androidx.compose.foundation.layout.padding 15 | import androidx.compose.foundation.pager.HorizontalPager 16 | import androidx.compose.foundation.pager.rememberPagerState 17 | import androidx.compose.material.icons.Icons 18 | import androidx.compose.material.icons.outlined.Timer 19 | import androidx.compose.material3.Icon 20 | import androidx.compose.material3.MaterialTheme 21 | import androidx.compose.material3.ScrollableTabRow 22 | import androidx.compose.material3.Tab 23 | import androidx.compose.material3.Text 24 | import androidx.compose.runtime.Composable 25 | import androidx.compose.runtime.LaunchedEffect 26 | import androidx.compose.runtime.getValue 27 | import androidx.compose.runtime.mutableFloatStateOf 28 | import androidx.compose.runtime.mutableStateOf 29 | import androidx.compose.runtime.remember 30 | import androidx.compose.runtime.rememberCoroutineScope 31 | import androidx.compose.runtime.setValue 32 | import androidx.compose.ui.Alignment 33 | import androidx.compose.ui.Modifier 34 | import androidx.compose.ui.draw.clip 35 | import androidx.compose.ui.geometry.Offset 36 | import androidx.compose.ui.graphics.RectangleShape 37 | import androidx.compose.ui.graphics.TransformOrigin 38 | import androidx.compose.ui.graphics.graphicsLayer 39 | import androidx.compose.ui.input.pointer.pointerInput 40 | import androidx.compose.ui.layout.ContentScale 41 | import androidx.compose.ui.platform.LocalContext 42 | import androidx.compose.ui.platform.LocalDensity 43 | import androidx.compose.ui.res.painterResource 44 | import androidx.compose.ui.res.stringResource 45 | import androidx.compose.ui.unit.dp 46 | import coil3.compose.AsyncImage 47 | import coil3.imageLoader 48 | import coil3.memory.MemoryCache 49 | import coil3.request.ImageRequest 50 | import coil3.toBitmap 51 | import kotlinx.coroutines.launch 52 | import net.phbwt.paperwork.R 53 | import net.phbwt.paperwork.helper.animateBy 54 | import net.phbwt.paperwork.helper.appDetectTransformGestures 55 | import java.io.File 56 | 57 | 58 | @Composable 59 | fun PageListContentPdf( 60 | pdfFile: File, 61 | ) { 62 | val renderer = remember(Unit) { PdfRendererWrapper() } 63 | 64 | LaunchedEffect(pdfFile) { 65 | renderer.open(pdfFile) 66 | } 67 | 68 | val pageCount = renderer.state?.pageCount ?: 0 69 | val pagerState = rememberPagerState(initialPage = 0) { pageCount } 70 | val scope = rememberCoroutineScope() 71 | 72 | Column( 73 | modifier = Modifier 74 | .fillMaxSize() 75 | ) { 76 | 77 | if (pageCount > 1) { 78 | ScrollableTabRow( 79 | // Our selected tab is our current page 80 | selectedTabIndex = pagerState.currentPage, 81 | ) { 82 | for (index in 0 until pageCount) { 83 | Tab( 84 | modifier = Modifier.padding(horizontal = 16.dp), 85 | text = { Text(stringResource(R.string.pages_page_1, index + 1)) }, 86 | selected = pagerState.currentPage == index, 87 | onClick = { scope.launch { pagerState.scrollToPage(index) } }, 88 | ) 89 | } 90 | } 91 | } 92 | 93 | val imageCache = LocalContext.current.imageLoader.memoryCache 94 | ?: throw IllegalStateException("Coil image loader should have a cache") 95 | 96 | BoxWithConstraints { 97 | val width = with(LocalDensity.current) { maxWidth.toPx() }.toInt() 98 | val height = with(LocalDensity.current) { maxHeight.toPx() }.toInt() 99 | 100 | var scale by remember { mutableFloatStateOf(1f) } 101 | var offset by remember { mutableStateOf(Offset.Zero) } 102 | 103 | val transformableState = rememberTransformableState { zoomChange, offsetChange, _ -> 104 | scale *= zoomChange 105 | offset += offsetChange 106 | } 107 | 108 | HorizontalPager( 109 | state = pagerState, 110 | modifier = Modifier.fillMaxSize(), 111 | key = { it }, 112 | ) { index -> 113 | 114 | // another box : clipped zone includes navigation bar padding 115 | Box( 116 | modifier = Modifier 117 | .fillMaxSize() 118 | .clip(RectangleShape), 119 | ) { 120 | // image wrapped in a box : gesture not impacted by scale (especially touchSlop) 121 | Box( 122 | modifier = Modifier 123 | .matchParentSize() 124 | // edge2edge : bottom 125 | .navigationBarsPadding() 126 | .pointerInput(Unit) { 127 | detectTapGestures( 128 | onDoubleTap = { tapOffset -> 129 | if (scale > 1.00001f) { 130 | scope.launch { 131 | transformableState.animateBy(1 / scale, -offset) 132 | } 133 | } else { 134 | val zoomBy = 3f 135 | val o = tapOffset - ((tapOffset - offset) * zoomBy) 136 | scope.launch { 137 | transformableState.animateBy(zoomBy, o) 138 | } 139 | } 140 | } 141 | ) 142 | } 143 | .pointerInput(Unit) { 144 | appDetectTransformGestures(true) { centroid, pan, baseZoom, _ -> 145 | val newScale = minOf(99f, maxOf(1f, scale * baseZoom)) 146 | val zoomBy = newScale / scale 147 | scale = newScale 148 | 149 | val o = centroid - ((centroid - offset) * zoomBy) 150 | 151 | val mx = minOf(0f, maxOf(size.width * (1 - scale), pan.x + o.x)) 152 | val my = minOf(0f, maxOf(size.height * (1 - scale), pan.y + o.y)) 153 | 154 | offset = Offset(mx, my) 155 | // FIXME gesture end rebound 156 | scale > 1.05f 157 | } 158 | }, 159 | ) { 160 | val cacheKey = MemoryCache.Key("$pdfFile-$index}") 161 | var bitmap by remember { mutableStateOf(imageCache[cacheKey]?.image?.toBitmap()) } 162 | if (bitmap == null) { 163 | LaunchedEffect(pdfFile, index) { 164 | Log.w(TAG, "> $index") 165 | bitmap = renderer.renderPage( 166 | index, 167 | width, height, 168 | null, 169 | null, 170 | imageCache, 171 | cacheKey, 172 | ) 173 | Log.w(TAG, "< $index") 174 | } 175 | Icon( 176 | Icons.Outlined.Timer, 177 | null, 178 | modifier = Modifier 179 | .align(Alignment.Center) 180 | .fillMaxSize(.3f), 181 | tint = MaterialTheme.colorScheme.outline, 182 | ) 183 | } else { 184 | AsyncImage( 185 | model = ImageRequest.Builder(LocalContext.current) 186 | // .size(width, height) 187 | .memoryCacheKey(cacheKey) 188 | .data(bitmap) 189 | .build(), 190 | contentDescription = "Page ${index + 1} of ${pageCount}", 191 | modifier = Modifier 192 | .fillMaxSize() 193 | .graphicsLayer { 194 | scaleX = scale 195 | scaleY = scale 196 | translationX = offset.x 197 | translationY = offset.y 198 | transformOrigin = TransformOrigin(0f, 0f) 199 | }, 200 | contentScale = ContentScale.Fit, 201 | error = painterResource(R.drawable.ic_error_outline_24), 202 | ) 203 | } 204 | } 205 | } 206 | } 207 | } 208 | } 209 | } 210 | 211 | private const val TAG = "PageListContentPdf" 212 | 213 | 214 | -------------------------------------------------------------------------------- /app/src/main/java/net/phbwt/paperwork/ui/pagelist/PageListScreen.kt: -------------------------------------------------------------------------------- 1 | package net.phbwt.paperwork.ui.pagelist 2 | 3 | import androidx.compose.foundation.layout.Box 4 | import androidx.compose.foundation.layout.fillMaxSize 5 | import androidx.compose.material3.SnackbarHostState 6 | import androidx.compose.runtime.Composable 7 | import androidx.compose.runtime.getValue 8 | import androidx.compose.runtime.mutableIntStateOf 9 | import androidx.compose.runtime.remember 10 | import androidx.compose.ui.Modifier 11 | import androidx.hilt.navigation.compose.hiltViewModel 12 | import androidx.lifecycle.compose.collectAsStateWithLifecycle 13 | import com.ramcosta.composedestinations.annotation.Destination 14 | import net.phbwt.paperwork.ui.main.AppTransitions 15 | import net.phbwt.paperwork.ui.main.EmptyScaffold 16 | 17 | data class PageListScreenArgs( 18 | val documentId: Int, 19 | ) 20 | 21 | @Destination( 22 | navArgsDelegate = PageListScreenArgs::class, 23 | style = AppTransitions::class, 24 | ) 25 | @Composable 26 | fun PageListScreen( 27 | snackbarHostState: SnackbarHostState, 28 | onNavigationIcon: (Boolean) -> Unit, 29 | vm: PageListVM = hiltViewModel(), 30 | ) { 31 | val document by vm.document.collectAsStateWithLifecycle() 32 | 33 | val selectedIndex by remember { mutableIntStateOf(0) } 34 | 35 | val pdfFile = vm.getPdfLocalPath(document) 36 | 37 | // Scaffold is only needed for the snackbar 38 | EmptyScaffold( 39 | snackbarHostState, 40 | ) { innerPadding -> 41 | 42 | // ensure fillMaxSize, even when the content is not ready 43 | // to avoid spurious automatic scaleIn 44 | // cf https://issuetracker.google.com/issues/295536728 45 | Box( 46 | modifier = Modifier 47 | .fillMaxSize() 48 | ) { 49 | when { 50 | document == null -> { 51 | // nothing 52 | } 53 | 54 | document!!.isImagesDoc -> { 55 | PageListContentImages(document, selectedIndex) 56 | } 57 | 58 | pdfFile != null && pdfFile.exists() -> { 59 | PageListContentPdf(pdfFile) 60 | } 61 | 62 | else -> { 63 | throw IllegalStateException("Should not happen : exists : ${pdfFile?.exists()} '${document}'") 64 | } 65 | } 66 | } 67 | } 68 | } 69 | 70 | private const val TAG = "PageListScreen" -------------------------------------------------------------------------------- /app/src/main/java/net/phbwt/paperwork/ui/pagelist/PageListVM.kt: -------------------------------------------------------------------------------- 1 | @file:OptIn(ExperimentalCoroutinesApi::class) 2 | 3 | package net.phbwt.paperwork.ui.pagelist 4 | 5 | import android.app.Application 6 | import androidx.lifecycle.AndroidViewModel 7 | import androidx.lifecycle.SavedStateHandle 8 | import androidx.lifecycle.viewModelScope 9 | import dagger.hilt.android.lifecycle.HiltViewModel 10 | import kotlinx.coroutines.ExperimentalCoroutinesApi 11 | import net.phbwt.paperwork.data.Repository 12 | import net.phbwt.paperwork.data.entity.db.DocumentFull 13 | import net.phbwt.paperwork.data.settings.Settings 14 | import net.phbwt.paperwork.helper.latestRelease 15 | import net.phbwt.paperwork.ui.destinations.PageListScreenDestination 16 | import java.io.File 17 | import javax.inject.Inject 18 | 19 | 20 | @HiltViewModel 21 | class PageListVM @Inject constructor( 22 | application: Application, 23 | private val savedStateHandle: SavedStateHandle, 24 | private val repo: Repository, 25 | private val settings: Settings, 26 | ) : AndroidViewModel(application) { 27 | 28 | val navArgs = PageListScreenDestination.argsFrom(savedStateHandle) 29 | 30 | val document = repo.db.docDao() 31 | .loadDocument(navArgs.documentId) 32 | .latestRelease(viewModelScope, null) 33 | 34 | fun getPdfLocalPath(doc: DocumentFull?) = if (doc != null) File(settings.localPartsDir, doc.partPath(0)) else null 35 | 36 | } 37 | -------------------------------------------------------------------------------- /app/src/main/java/net/phbwt/paperwork/ui/pagelist/PdfRendererWrapper.kt: -------------------------------------------------------------------------------- 1 | package net.phbwt.paperwork.ui.pagelist 2 | 3 | import android.graphics.Bitmap 4 | import android.graphics.Matrix 5 | import android.graphics.Rect 6 | import android.graphics.pdf.PdfRenderer 7 | import android.os.ParcelFileDescriptor 8 | import android.util.Log 9 | import androidx.compose.runtime.* 10 | import androidx.compose.ui.graphics.Color 11 | import androidx.compose.ui.graphics.toArgb 12 | import coil3.asImage 13 | import coil3.memory.MemoryCache 14 | import kotlinx.coroutines.asCoroutineDispatcher 15 | import kotlinx.coroutines.withContext 16 | import java.io.File 17 | import java.util.concurrent.Executors 18 | 19 | @Stable 20 | class PdfRendererWrapper { 21 | 22 | // PdfRenderer are not threadsafe 23 | // A Mutex does not guaranty that the thread is always the same 24 | // FIXME make it global ? 25 | private val dispatcher = Executors.newSingleThreadExecutor().asCoroutineDispatcher() 26 | 27 | private var renderer: PdfRenderer? = null 28 | 29 | var state: State? by mutableStateOf(null) 30 | 31 | suspend fun open(newFile: File) = withContext(dispatcher) { 32 | Log.d(TAG, "Opening $newFile") 33 | if (newFile != state?.file) { 34 | Log.d(TAG, "Really opening $newFile") 35 | closeImp() 36 | renderer = PdfRenderer(ParcelFileDescriptor.open(newFile, ParcelFileDescriptor.MODE_READ_ONLY)) 37 | state = State(newFile, renderer!!.pageCount) 38 | } 39 | } 40 | 41 | suspend fun renderPage( 42 | pageIndex: Int, 43 | width: Int, 44 | height: Int, 45 | destClip: Rect?, 46 | transform: Matrix?, 47 | imageCache: MemoryCache, 48 | cacheKey: MemoryCache.Key, 49 | ): Bitmap = withContext(dispatcher) { 50 | Log.e(TAG, ">> $pageIndex") 51 | 52 | val r = renderer ?: throw IllegalStateException("Missing open() ?") 53 | 54 | val ts = System.currentTimeMillis() 55 | 56 | val bm: Bitmap 57 | 58 | r.openPage(pageIndex).use { page -> 59 | 60 | val screenRatio = height.toFloat() / width 61 | val pageRatio = page.height.toFloat() / page.width 62 | 63 | val bmWidth: Int 64 | val bmHeight: Int 65 | 66 | if (pageRatio > screenRatio) { 67 | bmWidth = (width / pageRatio * screenRatio).toInt() 68 | bmHeight = height 69 | } else { 70 | bmWidth = width 71 | bmHeight = (height * pageRatio / screenRatio).toInt() 72 | } 73 | 74 | bm = Bitmap.createBitmap(bmWidth, bmHeight, Bitmap.Config.ARGB_8888) 75 | bm.eraseColor(Color.White.toArgb()) 76 | 77 | page.render( 78 | bm, 79 | destClip, 80 | transform, 81 | PdfRenderer.Page.RENDER_MODE_FOR_DISPLAY, 82 | ) 83 | } 84 | 85 | // If the page was cancelled during the rendering, the bitmap will not be used 86 | // so we put it in cache ourself 87 | imageCache[cacheKey] = MemoryCache.Value(bm.asImage()) 88 | 89 | Log.e(TAG, "<< $pageIndex in ${System.currentTimeMillis() - ts} ms") 90 | bm 91 | } 92 | 93 | // TODO : explicitly release the renderer when we are done, instead of relying on finalize() 94 | // we want it to run when the scope is cancelled but also to be a suspend fun because we want to run it on the single thread dispatcher 95 | suspend fun close() = withContext(dispatcher) { 96 | closeImp() 97 | } 98 | 99 | private fun closeImp() { 100 | if (renderer != null) { 101 | Log.d(TAG, "Closing $state") 102 | renderer?.close() 103 | renderer = null 104 | } 105 | state = null 106 | } 107 | } 108 | 109 | @Immutable 110 | data class State(val file: File, val pageCount: Int) 111 | 112 | private const val TAG = "PdfRendererWrapper" -------------------------------------------------------------------------------- /app/src/main/java/net/phbwt/paperwork/ui/settings/SettingsVM.kt: -------------------------------------------------------------------------------- 1 | @file:OptIn(SavedStateHandleSaveableApi::class, ExperimentalCoroutinesApi::class) 2 | 3 | package net.phbwt.paperwork.ui.settings 4 | 5 | import android.app.Application 6 | import android.net.Uri 7 | import android.util.Log 8 | import androidx.compose.runtime.Immutable 9 | import androidx.compose.runtime.mutableStateOf 10 | import androidx.compose.ui.text.TextRange 11 | import androidx.compose.ui.text.input.TextFieldValue 12 | import androidx.lifecycle.AndroidViewModel 13 | import androidx.lifecycle.SavedStateHandle 14 | import androidx.lifecycle.viewModelScope 15 | import androidx.lifecycle.viewmodel.compose.SavedStateHandleSaveableApi 16 | import androidx.lifecycle.viewmodel.compose.saveable 17 | import dagger.hilt.android.lifecycle.HiltViewModel 18 | import kotlinx.coroutines.Dispatchers 19 | import kotlinx.coroutines.ExperimentalCoroutinesApi 20 | import kotlinx.coroutines.flow.Flow 21 | import kotlinx.coroutines.flow.combine 22 | import kotlinx.coroutines.flow.first 23 | import kotlinx.coroutines.flow.flatMapLatest 24 | import kotlinx.coroutines.flow.flowOf 25 | import kotlinx.coroutines.flow.map 26 | import kotlinx.coroutines.launch 27 | import kotlinx.coroutines.withContext 28 | import net.phbwt.paperwork.data.Repository 29 | import net.phbwt.paperwork.data.background.DownloadWorker 30 | import net.phbwt.paperwork.data.dao.AutoDownloadInfo 31 | import net.phbwt.paperwork.data.entity.db.LabelType 32 | import net.phbwt.paperwork.data.settings.MAX_VALUE_SIZE 33 | import net.phbwt.paperwork.data.settings.Settings 34 | import net.phbwt.paperwork.helper.desc 35 | import net.phbwt.paperwork.helper.firstThenDebounce 36 | import net.phbwt.paperwork.helper.latestRelease 37 | import net.phbwt.paperwork.helper.msg 38 | import okio.buffer 39 | import okio.source 40 | import javax.inject.Inject 41 | import kotlin.math.min 42 | 43 | @HiltViewModel 44 | class SettingsVM @Inject constructor( 45 | application: Application, 46 | private val savedStateHandle: SavedStateHandle, 47 | private val repo: Repository, 48 | private val settings: Settings, 49 | ) : AndroidViewModel(application) { 50 | 51 | //region editable fields hoisting 52 | 53 | var baseUrl by savedStateHandle.saveable { mutableStateOf("") } 54 | private set 55 | 56 | var autoDownloadLabels by savedStateHandle.saveable( 57 | stateSaver = TextFieldValue.Saver, 58 | ) { 59 | mutableStateOf(TextFieldValue()) 60 | } 61 | private set 62 | 63 | //endregion 64 | 65 | private val allLabels: Flow> = repo.db.labelDao().loadLabelTypes() 66 | 67 | private val labelInfo: Flow = settings.autoDownloadLabels 68 | .firstThenDebounce(500) 69 | .flatMapLatest { result -> 70 | if (result.isFailure) { 71 | // this should not happen 72 | Log.e(TAG, "Failed to get the label list setting ???") 73 | flowOf(LabelsInfo(listOf("*bug*", result.exceptionOrNull()?.msg() ?: ""))) 74 | } else { 75 | val labels = result.getOrThrow() 76 | repo.db.downloadDao().countAutoDownloads(labels) 77 | .map { info -> LabelsInfo(labels, info) } 78 | } 79 | } 80 | 81 | // Editable text fields states are hoisted directly : 82 | // base URL and auto download labels 83 | // here we only use the URL validation error and the downloadable count 84 | val data = combine( 85 | settings.baseUrl, 86 | allLabels, 87 | labelInfo, 88 | combine(settings.clientPemStr, settings.clientPem) { txt, certResult -> 89 | val certInfo = certResult.mapCatching { it?.certificate.toString() } 90 | SettingItem(txt, certInfo.getOrNull(), certInfo.exceptionOrNull()?.msg()) 91 | }, 92 | combine(settings.serverCaStr, settings.serverCa) { txt, certResult -> 93 | val certInfo = certResult.mapCatching { it?.toString() } 94 | SettingItem(txt, certInfo.getOrNull(), certInfo.exceptionOrNull()?.msg()) 95 | }, 96 | ) { url, all, info, client, server -> 97 | SettingsData( 98 | url.exceptionOrNull().msg(), 99 | all, 100 | info, 101 | client, 102 | server, 103 | ) 104 | }.latestRelease(viewModelScope, SettingsData()) 105 | 106 | init { 107 | viewModelScope.launch { 108 | baseUrl = settings.baseUrlStr.first() 109 | autoDownloadLabels = TextFieldValue(settings.autoDownloadLabelsStr.first()) 110 | } 111 | } 112 | 113 | fun updateBaseUrl(newVal: String) { 114 | baseUrl = newVal 115 | viewModelScope.launch { 116 | settings.updateBaseUrl(newVal) 117 | } 118 | } 119 | 120 | fun updateAutoDownloadLabels(newVal: TextFieldValue, wasCompleted: Boolean) { 121 | val newText = newVal.text.trimStart() 122 | 123 | autoDownloadLabels = if (!wasCompleted) { 124 | newVal 125 | } else { 126 | // after a completion, move the cursor to the end 127 | newVal.copy( 128 | text = newText, 129 | selection = TextRange(newText.length), 130 | ) 131 | } 132 | 133 | viewModelScope.launch { 134 | settings.updateAutoDownloadLabels(newText) 135 | } 136 | } 137 | 138 | suspend fun startAutoDownloads(info: LabelsInfo): Int { 139 | val count = repo.db.downloadDao().queueAutoDownloads(info.labels) 140 | Log.i(TAG, "Requested $count new downloads for ${info.autoDownloads} documents") 141 | DownloadWorker.enqueueLoad(getApplication()) 142 | return count 143 | } 144 | 145 | fun updateClientPem(newVal: String) = viewModelScope.launch { 146 | settings.updateClientPem(newVal) 147 | } 148 | 149 | fun updateClientPem(uri: Uri) = viewModelScope.launch { 150 | updateClientPem(loadFromUri(uri)) 151 | } 152 | 153 | fun updateServerCa(newVal: String) = viewModelScope.launch { 154 | settings.updateServerCa(newVal) 155 | } 156 | 157 | fun updateServerCa(uri: Uri) = viewModelScope.launch { 158 | updateServerCa(loadFromUri(uri)) 159 | } 160 | 161 | private suspend fun loadFromUri(newVal: Uri): String = withContext(Dispatchers.IO) { 162 | try { 163 | getApplication().contentResolver.openInputStream(newVal).use { 164 | // FIXME this seems quite complicated 165 | // There is probably a simpler way to read min(content_size, a_reasonable_value) 166 | 167 | val source = it?.source()?.buffer() ?: return@withContext "No content ???" 168 | 169 | val maxLen = MAX_VALUE_SIZE.toLong() 170 | 171 | source.request(maxLen + 1) 172 | 173 | val readLen = source.buffer.size 174 | 175 | if (readLen > maxLen) { 176 | "Error : too long (max size : $maxLen)" 177 | } else { 178 | source.readUtf8(min(readLen, maxLen)) 179 | } 180 | } 181 | } catch (ex: Exception) { 182 | Log.w(TAG, "Failed to read the client certificate and key", ex) 183 | "Error: ${ex.desc()}" 184 | } 185 | } 186 | 187 | companion object { 188 | private const val TAG = "SettingsVM" 189 | } 190 | } 191 | 192 | 193 | @Immutable 194 | data class SettingItem( 195 | val inputValue: String = "", 196 | val value: String? = null, 197 | val error: String? = null, 198 | ) 199 | 200 | @Immutable 201 | data class LabelsInfo( 202 | val labels: List = listOf(), 203 | val autoDownloads: AutoDownloadInfo = AutoDownloadInfo(0, 0), 204 | ) 205 | 206 | @Immutable 207 | data class SettingsData( 208 | val baseUrlError: String = "", 209 | val allLabels: List = listOf(), 210 | val labelsInfo: LabelsInfo = LabelsInfo(), 211 | val clientPem: SettingItem = SettingItem(), 212 | val serverCa: SettingItem = SettingItem(), 213 | ) 214 | -------------------------------------------------------------------------------- /app/src/main/java/net/phbwt/paperwork/ui/settingscheck/SettingsCheckScreen.kt: -------------------------------------------------------------------------------- 1 | @file:OptIn(ExperimentalFoundationApi::class) 2 | 3 | package net.phbwt.paperwork.ui.settingscheck 4 | 5 | import androidx.compose.animation.animateContentSize 6 | import androidx.compose.foundation.ExperimentalFoundationApi 7 | import androidx.compose.foundation.layout.Arrangement 8 | import androidx.compose.foundation.layout.Column 9 | import androidx.compose.foundation.layout.Row 10 | import androidx.compose.foundation.layout.Spacer 11 | import androidx.compose.foundation.layout.WindowInsets 12 | import androidx.compose.foundation.layout.fillMaxWidth 13 | import androidx.compose.foundation.layout.navigationBars 14 | import androidx.compose.foundation.layout.padding 15 | import androidx.compose.foundation.layout.windowInsetsBottomHeight 16 | import androidx.compose.foundation.lazy.LazyColumn 17 | import androidx.compose.material.icons.Icons 18 | import androidx.compose.material.icons.outlined.Check 19 | import androidx.compose.material.icons.outlined.Error 20 | import androidx.compose.material.icons.outlined.Warning 21 | import androidx.compose.material3.Button 22 | import androidx.compose.material3.Icon 23 | import androidx.compose.material3.LocalContentColor 24 | import androidx.compose.material3.MaterialTheme 25 | import androidx.compose.material3.SnackbarHostState 26 | import androidx.compose.material3.Text 27 | import androidx.compose.runtime.Composable 28 | import androidx.compose.runtime.LaunchedEffect 29 | import androidx.compose.runtime.getValue 30 | import androidx.compose.runtime.remember 31 | import androidx.compose.runtime.rememberCoroutineScope 32 | import androidx.compose.ui.Alignment 33 | import androidx.compose.ui.ExperimentalComposeUiApi 34 | import androidx.compose.ui.Modifier 35 | import androidx.compose.ui.draw.alpha 36 | import androidx.compose.ui.platform.LocalContext 37 | import androidx.compose.ui.res.stringResource 38 | import androidx.compose.ui.tooling.preview.Preview 39 | import androidx.compose.ui.unit.dp 40 | import androidx.hilt.navigation.compose.hiltViewModel 41 | import androidx.lifecycle.compose.collectAsStateWithLifecycle 42 | import com.ramcosta.composedestinations.annotation.Destination 43 | import kotlinx.collections.immutable.persistentListOf 44 | import kotlinx.coroutines.launch 45 | import net.phbwt.paperwork.R 46 | import net.phbwt.paperwork.ui.main.AppTransitions 47 | import net.phbwt.paperwork.ui.main.Dest 48 | import net.phbwt.paperwork.ui.main.WrappedScaffold 49 | import net.phbwt.paperwork.ui.theme.AppTheme 50 | 51 | @Destination(style = AppTransitions::class) 52 | @Composable 53 | fun SettingsCheckScreen( 54 | snackbarHostState: SnackbarHostState, 55 | onNavigationIcon: (Boolean) -> Unit, 56 | vm: SettingsCheckVM = hiltViewModel(), 57 | ) { 58 | val data by vm.data.collectAsStateWithLifecycle() 59 | 60 | LaunchedEffect(Unit) { 61 | vm.startChecks() 62 | } 63 | 64 | SettingsCheckContent( 65 | data, 66 | onStart = vm::startChecks, 67 | onStop = vm::stopChecks, 68 | onReset = vm::clearDataAndReloadDb, 69 | snackbarHostState, 70 | onNavigationIcon, 71 | ) 72 | } 73 | 74 | @Composable 75 | fun SettingsCheckContent( 76 | data: SettingsCheckState, 77 | onStart: () -> Unit = {}, 78 | onStop: () -> Unit = {}, 79 | onReset: suspend () -> Unit = {}, 80 | snackbarHostState: SnackbarHostState = remember { SnackbarHostState() }, 81 | onNavigationIcon: (Boolean) -> Unit = {}, 82 | ) = WrappedScaffold( 83 | snackbarHostState, 84 | onNavigationIcon, 85 | Dest.SettingsCheck.labelRes, 86 | topLevel = false, 87 | ) { modifier -> 88 | val scope = rememberCoroutineScope() 89 | 90 | Column( 91 | modifier = modifier 92 | .padding(8.dp), 93 | ) { 94 | 95 | val items = data.items 96 | LazyColumn( 97 | modifier = Modifier.weight(1f), 98 | ) { 99 | items(items.size, { idx -> idx }) { idx -> 100 | ItemRow( 101 | items[idx], 102 | modifier = Modifier 103 | .fillMaxWidth() 104 | .animateItem(), 105 | ) 106 | } 107 | } 108 | 109 | Row( 110 | modifier = Modifier 111 | .fillMaxWidth() 112 | .padding(horizontal = 8.dp), 113 | horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.End), 114 | ) { 115 | Button( 116 | onClick = { scope.launch { onReset() } }, 117 | enabled = !data.running && data.paramsOk, 118 | ) { 119 | Text(stringResource(R.string.settingsCheck_reset)) 120 | } 121 | Button( 122 | onClick = { if (data.running) onStop() else onStart() }, 123 | modifier = Modifier 124 | .animateContentSize(), 125 | ) { 126 | val resId = if (data.running) R.string.settingsCheck_stop else R.string.settingsCheck_restart 127 | Text(stringResource(resId)) 128 | } 129 | } 130 | 131 | // edge2edge : bottom 132 | Spacer(Modifier.windowInsetsBottomHeight(WindowInsets.navigationBars)) 133 | } 134 | } 135 | 136 | 137 | @Composable 138 | fun ItemRow( 139 | item: Check, 140 | modifier: Modifier = Modifier, 141 | ) { 142 | Row( 143 | modifier = modifier.padding(vertical = 8.dp), 144 | ) { 145 | Column( 146 | modifier = Modifier.weight(1f), 147 | ) { 148 | val context = LocalContext.current 149 | val alpha = if (item.level == Level.None) .6f else 1f 150 | Text( 151 | text = item.desc.format(context), 152 | modifier = Modifier.alpha(alpha), 153 | style = MaterialTheme.typography.bodyMedium, 154 | ) 155 | if (item.msg != null) { 156 | Text( 157 | text = item.msg.format(context), 158 | modifier = Modifier.alpha(alpha), 159 | style = MaterialTheme.typography.bodySmall, 160 | ) 161 | } 162 | } 163 | if (item.level != Level.None) { 164 | val color = if (item.level == Level.Error) MaterialTheme.colorScheme.error else LocalContentColor.current 165 | Icon( 166 | imageVector = when (item.level) { 167 | Level.OK -> Icons.Outlined.Check 168 | Level.Warn -> Icons.Outlined.Warning 169 | Level.Error -> Icons.Outlined.Error 170 | else -> Icons.Outlined.Error 171 | }, 172 | contentDescription = null, 173 | tint = color, 174 | ) 175 | } 176 | } 177 | } 178 | 179 | 180 | //region preview 181 | 182 | @ExperimentalComposeUiApi 183 | @Preview(showBackground = true) 184 | @Composable 185 | fun DefaultPreview() { 186 | AppTheme { 187 | SettingsCheckContent( 188 | data = SettingsCheckState( 189 | true, 190 | true, 191 | persistentListOf( 192 | Check(Msg(R.string.check_base_url), Level.OK, Msg(R.string.check_no_network_response_2, "aa", "bb")), 193 | Check(Msg(R.string.check_failure), Level.Error, Msg(R.string.check_no_network_response)), 194 | ) 195 | ) 196 | ) 197 | } 198 | } 199 | 200 | //endregion 201 | 202 | -------------------------------------------------------------------------------- /app/src/main/java/net/phbwt/paperwork/ui/theme/Type.kt: -------------------------------------------------------------------------------- 1 | package net.phbwt.paperwork.ui.theme 2 | 3 | import androidx.compose.material3.Typography 4 | import androidx.compose.ui.text.TextStyle 5 | import androidx.compose.ui.text.font.FontFamily 6 | import androidx.compose.ui.text.font.FontWeight 7 | import androidx.compose.ui.unit.sp 8 | 9 | val AppTypography = Typography() 10 | -------------------------------------------------------------------------------- /app/src/main/res/drawable-anydpi-v26/ic_launcher.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_baseline_download_24.xml: -------------------------------------------------------------------------------- 1 | 6 | 9 | 10 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_cloud_queue_24.xml: -------------------------------------------------------------------------------- 1 | 8 | 11 | 12 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_error_outline_24.xml: -------------------------------------------------------------------------------- 1 | 8 | 11 | 12 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_launcher.xml: -------------------------------------------------------------------------------- 1 | 7 | 12 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_launcher_background.xml: -------------------------------------------------------------------------------- 1 | 2 | 8 | 11 | 16 | 21 | 26 | 31 | 36 | 41 | 46 | 51 | 56 | 61 | 66 | 71 | 76 | 81 | 86 | 91 | 96 | 101 | 106 | 111 | 116 | 121 | 126 | 131 | 136 | 141 | 146 | 151 | 156 | 161 | 166 | 171 | 172 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_launcher_foreground.xml: -------------------------------------------------------------------------------- 1 | 8 | 13 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_launcher_monochrome.xml: -------------------------------------------------------------------------------- 1 | 8 | 13 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/splash_anim.xml: -------------------------------------------------------------------------------- 1 | 4 | 5 | 11 | 19 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 36 | 43 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 63 | 64 | 65 | 66 | 67 | -------------------------------------------------------------------------------- /app/src/main/res/values-fr/strings.xml: -------------------------------------------------------------------------------- 1 | 2 | OpenPaperView 3 | 4 | Recherche 5 | 6 | Tester la demo ? 7 | Le serveur n\'est pas défini. 8 | \nCette application nécessite un serveur HTTPS hébergeant les documents d\'OpenPaper.work 9 | \nVoulez vous utiliser un serveur de démo ? 10 | \n(Vous pourrez changer la configuration dans les paramètres) 11 | \nLe téléchargement de la BD peut prendre quelques secondes. 12 | OK, essayer la demo 13 | Non merci 14 | 15 | Téléchargement 16 | Téléchargement des documents 17 | En cours 18 | Téléchargement des documents en cours 19 | 20 | Une nouvelle BD est disponible 21 | Redémarrer l\'application 22 | Il y a un problème avec la mise à jour de la DB :\n%1$s 23 | 24 | Documents 25 | Paramètres 26 | Tester les paramètres 27 | Téléchargements 28 | Pages 29 | A propos 30 | 31 | Partages %1$s 32 | 33 | Les PDF doivent être téléchargés 34 | Télécharger 35 | Type de document inconnu 36 | 37 | Page %1$d 38 | 39 | Pas de valeur 40 | OK 41 | Erreur 42 | OK 43 | URL de base 44 | Une URL HTTPS (sans /papers/) 45 | Étiquettes téléchargées automatiquement ("*" pour toutes) 46 | Séparées par des , 47 | Documents : %1$d, à télécharger : %2$d 48 | 49 | Ajout de %d téléchargement 50 | Ajout de %d téléchargements 51 | Ajout de %d de téléchargements 52 | 53 | Certificat et clé privée du client 54 | Texte encodé en PEM contenant un certificat et une clé privée. 55 | \nOptionnel, mais fortement recommandé 56 | Certificat racine 57 | Texte encodé en PEM contenant un certificat. 58 | \nLe serveur doit fournir une chaîne de certificat remontant à ce certificat. 59 | \nOptionnel, sinon les certificats racines d\'Android seront utilisés. 60 | 61 | Test 62 | Démarrer 63 | 64 | Stop 65 | Réessayer 66 | Effacer les données 67 | 68 | Arrêté 69 | Vérification des paramètres 70 | Échec 71 | URL 72 | l\'URL n\'est pas HTTPS 73 | Certificat client 74 | Pas de certificat client 75 | Utilisation d\'un certificat client en HTTP 76 | Autorité racine du serveur 77 | Pas d\'autorité racine pour le serveur 78 | Utilisation d\'une autorité racine en HTTP 79 | Pas de réseau 80 | Téléchargement de la BD sans certificat 81 | Téléchargement de la BD 82 | Réponse vide 83 | Réponse venant du cache 84 | C\'est probablement un bug 85 | Non compressée 86 | Envisagez de configurer le serveur pour compresser le contenu \'%1$s\' 87 | Réponse compressée : %1$s (%2$s octets) 88 | Erreur HTTP 89 | Nouveau téléchargement de la BD 90 | Réponse absente 91 | BD reçue à nouveau 92 | Ce n\'est pas sécurisé 93 | Semble correct 94 | C\'est incohérent 95 | Les autorités de l\'OS seront utilisées 96 | Le serveur devrait refuser la requête 97 | Refusé, comme prévu 98 | L\'accès n\'a pas été refusé, le serveur n\'est PAS protégé correctement 99 | Erreur inattendue (ni 401, ni 403) 100 | Le serveur devrait repondre que ce n\'est pas nécessaire 101 | \'Non modifié\', comme prévu 102 | Le cache du serveur ne fonctionne pas correctement 103 | 104 | Réponse code %1$s 105 | Réponse code %1$s (au lieu de 401 ou 403) 106 | Réponse code %1$s vide ??? 107 | %1$s 108 | 109 | Lu %1$s (%2$s octets) 110 | Pas de réponse ?? %1$s, \'%2$s\' 111 | 112 | 113 | %d document 114 | %d documents 115 | %d de documents 116 | 117 | 118 | , %d fichier 119 | , %d fichiers 120 | %d de fichiers 121 | 122 | , %1$s 123 | 124 | -------------------------------------------------------------------------------- /app/src/main/res/values-night/colors.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | #ff000000 4 | 5 | #FF1A1C1E 6 | -------------------------------------------------------------------------------- /app/src/main/res/values/colors.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | #ffff0000 4 | #ffaaaaaa 5 | #ffffffff 6 | 7 | #FFFCFCFF 8 | -------------------------------------------------------------------------------- /app/src/main/res/values/strings.xml: -------------------------------------------------------------------------------- 1 | 2 | OpenPaperView 3 | 4 | Search 5 | 6 | Check the demo ? 7 | The base URL is not set. 8 | \nThis application is not usable without an HTTP server hosting OpenPaperwork\'s documents. 9 | \nWould you like to test it with a demo server ? 10 | (You can later configure a real one in the settings) 11 | \nPlease note that it may take a few seconds to download the DB 12 | OK, check the demo 13 | No 14 | 15 | Downloader 16 | Download the documents 17 | Downloading 18 | Downloading offline documents 19 | 20 | A new DB is available 21 | Restart application 22 | Something went wrong with a DB update :\n%1$s 23 | 24 | Documents 25 | Settings 26 | Check settings 27 | Downloads 28 | Pages 29 | About 30 | 31 | Send %1$s 32 | 33 | PDF must be downloaded first 34 | Download 35 | Unknown document type 36 | 37 | Page %1$d 38 | 39 | No value 40 | OK 41 | Error 42 | OK 43 | Base URL 44 | An HTTPS URL (without /papers/) 45 | Auto downloaded labels ("*" for all) 46 | Comma separated labels 47 | Matching documents : %1$d, to be downloaded : %2$d 48 | 49 | Added %d download 50 | Added %d downloads 51 | 52 | Client\'s certificate and private key 53 | An optional (but strongly recommended !) 54 | PEM encoded text containing one certificate and one private key. 55 | 56 | Trusted root CA 57 | An optional PEM encoded text containing one certificate. 58 | \nThe server must provide a chain of certificates whose root is this certificate. 59 | \nIf left empty, the systems CAs will be trusted. 60 | 61 | Test 62 | Start 63 | 64 | Stop 65 | Test again 66 | Clear Data 67 | 68 | Stopped 69 | Checking parameters 70 | Failure 71 | Base URL 72 | Base URL is not HTTPS 73 | Client certificate 74 | No client certificate 75 | Client certificate over HTTP 76 | Server root CA 77 | No Server root CA 78 | Server CA over HTTP 79 | No network connectivity 80 | Trying to download the DB without certificate 81 | Trying to download the DB 82 | Empty response 83 | Response came from cache 84 | This is probably a bug 85 | Response was not compressed 86 | Consider configuring the server to compress \'%1$s\' content 87 | Compressed response : %1$s (%2$s bytes) 88 | HTTP error 89 | Trying to download again the DB 90 | No network response 91 | Db received again 92 | This is not secure 93 | Looks good 94 | This is inconsistent 95 | The system\'s CA will be trusted 96 | The server should deny the request 97 | Denied, as expected 98 | Access not denied, the server is NOT properly protected 99 | Unexpected error (expected 401 or 403) 100 | The server should respond that it is not necessary 101 | \'Not modified\', as expected 102 | The cache control does not work properly 103 | 104 | Response code %1$s 105 | Response code %1$s (expected 401 ot 403) 106 | Response code %1$s without body ??? 107 | %1$s 108 | 109 | Read %1$s (%2$s bytes) 110 | No network response ?? %1$s, \'%2$s\' 111 | 112 | 113 | %d document 114 | %d documents 115 | 116 | 117 | , %d file 118 | , %d files 119 | 120 | , %1$s 121 | 122 | -------------------------------------------------------------------------------- /app/src/main/res/values/themes.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 7 | 8 | 14 | 15 | -------------------------------------------------------------------------------- /app/src/main/res/xml/file_provider_paths.xml: -------------------------------------------------------------------------------- 1 | 3 | 6 | 7 | -------------------------------------------------------------------------------- /app/src/main/res/xml/locales_config.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /app/src/main/res/xml/network_security_config.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /app/src/test/java/net/phbwt/paperwork/data/dao/DocumentQueryBuilderTest.kt: -------------------------------------------------------------------------------- 1 | package net.phbwt.paperwork.data.dao 2 | 3 | import org.junit.Assert.* 4 | 5 | 6 | import org.junit.Test 7 | 8 | class DocumentQueryBuilderTest { 9 | 10 | private fun getBuilder() = DocumentQueryBuilder() 11 | 12 | @Test 13 | fun prepareFtsQuery_empty() { 14 | val r = getBuilder().prepareFtsQuery("") 15 | assertEquals("", r) 16 | } 17 | 18 | @Test 19 | fun prepareFtsQuery_empty_with_quote_begin() { 20 | val r = getBuilder().prepareFtsQuery("\"") 21 | assertEquals("", r) 22 | } 23 | 24 | @Test 25 | fun prepareFtsQuery_empty_with_quote_begin_blank() { 26 | val r = getBuilder().prepareFtsQuery("\" ") 27 | assertEquals("", r) 28 | } 29 | 30 | @Test 31 | fun prepareFtsQuery_quoted_empty() { 32 | val r = getBuilder().prepareFtsQuery("\"\"") 33 | assertEquals("", r) 34 | } 35 | 36 | @Test 37 | fun prepareFtsQuery_quoted_blank() { 38 | val r = getBuilder().prepareFtsQuery("\" \"") 39 | assertEquals("", r) 40 | } 41 | 42 | @Test 43 | fun prepareFtsQuery_lot_of_blank() { 44 | val r = getBuilder().prepareFtsQuery(" \"\" \" \"\"\"\" \" \" ") 45 | assertEquals("", r) 46 | } 47 | 48 | @Test 49 | fun prepareFtsQuery_single_as_prefix() { 50 | // typing probably not finished, the last word is used as a prefix 51 | val r = getBuilder().prepareFtsQuery("sampl") 52 | assertEquals("\"sampl*\"", r) 53 | } 54 | 55 | @Test 56 | fun prepareFtsQuery_single_not_prefixed() { 57 | // ending with a space, so the last word is not implicitly considered as a prefix 58 | val r = getBuilder().prepareFtsQuery("sample ") 59 | assertEquals("\"sample\"", r) 60 | } 61 | 62 | @Test 63 | fun prepareFtsQuery_multiple_as_prefix() { 64 | // typing probably not finished, the last word is used as a prefix 65 | val r = getBuilder().prepareFtsQuery("some sampl") 66 | assertEquals("\"some\" \"sampl*\"", r) 67 | } 68 | 69 | @Test 70 | fun prepareFtsQuery_multiple_not_prefixed() { 71 | // ending with a space, so the last word is not implicitly considered as a prefix 72 | val r = getBuilder().prepareFtsQuery("some sample ") 73 | assertEquals("\"some\" \"sample\"", r) 74 | } 75 | 76 | @Test 77 | fun prepareFtsQuery_quoted_simple() { 78 | val r = getBuilder().prepareFtsQuery("\"some sample\"") 79 | assertEquals("\"some sample\"", r) 80 | } 81 | 82 | @Test 83 | fun prepareFtsQuery_quoted_trailing_return() { 84 | // fixed bug 85 | val r = getBuilder().prepareFtsQuery("\"sample\"\n") 86 | assertEquals("\"sample\"", r) 87 | } 88 | 89 | @Test 90 | fun prepareFtsQuery_quoted_containing_return() { 91 | // fixed bug 92 | val r = getBuilder().prepareFtsQuery("\"the\nsample\"\n") 93 | assertEquals("\"the sample\"", r) 94 | } 95 | 96 | @Test 97 | fun prepareFtsQuery_quoted_with_spacing() { 98 | val r = getBuilder().prepareFtsQuery(" \"some sample\" ") 99 | assertEquals("\"some sample\"", r) 100 | } 101 | 102 | @Test 103 | fun prepareFtsQuery_quoted_prefixed_star() { 104 | val r = getBuilder().prepareFtsQuery("\"some sample*\"") 105 | assertEquals("\"some sample*\"", r) 106 | } 107 | 108 | @Test 109 | fun prepareFtsQuery_quoted_prefixed_space() { 110 | val r = getBuilder().prepareFtsQuery("\"some sample \"") 111 | assertEquals("\"some sample \"", r) 112 | } 113 | @Test 114 | fun prepareFtsQuery_quoted_unfinished() { 115 | // quote not closed, last word mays be a prefix 116 | val r = getBuilder().prepareFtsQuery("\"some sampl") 117 | assertEquals("\"some sampl*\"", r) 118 | } 119 | 120 | @Test 121 | fun prepareFtsQuery_quoted_unfinished_without_prefix() { 122 | // quote not closed, last word is complete 123 | val r = getBuilder().prepareFtsQuery("\"some sample ") 124 | assertEquals("\"some sample \"", r) 125 | } 126 | 127 | @Test 128 | fun prepareFtsQuery_quoted_unfinished_already_prefix() { 129 | // quote not closed, last word is explicitly a prefix 130 | val r = getBuilder().prepareFtsQuery("\"some sampl*") 131 | assertEquals("\"some sampl*\"", r) 132 | } 133 | 134 | @Test 135 | fun prepareFtsQuery_2_complete() { 136 | val r = getBuilder().prepareFtsQuery("first val1 \"second val2\"") 137 | assertEquals("\"first\" \"val1\" \"second val2\"", r) 138 | } 139 | 140 | @Test 141 | fun prepareFtsQuery_2_no_space() { 142 | val r = getBuilder().prepareFtsQuery("first val1\"second val2\"") 143 | assertEquals("\"first\" \"val1\" \"second val2\"", r) 144 | } 145 | 146 | @Test 147 | fun prepareFtsQuery_2_quote_begin() { 148 | val r = getBuilder().prepareFtsQuery("first value \"second val") 149 | assertEquals("\"first\" \"value\" \"second val*\"", r) 150 | } 151 | 152 | @Test 153 | fun prepareFtsQuery_multiple_alterned() { 154 | val r = getBuilder().prepareFtsQuery("\"zeroth val0\" first val1 unquoted \"second val2 quoted\" third val3 \"forth val4\"") 155 | assertEquals("\"zeroth val0\" \"first\" \"val1\" \"unquoted\" \"second val2 quoted\" \"third\" \"val3\" \"forth val4\"", r) 156 | } 157 | 158 | @Test 159 | fun prepareFtsQuery_multiple_quoted_various_spacing() { 160 | // quoted parts separated by 0, 1 or more spaces 161 | val r = getBuilder().prepareFtsQuery("\"first val1\"\"second val2 quoted\" \"third val3\" \"forth val4\" ") 162 | assertEquals("\"first val1\" \"second val2 quoted\" \"third val3\" \"forth val4\"", r) 163 | } 164 | 165 | 166 | } -------------------------------------------------------------------------------- /build.gradle.kts: -------------------------------------------------------------------------------- 1 | 2 | plugins { 3 | alias(libs.plugins.androidApplication) apply false 4 | alias(libs.plugins.jetbrainsKotlinAndroid) apply false 5 | alias(libs.plugins.ksp) apply false 6 | alias(libs.plugins.hilt) apply false 7 | alias(libs.plugins.parcelize) apply false 8 | alias(libs.plugins.compose.compiler) apply false 9 | } 10 | 11 | 12 | -------------------------------------------------------------------------------- /gradle.properties: -------------------------------------------------------------------------------- 1 | # Project-wide Gradle settings. 2 | # IDE (e.g. Android Studio) users: 3 | # Gradle settings configured through the IDE *will override* 4 | # any settings specified in this file. 5 | # For more details on how to configure your build environment visit 6 | # http://www.gradle.org/docs/current/userguide/build_environment.html 7 | # Specifies the JVM arguments used for the daemon process. 8 | # The setting is particularly useful for tweaking memory settings. 9 | org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 10 | # When configured, Gradle will run in incubating parallel mode. 11 | # This option should only be used with decoupled projects. More details, visit 12 | # http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects 13 | # org.gradle.parallel=true 14 | # AndroidX package structure to make it clearer which packages are bundled with the 15 | # Android operating system, and which are packaged with your app"s APK 16 | # https://developer.android.com/topic/libraries/support-library/androidx-rn 17 | android.useAndroidX=true 18 | android.enableR8.fullMode=true 19 | # Automatically convert third-party libraries to use AndroidX 20 | android.enableJetifier=false 21 | android.nonTransitiveRClass=true 22 | # Kotlin code style for this project: "official" or "obsolete": 23 | kotlin.code.style=official 24 | # overridden in buildFeatures 25 | #android.defaults.buildfeatures.buildconfig=true 26 | android.nonFinalResIds=true 27 | -------------------------------------------------------------------------------- /gradle/libs.versions.toml: -------------------------------------------------------------------------------- 1 | 2 | [versions] 3 | agp = "8.7.1" 4 | kotlin = "2.0.21" 5 | 6 | # https://github.com/google/ksp/releases 7 | ksp = "2.0.21-1.0.25" 8 | 9 | #https://github.com/google/dagger/releases 10 | dagger = "2.52" 11 | 12 | # https://developer.android.com/jetpack/androidx/releases/hilt 13 | hilt = "1.2.0" 14 | 15 | # kotlinx 16 | # https://github.com/Kotlin/kotlinx.collections.immutable/blob/master/CHANGELOG.md 17 | kotlinxImmutable = "0.3.8" 18 | 19 | # https://developer.android.com/jetpack/androidx/versions 20 | activityCompose = "1.9.3" 21 | core = "1.13.1" 22 | coreSplashscreen = "1.0.1" 23 | datastorePreference = "1.1.1" 24 | 25 | # https://developer.android.com/jetpack/androidx/releases/lifecycle#kts 26 | # see https://stackoverflow.com/a/78490417/1283554 27 | # and https://issuetracker.google.com/issues/336842920#comment8 28 | lifecycle = "2.8.6" 29 | 30 | # https://developer.android.com/jetpack/androidx/releases/navigation 31 | navigation = "2.7.7" 32 | 33 | # Workmanager 34 | # https://developer.android.com/jetpack/androidx/releases/work 35 | workmanager = "2.9.1" 36 | 37 | # https://developer.android.com/jetpack/androidx/releases/room 38 | room = "2.6.1" 39 | 40 | # https://github.com/coil-kt/coil/blob/main/CHANGELOG.md 41 | coil = "3.0.4" 42 | 43 | # https://github.com/square/okhttp/blob/master/docs/changelogs/changelog_4x.md 44 | okhttpBom = "4.12.0" 45 | 46 | # https://github.com/gildor/kotlin-coroutines-okhttp/blob/master/CHANGELOG.md 47 | gildorCoroutinesOkhttp = "1.0" 48 | 49 | # https://developer.android.com/jetpack/compose/bom/bom-mapping 50 | composeBom = "2024.10.00" 51 | # https://github.com/google/accompanist/releases 52 | accompanist = "0.34.0" 53 | # https://github.com/raamcosta/compose-destinations/releases 54 | composeDestinations = "1.11.7" 55 | 56 | junit = "4.13.2" 57 | junitVersion = "1.2.1" 58 | espressoCore = "3.6.1" 59 | 60 | 61 | [libraries] 62 | androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" } 63 | androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "core" } 64 | androidx-core-splashscreen = { group = "androidx.core", name = "core-splashscreen", version.ref = "coreSplashscreen" } 65 | androidx-datastore-preferences = { group = "androidx.datastore", name = "datastore-preferences", version.ref = "datastorePreference" } 66 | 67 | kotlinx-collections-immutable = { group = "org.jetbrains.kotlinx", name = "kotlinx-collections-immutable-jvm", version.ref = "kotlinxImmutable" } 68 | 69 | # LifeCycle 70 | androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycle" } 71 | androidx-lifecycle-runtime-compose = { group = "androidx.lifecycle", name = "lifecycle-runtime-compose", version.ref = "lifecycle" } 72 | androidx-lifecycle-viewmodel-ktx = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-ktx", version.ref = "lifecycle" } 73 | androidx-lifecycle-viewmodel-compose = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "lifecycle" } 74 | androidx-lifecycle-viewmodel-savedstate = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-savedstate", version.ref = "lifecycle" } 75 | 76 | # Navigation 77 | # Provided by Compose Destinations 78 | #androidx-navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "navigation" } 79 | 80 | # Workmanager 81 | workmanager-runtime = { group = "androidx.work", name = "work-runtime-ktx", version.ref = "workmanager" } 82 | 83 | # Room 84 | room-runtime = { group = "androidx.room", name = "room-runtime", version.ref = "room" } 85 | room-ktx = { group = "androidx.room", name = "room-ktx", version.ref = "room" } 86 | room-compiler = { group = "androidx.room", name = "room-compiler", version.ref = "room" } 87 | 88 | # Compose 89 | compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" } 90 | compose-ui = { group = "androidx.compose.ui", name = "ui" } 91 | compose-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" } 92 | compose-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" } 93 | compose-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" } 94 | compose-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" } 95 | compose-material3 = { group = "androidx.compose.material3", name = "material3" } 96 | compose-material-icons-extended = { group = "androidx.compose.material", name = "material-icons-extended" } 97 | 98 | # Compose Destinations 99 | compose-destinations-core = { group = "io.github.raamcosta.compose-destinations", name = "core", version.ref = "composeDestinations" } 100 | compose-destinations-compiler = { group = "io.github.raamcosta.compose-destinations", name = "ksp", version.ref = "composeDestinations" } 101 | 102 | # Coil 103 | coil-compose = { group = "io.coil-kt.coil3", name = "coil-compose", version.ref = "coil" } 104 | coil-okhttp = { group = "io.coil-kt.coil3", name = "coil-network-okhttp", version.ref = "coil" } 105 | 106 | # Hilt 107 | # both hilt-compiler are required 108 | # cf https://github.com/google/dagger/issues/4058#issuecomment-1739045490 109 | dagger-hilt = { group = "com.google.dagger", name = "hilt-android", version.ref = "dagger" } 110 | dagger-compiler = { group = "com.google.dagger", name = "hilt-compiler", version.ref = "dagger" } 111 | hilt-compiler = { group = "androidx.hilt", name = "hilt-compiler", version.ref = "hilt" } 112 | hilt-navigation-compose = { group = "androidx.hilt", name = "hilt-navigation-compose", version.ref = "hilt" } 113 | hilt-workmanager = { group = "androidx.hilt", name = "hilt-work", version.ref = "hilt" } 114 | 115 | # OkHttp + coroutines integration 116 | okhttp-bom = { group = "com.squareup.okhttp3", name = "okhttp-bom", version.ref = "okhttpBom" } 117 | okhttp-okhttp = { group = "com.squareup.okhttp3", name = "okhttp" } 118 | okhttp-tls = { group = "com.squareup.okhttp3", name = "okhttp-tls" } 119 | okhttp-logging-interceptor = { group = "com.squareup.okhttp3", name = "logging-interceptor" } 120 | gildor-coroutines-okhttp= { group = "ru.gildor.coroutines", name = "kotlin-coroutines-okhttp", version.ref = "gildorCoroutinesOkhttp" } 121 | 122 | junit = { group = "junit", name = "junit", version.ref = "junit" } 123 | androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" } 124 | androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" } 125 | 126 | [plugins] 127 | androidApplication = { id = "com.android.application", version.ref = "agp" } 128 | jetbrainsKotlinAndroid = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } 129 | ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" } 130 | hilt = { id = "com.google.dagger.hilt.android", version.ref = "dagger" } 131 | parcelize = { id = "org.jetbrains.kotlin.plugin.parcelize", version.ref = "kotlin" } 132 | compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } 133 | -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bwt/OpenPaperView/9ad4eafa242f5fec5a659ebceee3dbff67d01fd3/gradle/wrapper/gradle-wrapper.jar -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | #Thu Aug 19 14:24:00 CEST 2021 2 | distributionBase=GRADLE_USER_HOME 3 | distributionUrl=https\://services.gradle.org/distributions/gradle-8.9-bin.zip 4 | distributionPath=wrapper/dists 5 | zipStorePath=wrapper/dists 6 | zipStoreBase=GRADLE_USER_HOME 7 | -------------------------------------------------------------------------------- /gradlew: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | 3 | # 4 | # Copyright 2015 the original author or authors. 5 | # 6 | # Licensed under the Apache License, Version 2.0 (the "License"); 7 | # you may not use this file except in compliance with the License. 8 | # You may obtain a copy of the License at 9 | # 10 | # https://www.apache.org/licenses/LICENSE-2.0 11 | # 12 | # Unless required by applicable law or agreed to in writing, software 13 | # distributed under the License is distributed on an "AS IS" BASIS, 14 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 15 | # See the License for the specific language governing permissions and 16 | # limitations under the License. 17 | # 18 | 19 | ############################################################################## 20 | ## 21 | ## Gradle start up script for UN*X 22 | ## 23 | ############################################################################## 24 | 25 | # Attempt to set APP_HOME 26 | # Resolve links: $0 may be a link 27 | PRG="$0" 28 | # Need this for relative symlinks. 29 | while [ -h "$PRG" ] ; do 30 | ls=`ls -ld "$PRG"` 31 | link=`expr "$ls" : '.*-> \(.*\)$'` 32 | if expr "$link" : '/.*' > /dev/null; then 33 | PRG="$link" 34 | else 35 | PRG=`dirname "$PRG"`"/$link" 36 | fi 37 | done 38 | SAVED="`pwd`" 39 | cd "`dirname \"$PRG\"`/" >/dev/null 40 | APP_HOME="`pwd -P`" 41 | cd "$SAVED" >/dev/null 42 | 43 | APP_NAME="Gradle" 44 | APP_BASE_NAME=`basename "$0"` 45 | 46 | # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 47 | DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' 48 | 49 | # Use the maximum available, or set MAX_FD != -1 to use that value. 50 | MAX_FD="maximum" 51 | 52 | warn () { 53 | echo "$*" 54 | } 55 | 56 | die () { 57 | echo 58 | echo "$*" 59 | echo 60 | exit 1 61 | } 62 | 63 | # OS specific support (must be 'true' or 'false'). 64 | cygwin=false 65 | msys=false 66 | darwin=false 67 | nonstop=false 68 | case "`uname`" in 69 | CYGWIN* ) 70 | cygwin=true 71 | ;; 72 | Darwin* ) 73 | darwin=true 74 | ;; 75 | MINGW* ) 76 | msys=true 77 | ;; 78 | NONSTOP* ) 79 | nonstop=true 80 | ;; 81 | esac 82 | 83 | CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar 84 | 85 | 86 | # Determine the Java command to use to start the JVM. 87 | if [ -n "$JAVA_HOME" ] ; then 88 | if [ -x "$JAVA_HOME/jre/sh/java" ] ; then 89 | # IBM's JDK on AIX uses strange locations for the executables 90 | JAVACMD="$JAVA_HOME/jre/sh/java" 91 | else 92 | JAVACMD="$JAVA_HOME/bin/java" 93 | fi 94 | if [ ! -x "$JAVACMD" ] ; then 95 | die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME 96 | 97 | Please set the JAVA_HOME variable in your environment to match the 98 | location of your Java installation." 99 | fi 100 | else 101 | JAVACMD="java" 102 | which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 103 | 104 | Please set the JAVA_HOME variable in your environment to match the 105 | location of your Java installation." 106 | fi 107 | 108 | # Increase the maximum file descriptors if we can. 109 | if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then 110 | MAX_FD_LIMIT=`ulimit -H -n` 111 | if [ $? -eq 0 ] ; then 112 | if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then 113 | MAX_FD="$MAX_FD_LIMIT" 114 | fi 115 | ulimit -n $MAX_FD 116 | if [ $? -ne 0 ] ; then 117 | warn "Could not set maximum file descriptor limit: $MAX_FD" 118 | fi 119 | else 120 | warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" 121 | fi 122 | fi 123 | 124 | # For Darwin, add options to specify how the application appears in the dock 125 | if $darwin; then 126 | GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" 127 | fi 128 | 129 | # For Cygwin or MSYS, switch paths to Windows format before running java 130 | if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then 131 | APP_HOME=`cygpath --path --mixed "$APP_HOME"` 132 | CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` 133 | 134 | JAVACMD=`cygpath --unix "$JAVACMD"` 135 | 136 | # We build the pattern for arguments to be converted via cygpath 137 | ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` 138 | SEP="" 139 | for dir in $ROOTDIRSRAW ; do 140 | ROOTDIRS="$ROOTDIRS$SEP$dir" 141 | SEP="|" 142 | done 143 | OURCYGPATTERN="(^($ROOTDIRS))" 144 | # Add a user-defined pattern to the cygpath arguments 145 | if [ "$GRADLE_CYGPATTERN" != "" ] ; then 146 | OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" 147 | fi 148 | # Now convert the arguments - kludge to limit ourselves to /bin/sh 149 | i=0 150 | for arg in "$@" ; do 151 | CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` 152 | CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option 153 | 154 | if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition 155 | eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` 156 | else 157 | eval `echo args$i`="\"$arg\"" 158 | fi 159 | i=`expr $i + 1` 160 | done 161 | case $i in 162 | 0) set -- ;; 163 | 1) set -- "$args0" ;; 164 | 2) set -- "$args0" "$args1" ;; 165 | 3) set -- "$args0" "$args1" "$args2" ;; 166 | 4) set -- "$args0" "$args1" "$args2" "$args3" ;; 167 | 5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; 168 | 6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; 169 | 7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; 170 | 8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; 171 | 9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; 172 | esac 173 | fi 174 | 175 | # Escape application args 176 | save () { 177 | for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done 178 | echo " " 179 | } 180 | APP_ARGS=`save "$@"` 181 | 182 | # Collect all arguments for the java command, following the shell quoting and substitution rules 183 | eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" 184 | 185 | exec "$JAVACMD" "$@" 186 | -------------------------------------------------------------------------------- /gradlew.bat: -------------------------------------------------------------------------------- 1 | @rem 2 | @rem Copyright 2015 the original author or authors. 3 | @rem 4 | @rem Licensed under the Apache License, Version 2.0 (the "License"); 5 | @rem you may not use this file except in compliance with the License. 6 | @rem You may obtain a copy of the License at 7 | @rem 8 | @rem https://www.apache.org/licenses/LICENSE-2.0 9 | @rem 10 | @rem Unless required by applicable law or agreed to in writing, software 11 | @rem distributed under the License is distributed on an "AS IS" BASIS, 12 | @rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | @rem See the License for the specific language governing permissions and 14 | @rem limitations under the License. 15 | @rem 16 | 17 | @if "%DEBUG%" == "" @echo off 18 | @rem ########################################################################## 19 | @rem 20 | @rem Gradle startup script for Windows 21 | @rem 22 | @rem ########################################################################## 23 | 24 | @rem Set local scope for the variables with windows NT shell 25 | if "%OS%"=="Windows_NT" setlocal 26 | 27 | set DIRNAME=%~dp0 28 | if "%DIRNAME%" == "" set DIRNAME=. 29 | set APP_BASE_NAME=%~n0 30 | set APP_HOME=%DIRNAME% 31 | 32 | @rem Resolve any "." and ".." in APP_HOME to make it shorter. 33 | for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi 34 | 35 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 36 | set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" 37 | 38 | @rem Find java.exe 39 | if defined JAVA_HOME goto findJavaFromJavaHome 40 | 41 | set JAVA_EXE=java.exe 42 | %JAVA_EXE% -version >NUL 2>&1 43 | if "%ERRORLEVEL%" == "0" goto execute 44 | 45 | echo. 46 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 47 | echo. 48 | echo Please set the JAVA_HOME variable in your environment to match the 49 | echo location of your Java installation. 50 | 51 | goto fail 52 | 53 | :findJavaFromJavaHome 54 | set JAVA_HOME=%JAVA_HOME:"=% 55 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe 56 | 57 | if exist "%JAVA_EXE%" goto execute 58 | 59 | echo. 60 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 61 | echo. 62 | echo Please set the JAVA_HOME variable in your environment to match the 63 | echo location of your Java installation. 64 | 65 | goto fail 66 | 67 | :execute 68 | @rem Setup the command line 69 | 70 | set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar 71 | 72 | 73 | @rem Execute Gradle 74 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* 75 | 76 | :end 77 | @rem End local scope for the variables with windows NT shell 78 | if "%ERRORLEVEL%"=="0" goto mainEnd 79 | 80 | :fail 81 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of 82 | rem the _cmd.exe /c_ return code! 83 | if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 84 | exit /b 1 85 | 86 | :mainEnd 87 | if "%OS%"=="Windows_NT" endlocal 88 | 89 | :omega 90 | -------------------------------------------------------------------------------- /metadata/en-US/changelogs/1000001.txt: -------------------------------------------------------------------------------- 1 | - Double tap to (un)zoom in the image and PDF viewer 2 | - Localization 3 | -------------------------------------------------------------------------------- /metadata/en-US/changelogs/1000007.txt: -------------------------------------------------------------------------------- 1 | - Slightly better full text search 2 | -------------------------------------------------------------------------------- /metadata/en-US/changelogs/1000011.txt: -------------------------------------------------------------------------------- 1 | - Updates, mostly Compose 1.5, Kotlin 1.9 2 | - A few tweaks and cleanups 3 | -------------------------------------------------------------------------------- /metadata/en-US/changelogs/1000014.txt: -------------------------------------------------------------------------------- 1 | - Target Android 14 2 | - A few fixes 3 | -------------------------------------------------------------------------------- /metadata/en-US/changelogs/1000019.txt: -------------------------------------------------------------------------------- 1 | - Minor fixes 2 | - Removed an unnecessary permission (boot completed) 3 | - Better edge to edge 4 | -------------------------------------------------------------------------------- /metadata/en-US/changelogs/1001000.txt: -------------------------------------------------------------------------------- 1 | - New feature : auto download 2 | - Lot of tweaks and minor fixes 3 | -------------------------------------------------------------------------------- /metadata/en-US/changelogs/1001006.txt: -------------------------------------------------------------------------------- 1 | - More tweaks and cosmetic changes 2 | -------------------------------------------------------------------------------- /metadata/en-US/changelogs/1001007.txt: -------------------------------------------------------------------------------- 1 | - Update Compose to 1.6.5 2 | -------------------------------------------------------------------------------- /metadata/en-US/changelogs/1001008.txt: -------------------------------------------------------------------------------- 1 | png support added ( Thanks to symphorien ) 2 | This requires the Python helper script to be updated 3 | -------------------------------------------------------------------------------- /metadata/en-US/changelogs/1001012.txt: -------------------------------------------------------------------------------- 1 | collapsible appbar (saves some space in the documents screen) 2 | 3 | -------------------------------------------------------------------------------- /metadata/en-US/changelogs/1001013.txt: -------------------------------------------------------------------------------- 1 | - One less permission 2 | - A few cosmetic changes 3 | 4 | -------------------------------------------------------------------------------- /metadata/en-US/changelogs/1002000.txt: -------------------------------------------------------------------------------- 1 | - Add autodownload for all documents (Thanks to Jérôme Flesch) 2 | - Add predictive back support (Android 15) 3 | - Fixes label autocomplete misplacement 4 | 5 | -------------------------------------------------------------------------------- /metadata/en-US/full_description.txt: -------------------------------------------------------------------------------- 1 | OpenPaperview's purpose is to browse, search in, download and display or send OpenPaper.work documents. 2 | A static HTTPS server is required but the application is designed to be useable offline 3 | with configurable pre-download and local full-text search. 4 | See full setup instructions. 5 | 6 | -------------------------------------------------------------------------------- /metadata/en-US/images/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bwt/OpenPaperView/9ad4eafa242f5fec5a659ebceee3dbff67d01fd3/metadata/en-US/images/icon.png -------------------------------------------------------------------------------- /metadata/en-US/images/phoneScreenshots/1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bwt/OpenPaperView/9ad4eafa242f5fec5a659ebceee3dbff67d01fd3/metadata/en-US/images/phoneScreenshots/1.png -------------------------------------------------------------------------------- /metadata/en-US/images/phoneScreenshots/2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bwt/OpenPaperView/9ad4eafa242f5fec5a659ebceee3dbff67d01fd3/metadata/en-US/images/phoneScreenshots/2.png -------------------------------------------------------------------------------- /metadata/en-US/images/phoneScreenshots/3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bwt/OpenPaperView/9ad4eafa242f5fec5a659ebceee3dbff67d01fd3/metadata/en-US/images/phoneScreenshots/3.png -------------------------------------------------------------------------------- /metadata/en-US/images/phoneScreenshots/4.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bwt/OpenPaperView/9ad4eafa242f5fec5a659ebceee3dbff67d01fd3/metadata/en-US/images/phoneScreenshots/4.png -------------------------------------------------------------------------------- /metadata/en-US/images/phoneScreenshots/5.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bwt/OpenPaperView/9ad4eafa242f5fec5a659ebceee3dbff67d01fd3/metadata/en-US/images/phoneScreenshots/5.png -------------------------------------------------------------------------------- /metadata/en-US/images/phoneScreenshots/6.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bwt/OpenPaperView/9ad4eafa242f5fec5a659ebceee3dbff67d01fd3/metadata/en-US/images/phoneScreenshots/6.png -------------------------------------------------------------------------------- /metadata/en-US/images/phoneScreenshots/7.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bwt/OpenPaperView/9ad4eafa242f5fec5a659ebceee3dbff67d01fd3/metadata/en-US/images/phoneScreenshots/7.png -------------------------------------------------------------------------------- /metadata/en-US/images/tenInchScreenshots/1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bwt/OpenPaperView/9ad4eafa242f5fec5a659ebceee3dbff67d01fd3/metadata/en-US/images/tenInchScreenshots/1.png -------------------------------------------------------------------------------- /metadata/en-US/short_description.txt: -------------------------------------------------------------------------------- 1 | An OpenPaper.work mobile companion designed to be usable offline 2 | -------------------------------------------------------------------------------- /metadata/en-US/title.txt: -------------------------------------------------------------------------------- 1 | OpenPaperView 2 | -------------------------------------------------------------------------------- /metadata/fr/full_description.txt: -------------------------------------------------------------------------------- 1 | OpenPaperView permet de télécharger, visualiser ou envoyer des documents OpenPaper.work. 2 | Un serveur HTTPS statique est nécessaire mais l'application est conçue pour fonctionner 3 | hors ligne avec un pré-téléchargement paramétrable et une recherche en texte intégral locale. 4 | Cf les instructions détaillées. 5 | 6 | -------------------------------------------------------------------------------- /metadata/fr/short_description.txt: -------------------------------------------------------------------------------- 1 | Un compagnon mobile d'OpenPaper.work conçu pour être utilisable hors-ligne 2 | -------------------------------------------------------------------------------- /metadata/fr/title.txt: -------------------------------------------------------------------------------- 1 | OpenPaperView 2 | -------------------------------------------------------------------------------- /settings.gradle.kts: -------------------------------------------------------------------------------- 1 | 2 | pluginManagement { 3 | repositories { 4 | google { 5 | content { 6 | includeGroupByRegex("com\\.android.*") 7 | includeGroupByRegex("com\\.google.*") 8 | includeGroupByRegex("androidx.*") 9 | } 10 | } 11 | mavenCentral() 12 | gradlePluginPortal() 13 | } 14 | } 15 | 16 | dependencyResolutionManagement { 17 | repositoriesMode = RepositoriesMode.FAIL_ON_PROJECT_REPOS 18 | repositories { 19 | google() 20 | mavenCentral() 21 | } 22 | } 23 | 24 | rootProject.name = "OpenPaperView" 25 | include(":app") 26 | -------------------------------------------------------------------------------- /tools/create_viewer_cb.config: -------------------------------------------------------------------------------- 1 | 2 | 3 | [paths] 4 | # the papers main content dir 5 | papers_data_dir = /opt/data/papers 6 | 7 | # The SQLite database containing the OCR 8 | # The location depends on Paperwork's version 9 | # Currently it is called doc_tracking.db and can be found in a directory like 10 | # ~/.local/share/paperwork2/workdir_data/papers_XXXXX 11 | papers_metadata_db = ~/.local/share/paperwork2/workdir_data/papers_randomid/doc_tracking.db 12 | 13 | # The SQLite database created by this script 14 | # It will be downloaded by the application through the HTTP server 15 | result_db = /opt/data/papers.sqlite 16 | 17 | 18 | 19 | # The retention level used for documents 20 | # The possible values are 21 | # ignored : not added at all to the DB 22 | # data : basic data, can only be researched from label, not text 23 | # index : text is indexed but not stored, the text may be searched, but no snippet will be displayed 24 | # full : text is indexed and stored 25 | # If a document matches multiple labels the lowest level is applied. 26 | 27 | [labels] 28 | ignorable_documents_label = ignored 29 | without_snippets = index 30 | fully_stored = full 31 | 32 | 33 | # This section controls which checks are performed 34 | # Warnings are displayed in the console. 35 | # This does not change the resulting DB. 36 | 37 | [warnings] 38 | 39 | # Paperwork saves edited PDF as images 40 | # Currently this images are always ignored (only the original PDF is used) 41 | pdf_with_edited_images = false 42 | 43 | # This is an extension of Paperwork 44 | # If the first line of the "Additional keywords" starts with # 45 | # it will be used as title 46 | missing_title = false 47 | 48 | # Error when trying to get the number of pages of the pdf 49 | pdf_error = false 50 | 51 | # Unexpected file type 52 | unknown_file_type = false 53 | 54 | # A document contains both PDF and images (other than edited pages) 55 | # As far as I know this should not happen. 56 | multiple_types = false 57 | 58 | # A document has a label taht we can't parse 59 | # As far as I know this should not happen. 60 | bad_label = false 61 | 62 | # A directory has a name in a form which is unexpected for a document 63 | # As far as I know this should not happen. 64 | unexpected_directory = false 65 | --------------------------------------------------------------------------------