├── .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 | [
](https://f-droid.org/packages/net.phbwt.paperwork/)
8 | [
](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 |
--------------------------------------------------------------------------------