Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 32 additions & 25 deletions app/src/main/java/com/skeler/scanely/core/actions/ActionExecutor.kt
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package com.skeler.scanely.core.actions

import android.content.ActivityNotFoundException
import android.content.ClipData
import android.content.ClipboardManager
import android.content.Context
Expand All @@ -10,12 +11,16 @@ import android.os.Build
import android.provider.CalendarContract
import android.provider.ContactsContract
import android.provider.Settings
import android.util.Log
import android.widget.Toast
import androidx.core.net.toUri
import java.text.ParseException
import java.text.SimpleDateFormat
import java.util.Locale
import java.util.TimeZone

private const val TAG = "ActionExecutor"

object ActionExecutor {

fun execute(context: Context, action: ScanAction) {
Expand All @@ -33,43 +38,55 @@ object ActionExecutor {
}
}

/** Runs an action, toasting [errorToast] on the failures launching intents can produce. */
private inline fun tryAction(context: Context, errorToast: String, block: () -> Unit) {
val failure = try {
block()
null
} catch (e: ActivityNotFoundException) {
e
} catch (e: SecurityException) {
e
} catch (e: IllegalArgumentException) {
e
}
if (failure != null) {
Log.w(TAG, errorToast, failure)
Toast.makeText(context, errorToast, Toast.LENGTH_SHORT).show()
}
}

private fun openUrl(context: Context, url: String) {
try {
tryAction(context, "Cannot open URL") {
var finalUrl = url
if (!url.startsWith("http://") && !url.startsWith("https://")) {
finalUrl = "https://$url"
}
val intent = Intent(Intent.ACTION_VIEW, finalUrl.toUri())
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
context.startActivity(intent)
} catch (e: Exception) {
Toast.makeText(context, "Cannot open URL", Toast.LENGTH_SHORT).show()
}
}

private fun copyText(context: Context, text: String) {
try {
tryAction(context, "Failed to copy") {
val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
val clip = ClipData.newPlainText("Scanned Text", text)
clipboard.setPrimaryClip(clip)
Toast.makeText(context, "Copied to clipboard", Toast.LENGTH_SHORT).show()
} catch (e: Exception) {
Toast.makeText(context, "Failed to copy", Toast.LENGTH_SHORT).show()
}
}

private fun callPhone(context: Context, number: String) {
try {
tryAction(context, "Cannot open dialer") {
val intent = Intent(Intent.ACTION_DIAL, "tel:$number".toUri())
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
context.startActivity(intent)
} catch (e: Exception) {
Toast.makeText(context, "Cannot open dialer", Toast.LENGTH_SHORT).show()
}
}

private fun sendEmail(context: Context, email: String, subject: String?, body: String?) {
try {
tryAction(context, "Cannot open email app") {
val intent = Intent(Intent.ACTION_SENDTO).apply {
data = "mailto:".toUri()
putExtra(Intent.EXTRA_EMAIL, arrayOf(email))
Expand All @@ -78,24 +95,20 @@ object ActionExecutor {
}
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
context.startActivity(intent)
} catch (e: Exception) {
Toast.makeText(context, "Cannot open email app", Toast.LENGTH_SHORT).show()
}
}

private fun sendSms(context: Context, number: String, message: String?) {
try {
tryAction(context, "Cannot open SMS app") {
val intent = Intent(Intent.ACTION_SENDTO, "smsto:$number".toUri())
message?.let { intent.putExtra("sms_body", it) }
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
context.startActivity(intent)
} catch (e: Exception) {
Toast.makeText(context, "Cannot open SMS app", Toast.LENGTH_SHORT).show()
}
}

private fun connectWifi(context: Context, ssid: String, password: String?, type: WifiType) {
try {
tryAction(context, "Cannot connect to WiFi") {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
val suggestion = WifiNetworkSuggestion.Builder()
.setSsid(ssid)
Expand All @@ -119,13 +132,11 @@ object ActionExecutor {
context.startActivity(intent)
Toast.makeText(context, "Open WiFi settings to connect to: $ssid", Toast.LENGTH_SHORT).show()
}
} catch (e: Exception) {
Toast.makeText(context, "Cannot connect to WiFi", Toast.LENGTH_SHORT).show()
}
}

private fun addContact(context: Context, action: ScanAction.AddContact) {
try {
tryAction(context, "Cannot open contacts") {
val intent = Intent(Intent.ACTION_INSERT).apply {
type = ContactsContract.Contacts.CONTENT_TYPE
action.name?.let { putExtra(ContactsContract.Intents.Insert.NAME, it) }
Expand All @@ -135,13 +146,11 @@ object ActionExecutor {
}
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
context.startActivity(intent)
} catch (e: Exception) {
Toast.makeText(context, "Cannot open contacts", Toast.LENGTH_SHORT).show()
}
}

private fun addEvent(context: Context, action: ScanAction.AddEvent) {
try {
tryAction(context, "Cannot open calendar") {
val intent = Intent(Intent.ACTION_INSERT).apply {
data = CalendarContract.Events.CONTENT_URI
action.title?.let { putExtra(CalendarContract.Events.TITLE, it) }
Expand All @@ -156,8 +165,6 @@ object ActionExecutor {
}
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
context.startActivity(intent)
} catch (e: Exception) {
Toast.makeText(context, "Cannot open calendar", Toast.LENGTH_SHORT).show()
}
}

Expand All @@ -176,7 +183,7 @@ object ActionExecutor {
if (utc) timeZone = TimeZone.getTimeZone("UTC")
}
format.parse(value)?.time
} catch (e: Exception) {
} catch (e: ParseException) {
null
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,7 @@ class KeyVerifier @Inject constructor(
classify(provider, code)
} catch (e: CancellationException) {
throw e
} catch (e: IOException) {
} catch (_: IOException) {
if (!networkObserver.isCurrentlyOnline()) {
VerificationResult.Failed("You're offline")
} else {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ import retrofit2.HttpException
import javax.inject.Inject
import javax.inject.Singleton

internal class TransientAiException(message: String) : Exception(message)
internal class TransientAiException(message: String, cause: Throwable? = null) : Exception(message, cause)

internal class FatalAiException(val result: AiResult.Error) : Exception(result.message)

Expand Down Expand Up @@ -87,8 +87,9 @@ internal class ProviderClient @Inject constructor(
}
} catch (e: FatalAiException) {
throw e
} catch (e: kotlinx.coroutines.CancellationException) {
throw e
} catch (e: Exception) {
if (e is kotlinx.coroutines.CancellationException) throw e
// Keep partial text after break (e.g. Cerebras closes post-token); don't re-bill.
if (accumulated.isEmpty()) throw e
aiDebug { "stream broke after ${accumulated.length} chars, keeping partial: ${e.message}" }
Expand All @@ -111,7 +112,7 @@ internal class ProviderClient @Inject constructor(
private inline fun <reified T> decodeChunk(payload: String): T = try {
streamJson.decodeFromString<T>(payload)
} catch (e: SerializationException) {
throw TransientAiException("Malformed response chunk")
throw TransientAiException("Malformed response chunk", e)
}

private fun parseStreamPiece(kind: ProviderKind, payload: String): String? = when (kind) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@ internal class ProviderExecutor @Inject constructor(
} else {
return ProviderOutcome.Fatal(AiResult.Error("No response generated"))
}
} catch (e: TimeoutCancellationException) {
} catch (_: TimeoutCancellationException) {
aiDebug { "$name attempt ${attempt + 1} timed out after $timeoutMs ms" }
lastFailureWasNetwork = false
if (streamedAnything[0]) return ProviderOutcome.Exhausted()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,15 +15,12 @@ suspend fun <T> withRetry(
shouldRetry: (Exception) -> Boolean = { it.isRetryable() },
block: suspend () -> T
): T {
var lastException: Exception? = null
var currentDelay = config.initialDelayMs

repeat(config.maxAttempts) { attempt ->
try {
return block()
} catch (e: Exception) {
lastException = e

if (!shouldRetry(e) || attempt == config.maxAttempts - 1) {
throw e
}
Expand All @@ -36,7 +33,8 @@ suspend fun <T> withRetry(
}
}

throw lastException ?: IllegalStateException("Retry failed")
// Reachable only with maxAttempts <= 0; the last attempt always rethrows.
error("Retry failed")
}

fun Exception.isRetryable(): Boolean {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,7 @@ class OpenLibraryEngine @Inject constructor(
}
else -> emptyList()
}
} catch (e: Exception) {
} catch (_: IllegalArgumentException) {
emptyList()
}
}
Expand Down
6 changes: 5 additions & 1 deletion app/src/main/java/com/skeler/scanely/core/pdf/PdfHelper.kt
Original file line number Diff line number Diff line change
@@ -1,13 +1,16 @@
package com.skeler.scanely.core.pdf

import android.content.ActivityNotFoundException
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.util.Log
import android.widget.Toast

object PdfHelper {

private const val MIME_TYPE_PDF = "application/pdf"
private const val TAG = "PdfHelper"

fun openPdfExternally(context: Context, uri: Uri): Boolean {
return try {
Expand All @@ -22,7 +25,8 @@ object PdfHelper {

context.startActivity(chooser)
true
} catch (e: Exception) {
} catch (e: ActivityNotFoundException) {
Log.w(TAG, "No activity can view PDFs", e)
Toast.makeText(
context,
"No PDF viewer found. Install a PDF reader app.",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -360,6 +360,7 @@ object ScanExporter {
}
true
} catch (e: Exception) {
Log.e(TAG, "Gallery save failed for $displayName, rolling back MediaStore row", e)
resolver.delete(uri, null, null)
false
}
Expand Down
17 changes: 16 additions & 1 deletion app/src/main/java/com/skeler/scanely/core/security/KeyCipher.kt
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
package com.skeler.scanely.core.security

import android.security.keystore.KeyGenParameterSpec
import android.security.keystore.KeyPermanentlyInvalidatedException
import android.security.keystore.KeyProperties
import android.util.Base64
import android.util.Log
import java.security.GeneralSecurityException
import java.security.KeyStore
import javax.crypto.Cipher
import javax.crypto.KeyGenerator
Expand All @@ -11,6 +14,8 @@ import javax.crypto.spec.GCMParameterSpec
import javax.inject.Inject
import javax.inject.Singleton

private const val TAG = "KeyCipher"

@Singleton
class KeyCipher @Inject constructor() {

Expand All @@ -30,15 +35,25 @@ class KeyCipher @Inject constructor() {
return Base64.encodeToString(payload, Base64.NO_WRAP)
}

// Log only the exception class — never the payload or plaintext.
fun decrypt(encoded: String): String? = try {
val payload = Base64.decode(encoded, Base64.NO_WRAP)
require(payload.size > IV_SIZE_BYTES) { "payload too short" }
val iv = payload.copyOfRange(0, IV_SIZE_BYTES)
val ciphertext = payload.copyOfRange(IV_SIZE_BYTES, payload.size)
val cipher = Cipher.getInstance(TRANSFORMATION).apply {
init(Cipher.DECRYPT_MODE, secretKey(), GCMParameterSpec(TAG_LENGTH_BITS, iv))
}
String(cipher.doFinal(ciphertext), Charsets.UTF_8)
} catch (e: Exception) {
} catch (_: KeyPermanentlyInvalidatedException) {
// Lock-screen change etc. — ciphertext is undecryptable forever; user must re-enter keys.
Log.e(TAG, "Keystore key permanently invalidated, stored API keys are lost")
null
} catch (e: GeneralSecurityException) {
Log.w(TAG, "Decrypt failed: ${e.javaClass.simpleName}")
null
} catch (e: IllegalArgumentException) {
Log.w(TAG, "Decrypt failed on malformed payload: ${e.javaClass.simpleName}")
null
}

Expand Down
Loading
Loading