Files
paperscan/app/src/main/java/eu/geyskens/pdfscan/paperless/PaperlessClient.kt
sam 3c77e9ab3e Translate the entire repo to English (UI strings, README, CI, comments)
Prep for public distribution. All user-facing strings, the README, the Gitea
workflow step names/comments and the keystore example are now English.
Follow-up i18n (string resources + locale files) is tracked in #5.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 18:52:01 +02:00

149 lines
6.1 KiB
Kotlin

package eu.geyskens.pdfscan.paperless
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.delay
import kotlinx.coroutines.withContext
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.MultipartBody
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.asRequestBody
import org.json.JSONArray
import org.json.JSONObject
import java.io.File
import java.util.concurrent.TimeUnit
/**
* Minimal Paperless-ngx REST client. Talks only to the user-configured, self-hosted
* instance. The token is sent as an `Authorization: Token …` header over the connection
* the user set up (HTTPS strongly recommended — see SettingsScreen guidance).
*/
class PaperlessClient(
private val baseUrl: String,
private val token: String,
) {
private val client = OkHttpClient.Builder()
.connectTimeout(15, TimeUnit.SECONDS)
.writeTimeout(60, TimeUnit.SECONDS)
.readTimeout(30, TimeUnit.SECONDS)
.build()
private val root = baseUrl.trim().trimEnd('/')
sealed interface Result {
/** Document was consumed and created in Paperless. */
data object Success : Result
/** Paperless rejected it as a duplicate of an existing document. */
data object Duplicate : Result
/** Upload was accepted but still processing when we stopped waiting. */
data object Pending : Result
data class Failure(val message: String) : Result
}
/** Verifies the URL + token by hitting an authenticated endpoint. */
suspend fun testConnection(): Result = withContext(Dispatchers.IO) {
val request = Request.Builder()
.url("$root/api/documents/?page_size=1")
.header("Authorization", "Token $token")
.header("Accept", "application/json")
.get()
.build()
runCatching {
client.newCall(request).execute().use { resp ->
when {
resp.isSuccessful -> Result.Success
resp.code == 401 || resp.code == 403 -> Result.Failure("Invalid token (${resp.code}).")
else -> Result.Failure("Server responded with ${resp.code}.")
}
}
}.getOrElse { Result.Failure(it.message ?: "Connection failed.") }
}
/**
* Uploads a PDF and then waits for Paperless to actually consume it. The upload
* endpoint only queues the document and returns a task UUID; we poll the task so the
* user learns whether it was added, was a duplicate, or failed.
*/
suspend fun upload(pdf: File, title: String): Result = withContext(Dispatchers.IO) {
val body = MultipartBody.Builder()
.setType(MultipartBody.FORM)
.addFormDataPart("title", title)
.addFormDataPart(
"document", "$title.pdf",
pdf.asRequestBody("application/pdf".toMediaType())
)
.build()
val request = Request.Builder()
.url("$root/api/documents/post_document/")
.header("Authorization", "Token $token")
.post(body)
.build()
// The POST runs NonCancellable so that "stop waiting" only skips the status poll —
// the document is always really queued. post_document returns the task UUID string.
val posted: kotlin.Result<String?> = withContext(NonCancellable) {
runCatching {
client.newCall(request).execute().use { resp ->
if (!resp.isSuccessful) error("Upload failed: ${resp.code} ${resp.message}")
resp.body?.string()?.trim()?.trim('"')?.takeIf { it.isNotBlank() }
}
}
}
val taskId = posted.getOrElse { return@withContext Result.Failure(it.message ?: "Upload failed.") }
// Older servers may not return a usable id; then we can only confirm acceptance.
if (taskId == null || taskId.equals("OK", ignoreCase = true)) return@withContext Result.Pending
pollTask(taskId)
}
/** Polls /api/tasks/ until the consume task finishes, or gives up after ~45s. */
private suspend fun pollTask(taskId: String): Result {
val deadline = System.currentTimeMillis() + 45_000
while (System.currentTimeMillis() < deadline) {
val task = runCatching {
val req = Request.Builder()
.url("$root/api/tasks/?task_id=$taskId")
.header("Authorization", "Token $token")
.header("Accept", "application/json")
.get()
.build()
client.newCall(req).execute().use { resp ->
if (resp.isSuccessful) firstTask(resp.body?.string()) else null
}
}.getOrNull()
when (task?.status?.uppercase()) {
"SUCCESS" -> return Result.Success
"FAILURE" -> {
val msg = task.result.orEmpty()
return if (msg.contains("duplicate", ignoreCase = true)) Result.Duplicate
else Result.Failure(msg.ifBlank { "Paperless could not process the document." })
}
// PENDING / STARTED / RETRY / unknown → keep waiting.
}
delay(1500)
}
return Result.Pending
}
private data class Task(val status: String?, val result: String?)
/** Parses the first task object from the /api/tasks/ response (a plain array). */
private fun firstTask(json: String?): Task? {
if (json.isNullOrBlank()) return null
return runCatching {
val trimmed = json.trim()
val arr = if (trimmed.startsWith("[")) JSONArray(trimmed)
else JSONObject(trimmed).optJSONArray("results")
if (arr == null || arr.length() == 0) return null
val o = arr.getJSONObject(0)
Task(
status = o.optString("status").takeIf { it.isNotBlank() && it != "null" },
result = o.optString("result").takeIf { it.isNotBlank() && it != "null" },
)
}.getOrNull()
}
}