diff --git a/.gitignore b/.gitignore
index ee8bdf4..751d85a 100644
--- a/.gitignore
+++ b/.gitignore
@@ -9,3 +9,9 @@ MAVEN
tags
mupdf-*-android-viewer.apk
mupdf-*-android-viewer--app-release.aab
+commit.md
+/.claude
+/.vscode
+.vscode/settings.json
+*.hprof
+pull_request.md
diff --git a/app/build.gradle b/app/build.gradle
index c0cbbe1..50cf72f 100644
--- a/app/build.gradle
+++ b/app/build.gradle
@@ -1,25 +1,60 @@
apply plugin: 'com.android.application'
+apply plugin: 'kotlin-android'
group = 'com.artifex.mupdf'
version = '1.27.1a'
+def localProps = new Properties()
+def localPropsFile = rootProject.file('local.properties')
+if (localPropsFile.exists()) localProps.load(localPropsFile.newInputStream())
+
dependencies {
if (file('../lib/build.gradle').isFile())
api project(':lib')
else
api 'com.artifex.mupdf:viewer:1.27.1a'
+
+ // Compose
+ def compose_version = "1.5.0"
+ implementation "androidx.compose.ui:ui:$compose_version"
+ implementation "androidx.compose.material3:material3:1.1.1"
+ implementation "androidx.compose.ui:ui-tooling-preview:$compose_version"
+ implementation "androidx.lifecycle:lifecycle-runtime-compose:2.6.1"
+ implementation "androidx.activity:activity-compose:1.7.2"
+
+ // HTTP + coroutines for AI API
+ implementation "com.squareup.okhttp3:okhttp:4.12.0"
+ implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3"
+ implementation "androidx.lifecycle:lifecycle-runtime:2.6.1"
+ implementation "androidx.savedstate:savedstate:1.2.1"
}
android {
namespace 'com.artifex.mupdf.viewer.app'
- compileSdkVersion 33
+ compileSdkVersion 34
defaultConfig {
- minSdkVersion 21
+ minSdkVersion 24
targetSdkVersion 35
versionName '1.27.1a'
versionCode 210
}
+ buildFeatures {
+ compose true
+ buildConfig true
+ }
+ composeOptions {
+ kotlinCompilerExtensionVersion '1.5.1'
+ }
+ compileOptions {
+ sourceCompatibility JavaVersion.VERSION_17
+ targetCompatibility JavaVersion.VERSION_17
+ }
+
+ kotlinOptions {
+ jvmTarget = '17'
+ }
+
splits {
abi {
enable true
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 9da588a..d53aa4d 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -2,6 +2,11 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/java/com/artifex/mupdf/viewer/app/AiDocumentActivity.kt b/app/src/main/java/com/artifex/mupdf/viewer/app/AiDocumentActivity.kt
new file mode 100644
index 0000000..a53cd33
--- /dev/null
+++ b/app/src/main/java/com/artifex/mupdf/viewer/app/AiDocumentActivity.kt
@@ -0,0 +1,238 @@
+package com.artifex.mupdf.viewer.app
+
+import com.artifex.mupdf.viewer.SecurePreferences
+
+import android.os.Bundle
+import android.view.ViewGroup
+import android.widget.RelativeLayout
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material3.FloatingActionButton
+import androidx.compose.material3.FloatingActionButtonDefaults
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.platform.ComposeView
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import androidx.lifecycle.Lifecycle
+import androidx.lifecycle.LifecycleOwner
+import androidx.lifecycle.LifecycleRegistry
+import androidx.lifecycle.setViewTreeLifecycleOwner
+import androidx.savedstate.SavedStateRegistry
+import androidx.savedstate.SavedStateRegistryController
+import androidx.savedstate.SavedStateRegistryOwner
+import androidx.savedstate.setViewTreeSavedStateRegistryOwner
+import com.artifex.mupdf.viewer.DocumentActivity
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.cancel
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.catch
+import kotlinx.coroutines.flow.update
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+
+class AiDocumentActivity : DocumentActivity(), LifecycleOwner, SavedStateRegistryOwner {
+
+ // ── Lifecycle wiring (required for ComposeView inside plain Activity) ──────
+ private val lifecycleRegistry = LifecycleRegistry(this)
+ private val savedStateRegistryController = SavedStateRegistryController.create(this)
+
+ override val lifecycle: Lifecycle get() = lifecycleRegistry
+ override val savedStateRegistry: SavedStateRegistry
+ get() = savedStateRegistryController.savedStateRegistry
+
+ // ── AI state ──────────────────────────────────────────────────────────────
+ private val uiState = AiUiState()
+ private val scope = CoroutineScope(Dispatchers.Main + SupervisorJob())
+ private lateinit var exporter: ObsidianExporter
+ private var aiClient: OpenAiClient? = null
+
+ // ── Lifecycle callbacks ───────────────────────────────────────────────────
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ savedStateRegistryController.performRestore(savedInstanceState)
+ lifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_CREATE)
+ exporter = ObsidianExporter(this)
+ // No longer initializing with static key here
+ super.onCreate(savedInstanceState)
+ }
+
+ override fun onStart() {
+ super.onStart()
+ lifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_START)
+ }
+
+ override fun onResume() {
+ super.onResume()
+ lifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_RESUME)
+ }
+
+ override fun onPause() {
+ lifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_PAUSE)
+ super.onPause()
+ }
+
+ override fun onStop() {
+ lifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_STOP)
+ super.onStop()
+ }
+
+ override fun onSaveInstanceState(outState: Bundle) {
+ super.onSaveInstanceState(outState)
+ savedStateRegistryController.performSave(outState)
+ }
+
+ override fun onDestroy() {
+ lifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_DESTROY)
+ scope.cancel()
+ super.onDestroy()
+ }
+
+ // ── UI setup ──────────────────────────────────────────────────────────────
+
+ override fun createUI(savedInstanceState: Bundle?) {
+ super.createUI(savedInstanceState)
+
+ val rootLayout = findRelativeLayout(window.decorView.rootView as ViewGroup) ?: return
+
+ // Required: inject lifecycle owners so ComposeView can find them
+ rootLayout.setViewTreeLifecycleOwner(this)
+ rootLayout.setViewTreeSavedStateRegistryOwner(this)
+
+ val composeView = ComposeView(this).apply {
+ setContent {
+ val s by uiState.flow.collectAsState()
+ AiOverlay(
+ uiState = s,
+ onToggleAiMode = { uiState.toggleAiMode() },
+ onCircleComplete = { handleCircleComplete() },
+ onSaveNote = { exporter.exportAtomicNote(uiState.flow.value.response) },
+ onClosePanel = { uiState.closePanel() }
+ )
+ }
+ }
+
+ rootLayout.addView(
+ composeView,
+ RelativeLayout.LayoutParams(
+ RelativeLayout.LayoutParams.MATCH_PARENT,
+ RelativeLayout.LayoutParams.MATCH_PARENT
+ )
+ )
+ }
+
+ private fun handleCircleComplete() {
+ val pageNum = mDocView?.getDisplayedViewIndex() ?: return
+ uiState.startLoading()
+
+ scope.launch {
+ val pageText = withContext(Dispatchers.IO) {
+ core?.getPageText(pageNum) ?: ""
+ }
+ if (pageText.isBlank()) {
+ uiState.setError("No text found on this page.")
+ return@launch
+ }
+
+ val key = SecurePreferences.getApiKey(this@AiDocumentActivity)
+ if (key.isNullOrEmpty()) {
+ uiState.setError("API Key not set. Please set it in the Settings menu (Wrench icon).")
+ return@launch
+ }
+
+ if (aiClient == null || aiClient?.apiKey != key) {
+ aiClient = OpenAiClient(key)
+ }
+
+ aiClient!!.explain(pageText)
+ .catch { e -> uiState.setError(e.message ?: "Request failed") }
+ .collect { chunk -> uiState.appendChunk(chunk) }
+ uiState.finishLoading()
+ }
+ }
+
+ private fun findRelativeLayout(view: ViewGroup): RelativeLayout? {
+ if (view is RelativeLayout) return view
+ for (i in 0 until view.childCount) {
+ val child = view.getChildAt(i) as? ViewGroup ?: continue
+ findRelativeLayout(child)?.let { return it }
+ }
+ return null
+ }
+}
+
+// ── State holder ──────────────────────────────────────────────────────────────
+
+data class AiUiData(
+ val aiModeEnabled: Boolean = false,
+ val isPanelVisible: Boolean = false,
+ val isLoading: Boolean = false,
+ val response: String = ""
+)
+
+class AiUiState {
+ private val _flow = MutableStateFlow(AiUiData())
+ val flow = _flow.asStateFlow()
+
+ fun toggleAiMode() = _flow.update {
+ it.copy(
+ aiModeEnabled = !it.aiModeEnabled,
+ isPanelVisible = if (it.aiModeEnabled) false else it.isPanelVisible
+ )
+ }
+ fun startLoading() = _flow.update { it.copy(isPanelVisible = true, isLoading = true, response = "") }
+ fun appendChunk(chunk: String) = _flow.update { it.copy(response = it.response + chunk) }
+ fun setError(msg: String) = _flow.update { it.copy(isLoading = false, response = "[Error] $msg") }
+ fun finishLoading() = _flow.update { it.copy(isLoading = false) }
+ fun closePanel() = _flow.update { it.copy(isPanelVisible = false, response = "") }
+}
+
+// ── Composables ───────────────────────────────────────────────────────────────
+
+@Composable
+fun AiOverlay(
+ uiState: AiUiData,
+ onToggleAiMode: () -> Unit,
+ onCircleComplete: () -> Unit,
+ onSaveNote: () -> Unit,
+ onClosePanel: () -> Unit
+) {
+ Box(modifier = Modifier.fillMaxSize()) {
+ // Gesture layer — active only in AI mode when panel is not showing
+ if (uiState.aiModeEnabled && !uiState.isPanelVisible) {
+ IonizationOverlay(onCircleComplete = { onCircleComplete() })
+ }
+
+ // Bottom panel with streaming AI response
+ AiPanel(
+ isVisible = uiState.isPanelVisible,
+ streamedText = uiState.response,
+ isLoading = uiState.isLoading,
+ onSaveNote = onSaveNote,
+ onClose = onClosePanel
+ )
+
+ // Floating AI toggle button (bottom-right, above MuPDF's bottom bar)
+ FloatingActionButton(
+ onClick = onToggleAiMode,
+ modifier = Modifier
+ .align(Alignment.BottomEnd)
+ .padding(end = 24.dp, bottom = 96.dp),
+ containerColor = if (uiState.aiModeEnabled) Color(0xFF1976D2) else Color.White,
+ contentColor = if (uiState.aiModeEnabled) Color.White else Color(0xFF1976D2),
+ elevation = FloatingActionButtonDefaults.elevation(6.dp)
+ ) {
+ Text(text = "AI", fontSize = 13.sp, fontWeight = FontWeight.Bold)
+ }
+ }
+}
diff --git a/app/src/main/java/com/artifex/mupdf/viewer/app/AiFloatingDialog.kt b/app/src/main/java/com/artifex/mupdf/viewer/app/AiFloatingDialog.kt
new file mode 100644
index 0000000..daea1e3
--- /dev/null
+++ b/app/src/main/java/com/artifex/mupdf/viewer/app/AiFloatingDialog.kt
@@ -0,0 +1,101 @@
+package com.artifex.mupdf.viewer.app
+
+import androidx.compose.animation.*
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.*
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material3.*
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Brush
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+
+@Composable
+fun AiFloatingDialog(
+ isVisible: Boolean,
+ noteContent: String,
+ onExport: () -> Unit,
+ onClose: () -> Unit
+) {
+ AnimatedVisibility(
+ visible = isVisible,
+ enter = fadeIn() + expandVertically(),
+ exit = fadeOut() + shrinkVertically(),
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(16.dp)
+ .statusBarsPadding()
+ ) {
+ Card(
+ modifier = Modifier
+ .fillMaxWidth()
+ .wrapContentHeight(),
+ shape = RoundedCornerShape(24.dp),
+ elevation = CardDefaults.cardElevation(defaultElevation = 8.dp),
+ colors = CardDefaults.cardColors(containerColor = Color.White.copy(alpha = 0.95f))
+ ) {
+ Column(
+ modifier = Modifier
+ .background(
+ brush = Brush.verticalGradient(
+ colors = listOf(Color(0xFFE3F2FD), Color.White)
+ )
+ )
+ .padding(20.dp)
+ ) {
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ modifier = Modifier.fillMaxWidth()
+ ) {
+ Text(
+ text = "Atomic Note",
+ style = MaterialTheme.typography.titleMedium,
+ color = Color(0xFF1976D2)
+ )
+ Spacer(modifier = Modifier.weight(1f))
+ IconButton(onClick = onClose) {
+ Text("✕", fontSize = 18.sp)
+ }
+ }
+
+ Spacer(modifier = Modifier.height(12.dp))
+
+ Box(
+ modifier = Modifier
+ .heightIn(max = 300.dp)
+ .fillMaxWidth()
+ .verticalScroll(rememberScrollState())
+ ) {
+ Text(
+ text = noteContent,
+ style = MaterialTheme.typography.bodyMedium,
+ modifier = Modifier.fillMaxWidth()
+ )
+ }
+
+ Spacer(modifier = Modifier.height(16.dp))
+
+ Row(
+ horizontalArrangement = Arrangement.End,
+ modifier = Modifier.fillMaxWidth()
+ ) {
+ TextButton(onClick = onClose) {
+ Text("Dismiss")
+ }
+ Button(
+ onClick = onExport,
+ colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF1976D2)),
+ shape = RoundedCornerShape(12.dp)
+ ) {
+ Text("Export to Obsidian")
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/com/artifex/mupdf/viewer/app/AiPanel.kt b/app/src/main/java/com/artifex/mupdf/viewer/app/AiPanel.kt
new file mode 100644
index 0000000..88462c8
--- /dev/null
+++ b/app/src/main/java/com/artifex/mupdf/viewer/app/AiPanel.kt
@@ -0,0 +1,139 @@
+package com.artifex.mupdf.viewer.app
+
+import androidx.compose.animation.AnimatedVisibility
+import androidx.compose.animation.slideInVertically
+import androidx.compose.animation.slideOutVertically
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.*
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material3.*
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+
+@Composable
+fun AiPanel(
+ isVisible: Boolean,
+ streamedText: String,
+ isLoading: Boolean,
+ onSaveNote: () -> Unit,
+ onClose: () -> Unit
+) {
+ Box(modifier = Modifier.fillMaxSize()) {
+ // Dimmed backdrop — only when panel is visible
+ if (isVisible) {
+ Box(
+ modifier = Modifier
+ .fillMaxSize()
+ .background(Color.Black.copy(alpha = 0.35f))
+ )
+ }
+
+ AnimatedVisibility(
+ visible = isVisible,
+ enter = slideInVertically(initialOffsetY = { it }),
+ exit = slideOutVertically(targetOffsetY = { it }),
+ modifier = Modifier.align(Alignment.BottomCenter)
+ ) {
+ Surface(
+ modifier = Modifier
+ .fillMaxWidth()
+ .fillMaxHeight(0.55f),
+ shape = RoundedCornerShape(topStart = 20.dp, topEnd = 20.dp),
+ color = Color(0xFFF8F9FA),
+ shadowElevation = 16.dp
+ ) {
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(horizontal = 20.dp, vertical = 16.dp)
+ ) {
+ // Drag handle
+ Box(
+ modifier = Modifier
+ .align(Alignment.CenterHorizontally)
+ .width(40.dp)
+ .height(4.dp)
+ .background(Color(0xFFDDDDDD), RoundedCornerShape(2.dp))
+ )
+
+ Spacer(modifier = Modifier.height(12.dp))
+
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ modifier = Modifier.fillMaxWidth()
+ ) {
+ Text(
+ text = "AI Assistant",
+ fontSize = 16.sp,
+ fontWeight = FontWeight.SemiBold,
+ color = Color(0xFF1A1A2E)
+ )
+ Spacer(modifier = Modifier.weight(1f))
+ if (isLoading) {
+ CircularProgressIndicator(
+ modifier = Modifier.size(18.dp),
+ strokeWidth = 2.dp,
+ color = Color(0xFF1976D2)
+ )
+ Spacer(modifier = Modifier.width(10.dp))
+ }
+ TextButton(onClick = onClose) {
+ Text("Close", color = Color(0xFF888888), fontSize = 13.sp)
+ }
+ }
+
+ Divider(
+ modifier = Modifier.padding(vertical = 8.dp),
+ color = Color(0xFFEEEEEE)
+ )
+
+ // Scrollable AI response
+ Box(
+ modifier = Modifier
+ .weight(1f)
+ .fillMaxWidth()
+ .verticalScroll(rememberScrollState())
+ ) {
+ Text(
+ text = when {
+ isLoading && streamedText.isEmpty() -> "Analyzing selected text..."
+ streamedText.isEmpty() -> ""
+ else -> streamedText
+ },
+ fontSize = 14.sp,
+ lineHeight = 22.sp,
+ color = if (isLoading && streamedText.isEmpty())
+ Color(0xFF999999) else Color(0xFF333333)
+ )
+ }
+
+ // Save button — only after streaming completes
+ if (!isLoading && streamedText.isNotEmpty()) {
+ Spacer(modifier = Modifier.height(12.dp))
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.End
+ ) {
+ Button(
+ onClick = onSaveNote,
+ colors = ButtonDefaults.buttonColors(
+ containerColor = Color(0xFF1976D2)
+ ),
+ shape = RoundedCornerShape(10.dp)
+ ) {
+ Text("Save Atomic Note", fontSize = 13.sp)
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/com/artifex/mupdf/viewer/app/AtomReadActivity.kt b/app/src/main/java/com/artifex/mupdf/viewer/app/AtomReadActivity.kt
new file mode 100644
index 0000000..3d84f27
--- /dev/null
+++ b/app/src/main/java/com/artifex/mupdf/viewer/app/AtomReadActivity.kt
@@ -0,0 +1,86 @@
+package com.artifex.mupdf.viewer.app
+
+import android.net.Uri
+import android.os.Bundle
+import androidx.activity.ComponentActivity
+import androidx.activity.compose.setContent
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.viewinterop.AndroidView
+import com.artifex.mupdf.viewer.ReaderView
+import kotlinx.coroutines.launch
+
+class AtomReadActivity : ComponentActivity() {
+ private lateinit var exporter: ObsidianExporter
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ exporter = ObsidianExporter(this)
+
+ val uri = intent.data ?: Uri.EMPTY
+ val mimetype = intent.type ?: "application/pdf"
+
+ setContent {
+ AtomReadScreen(uri, mimetype, exporter)
+ }
+ }
+}
+
+@Composable
+fun AtomReadScreen(uri: Uri, mimetype: String, exporter: ObsidianExporter) {
+ var showAiDialog by remember { mutableStateOf(false) }
+ var currentNote by remember { mutableStateOf("") }
+ val coroutineScope = rememberCoroutineScope()
+
+ Box(modifier = Modifier.fillMaxSize()) {
+ PdfViewer(uri, mimetype)
+
+ IonizationOverlay(onCircleComplete = { bounds ->
+ coroutineScope.launch {
+ currentNote = mockAiExtraction(bounds)
+ showAiDialog = true
+ }
+ })
+
+ AiFloatingDialog(
+ isVisible = showAiDialog,
+ noteContent = currentNote,
+ onExport = {
+ exporter.exportAtomicNote(currentNote)
+ showAiDialog = false
+ },
+ onClose = { showAiDialog = false }
+ )
+ }
+}
+
+suspend fun mockAiExtraction(bounds: androidx.compose.ui.geometry.Rect): String {
+ return """
+ ### Extract: Quantum Entanglement
+
+ The circled region discusses the non-local correlation between particles.
+
+ **Key Concept:** When particles become entangled, their states are linked such that the measurement of one instantly determines the state of the other, regardless of distance.
+
+ **Atomic Insight:** This challenges our classical understanding of locality and causality.
+ """.trimIndent()
+}
+
+@Composable
+fun PdfViewer(uri: Uri, mimetype: String) {
+ AndroidView(
+ factory = { context ->
+ ReaderView(context).apply {
+ // Initialize core and adapter here
+ }
+ },
+ modifier = Modifier.fillMaxSize()
+ )
+}
diff --git a/app/src/main/java/com/artifex/mupdf/viewer/app/BondingView.kt b/app/src/main/java/com/artifex/mupdf/viewer/app/BondingView.kt
new file mode 100644
index 0000000..54c573c
--- /dev/null
+++ b/app/src/main/java/com/artifex/mupdf/viewer/app/BondingView.kt
@@ -0,0 +1,96 @@
+package com.artifex.mupdf.viewer.app
+
+import androidx.compose.animation.core.Animatable
+import androidx.compose.animation.core.VectorConverter
+import androidx.compose.foundation.background
+import androidx.compose.foundation.gestures.detectDragGestures
+import androidx.compose.foundation.layout.*
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.*
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.graphics.Brush
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.input.pointer.pointerInput
+import androidx.compose.ui.unit.IntOffset
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import kotlinx.coroutines.launch
+import kotlin.math.roundToInt
+
+data class Atom(
+ val id: String,
+ val content: String,
+ val initialPosition: Offset
+)
+
+@Composable
+fun BondingView(
+ atoms: List,
+ onBondCreated: (Atom, Atom) -> Unit
+) {
+ Box(modifier = Modifier.fillMaxSize().background(Color(0xFF0D47A1))) {
+ atoms.forEach { atom ->
+ AtomicSphere(atom = atom, otherAtoms = atoms.filter { it != atom }, onBondCreated = onBondCreated)
+ }
+ }
+}
+
+@Composable
+fun AtomicSphere(
+ atom: Atom,
+ otherAtoms: List,
+ onBondCreated: (Atom, Atom) -> Unit
+) {
+ val coroutineScope = rememberCoroutineScope()
+ val offset = remember { Animatable(atom.initialPosition, Offset.VectorConverter) }
+ var isDragging by remember { mutableStateOf(false) }
+
+ Box(
+ modifier = Modifier
+ .offset { IntOffset(offset.value.x.roundToInt(), offset.value.y.roundToInt()) }
+ .size(80.dp)
+ .clip(CircleShape)
+ .background(
+ brush = Brush.radialGradient(
+ colors = listOf(Color(0xFF64B5F6), Color(0xFF1976D2))
+ )
+ )
+ .pointerInput(Unit) {
+ detectDragGestures(
+ onDragStart = { isDragging = true },
+ onDrag = { change, dragAmount ->
+ change.consume()
+ coroutineScope.launch {
+ offset.snapTo(offset.value + dragAmount)
+ }
+ },
+ onDragEnd = {
+ isDragging = false
+ // Check for collisions with other atoms
+ val collided = otherAtoms.find { other ->
+ val distance = (offset.value - other.initialPosition).getDistance()
+ distance < 100f // Simple collision threshold
+ }
+ if (collided != null) {
+ onBondCreated(atom, collided)
+ }
+ }
+ )
+ },
+ contentAlignment = Alignment.Center
+ ) {
+ Text(
+ text = atom.id.take(2).uppercase(),
+ color = Color.White,
+ style = MaterialTheme.typography.bodyLarge,
+ fontSize = 18.sp
+ )
+ }
+}
+
+private fun Offset.getDistance(): Float = kotlin.math.sqrt(x * x + y * y)
diff --git a/app/src/main/java/com/artifex/mupdf/viewer/app/IonizationOverlay.kt b/app/src/main/java/com/artifex/mupdf/viewer/app/IonizationOverlay.kt
new file mode 100644
index 0000000..dfa8f6a
--- /dev/null
+++ b/app/src/main/java/com/artifex/mupdf/viewer/app/IonizationOverlay.kt
@@ -0,0 +1,92 @@
+package com.artifex.mupdf.viewer.app
+
+import android.content.Context
+import android.os.Build
+import android.os.VibrationEffect
+import android.os.Vibrator
+import android.os.VibratorManager
+import androidx.compose.foundation.Canvas
+import androidx.compose.foundation.gestures.detectDragGestures
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.runtime.*
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.Path
+import androidx.compose.ui.graphics.drawscope.Stroke
+import androidx.compose.ui.input.pointer.pointerInput
+import androidx.compose.ui.platform.LocalContext
+
+@Composable
+fun IonizationOverlay(
+ onCircleComplete: (bounds: androidx.compose.ui.geometry.Rect) -> Unit = {}
+) {
+ var currentPath by remember { mutableStateOf(null) }
+ var points = remember { mutableStateListOf() }
+ val context = LocalContext.current
+
+ Canvas(
+ modifier = Modifier
+ .fillMaxSize()
+ .pointerInput(Unit) {
+ detectDragGestures(
+ onDragStart = { offset ->
+ points.clear()
+ points.add(offset)
+ currentPath = Path().apply { moveTo(offset.x, offset.y) }
+ },
+ onDrag = { change, dragAmount ->
+ change.consume()
+ val newPoint = change.position
+ points.add(newPoint)
+ currentPath?.lineTo(newPoint.x, newPoint.y)
+
+ // Force recomposition
+ val p = currentPath
+ currentPath = null
+ currentPath = p
+ },
+ onDragEnd = {
+ if (points.size > 10) {
+ // Trigger vibration
+ triggerVibration(context)
+
+ // Calculate bounds
+ val minX = points.minOf { it.x }
+ val maxX = points.maxOf { it.x }
+ val minY = points.minOf { it.y }
+ val maxY = points.maxOf { it.y }
+
+ onCircleComplete(androidx.compose.ui.geometry.Rect(minX, minY, maxX, maxY))
+ }
+ currentPath = null
+ points.clear()
+ }
+ )
+ }
+ ) {
+ currentPath?.let { path ->
+ drawPath(
+ path = path,
+ color = Color(0xFF64B5F6).copy(alpha = 0.8f),
+ style = Stroke(width = 8f)
+ )
+ }
+ }
+}
+
+@Suppress("DEPRECATION")
+private fun triggerVibration(context: Context) {
+ val vibrator = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
+ val vibratorManager = context.getSystemService(Context.VIBRATOR_MANAGER_SERVICE) as VibratorManager
+ vibratorManager.defaultVibrator
+ } else {
+ context.getSystemService(Context.VIBRATOR_SERVICE) as Vibrator
+ }
+
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+ vibrator.vibrate(VibrationEffect.createOneShot(50, VibrationEffect.DEFAULT_AMPLITUDE))
+ } else {
+ vibrator.vibrate(50)
+ }
+}
diff --git a/app/src/main/java/com/artifex/mupdf/viewer/app/LibraryActivity.java b/app/src/main/java/com/artifex/mupdf/viewer/app/LibraryActivity.java
index a90043c..792520d 100644
--- a/app/src/main/java/com/artifex/mupdf/viewer/app/LibraryActivity.java
+++ b/app/src/main/java/com/artifex/mupdf/viewer/app/LibraryActivity.java
@@ -50,7 +50,7 @@ public void onStart() {
public void onActivityResult(int request, int result, Intent data) {
if (request == FILE_REQUEST && result == Activity.RESULT_OK) {
if (data != null) {
- Intent intent = new Intent(this, DocumentActivity.class);
+ Intent intent = new Intent(this, AiDocumentActivity.class);
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_DOCUMENT);
intent.addFlags(Intent.FLAG_ACTIVITY_MULTIPLE_TASK);
intent.setAction(Intent.ACTION_VIEW);
diff --git a/app/src/main/java/com/artifex/mupdf/viewer/app/ObsidianExporter.kt b/app/src/main/java/com/artifex/mupdf/viewer/app/ObsidianExporter.kt
new file mode 100644
index 0000000..310a059
--- /dev/null
+++ b/app/src/main/java/com/artifex/mupdf/viewer/app/ObsidianExporter.kt
@@ -0,0 +1,42 @@
+package com.artifex.mupdf.viewer.app
+
+import android.content.Context
+import android.os.Environment
+import android.util.Log
+import java.io.File
+import java.text.SimpleDateFormat
+import java.util.*
+
+class ObsidianExporter(private val context: Context) {
+
+ fun exportAtomicNote(content: String, title: String? = null): Boolean {
+ try {
+ // Default Obsidian directory (this might need to be configurable by the user)
+ val baseDir = File(Environment.getExternalStorageDirectory(), "Documents/ObsidianVault/Atoms")
+ if (!baseDir.exists()) {
+ baseDir.mkdirs()
+ }
+
+ val fileName = title ?: "Atom_${SimpleDateFormat("yyyyMMdd_HHmmss", Locale.getDefault()).format(Date())}"
+ val file = File(baseDir, "$fileName.md")
+
+ val markdownContent = """
+ # $fileName
+
+ Created: ${SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.getDefault()).format(Date())}
+ Tags: #atom #atomread
+
+ ---
+
+ $content
+ """.trimIndent()
+
+ file.writeText(markdownContent)
+ Log.d("ObsidianExporter", "Exported to ${file.absolutePath}")
+ return true
+ } catch (e: Exception) {
+ Log.e("ObsidianExporter", "Failed to export note", e)
+ return false
+ }
+ }
+}
diff --git a/app/src/main/java/com/artifex/mupdf/viewer/app/OpenAiClient.kt b/app/src/main/java/com/artifex/mupdf/viewer/app/OpenAiClient.kt
new file mode 100644
index 0000000..b3e5a42
--- /dev/null
+++ b/app/src/main/java/com/artifex/mupdf/viewer/app/OpenAiClient.kt
@@ -0,0 +1,97 @@
+package com.artifex.mupdf.viewer.app
+
+import kotlinx.coroutines.channels.awaitClose
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.callbackFlow
+import okhttp3.*
+import okhttp3.MediaType.Companion.toMediaType
+import okhttp3.RequestBody.Companion.toRequestBody
+import org.json.JSONArray
+import org.json.JSONObject
+import java.io.IOException
+
+class OpenAiClient(val apiKey: String) {
+
+ companion object {
+ // Update this to match your OpenAI model ID exactly
+ const val MODEL = "gpt-5.4-nano"
+ private const val API_URL = "https://api.openai.com/v1/chat/completions"
+ private val SYSTEM_PROMPT = """
+ You are an expert academic reading assistant embedded in a PDF reader.
+ When given text from a PDF page, respond in two sections:
+
+ ## AI Explanation
+ Explain the core concepts clearly in 2-4 sentences. Use plain language.
+
+ ## Atomic Note
+ **Core Concept:** [one sentence summary]
+ **Key Insight:** [the most important takeaway]
+ **Connections:** [2-3 related concepts or fields]
+
+ Be concise. Focus on depth over breadth.
+ """.trimIndent()
+ }
+
+ private val httpClient = OkHttpClient()
+ private val jsonMediaType = "application/json".toMediaType()
+
+ fun explain(pageText: String): Flow = callbackFlow {
+ val messages = JSONArray().apply {
+ put(JSONObject().put("role", "system").put("content", SYSTEM_PROMPT))
+ put(JSONObject().put("role", "user").put("content",
+ "Please explain and generate an atomic note for:\n\n$pageText"))
+ }
+
+ val requestBody = JSONObject()
+ .put("model", MODEL)
+ .put("messages", messages)
+ .put("stream", true)
+ .put("max_completion_tokens", 800)
+ .toString()
+ .toRequestBody(jsonMediaType)
+
+ val request = Request.Builder()
+ .url(API_URL)
+ .header("Authorization", "Bearer $apiKey")
+ .header("Content-Type", "application/json")
+ .post(requestBody)
+ .build()
+
+ val call = httpClient.newCall(request)
+
+ call.enqueue(object : Callback {
+ override fun onFailure(call: Call, e: IOException) {
+ close(e)
+ }
+
+ override fun onResponse(call: Call, response: Response) {
+ if (!response.isSuccessful) {
+ close(IOException("HTTP ${response.code}: ${response.body?.string()}"))
+ return
+ }
+ try {
+ response.body?.source()?.use { source ->
+ while (!source.exhausted()) {
+ val line = source.readUtf8Line() ?: break
+ if (!line.startsWith("data: ")) continue
+ val data = line.removePrefix("data: ").trim()
+ if (data == "[DONE]") break
+ try {
+ val content = JSONObject(data)
+ .getJSONArray("choices")
+ .getJSONObject(0)
+ .getJSONObject("delta")
+ .optString("content", "")
+ if (content.isNotEmpty()) trySend(content)
+ } catch (_: Exception) { }
+ }
+ }
+ } finally {
+ close()
+ }
+ }
+ })
+
+ awaitClose { call.cancel() }
+ }
+}
diff --git a/build.gradle b/build.gradle
index 8770582..4b865ee 100644
--- a/build.gradle
+++ b/build.gradle
@@ -9,7 +9,8 @@ buildscript {
mavenCentral()
}
dependencies {
- classpath 'com.android.tools.build:gradle:8.5.2'
+ classpath 'com.android.tools.build:gradle:8.13.2'
+ classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:1.9.0"
}
}
@@ -21,7 +22,7 @@ allprojects {
maven { url "file://${System.properties['user.home']}/MAVEN" }
}
maven { url 'https://maven.ghostscript.com/' }
- google()
+google()
mavenCentral()
}
}
diff --git a/gradle.properties b/gradle.properties
index 5bac8ac..d63d5b2 100644
--- a/gradle.properties
+++ b/gradle.properties
@@ -1 +1,2 @@
android.useAndroidX=true
+org.gradle.jvmargs=-Xmx4g -XX:MaxMetaspaceSize=512m
diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties
index 48c0a02..2733ed5 100644
--- a/gradle/wrapper/gradle-wrapper.properties
+++ b/gradle/wrapper/gradle-wrapper.properties
@@ -1,5 +1,5 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
-distributionUrl=https\://services.gradle.org/distributions/gradle-8.7-bin.zip
+distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-bin.zip
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
diff --git a/lib/build.gradle b/lib/build.gradle
index 69f34ee..7c4156d 100644
--- a/lib/build.gradle
+++ b/lib/build.gradle
@@ -1,4 +1,5 @@
apply plugin: 'com.android.library'
+apply plugin: 'kotlin-android'
apply plugin: 'maven-publish'
group = 'com.artifex.mupdf'
@@ -6,6 +7,8 @@ version = '1.27.1a'
dependencies {
implementation 'androidx.appcompat:appcompat:1.1.+'
+ // Security
+ implementation "androidx.security:security-crypto:1.1.0"
if (file('../jni/build.gradle').isFile())
api project(':jni')
else
@@ -19,6 +22,15 @@ android {
minSdkVersion 21
targetSdkVersion 35
}
+
+ compileOptions {
+ sourceCompatibility JavaVersion.VERSION_17
+ targetCompatibility JavaVersion.VERSION_17
+ }
+
+ kotlinOptions {
+ jvmTarget = '17'
+ }
publishing {
singleVariant("release") {
withSourcesJar()
diff --git a/lib/src/main/java/com/artifex/mupdf/viewer/DocumentActivity.java b/lib/src/main/java/com/artifex/mupdf/viewer/DocumentActivity.java
index 6f2c76e..e2a89d9 100644
--- a/lib/src/main/java/com/artifex/mupdf/viewer/DocumentActivity.java
+++ b/lib/src/main/java/com/artifex/mupdf/viewer/DocumentActivity.java
@@ -71,10 +71,10 @@ enum TopBarMode {Main, Search, More};
private final float EXCLUSION_HEIGHT_FACTOR = 2.0f;
private final int OUTLINE_REQUEST=0;
- private MuPDFCore core;
+ protected MuPDFCore core;
private String mDocTitle;
private String mDocKey;
- private ReaderView mDocView;
+ protected ReaderView mDocView;
private View mButtonsView;
private boolean mButtonsVisible;
private EditText mPasswordView;
@@ -112,6 +112,7 @@ enum TopBarMode {Main, Search, More};
protected Insets systemInsets = Insets.NONE;
protected View mLayoutButton;
+ protected View mSettingsButton;
protected PopupMenu mLayoutPopupMenu;
private String toHex(byte[] digest) {
@@ -606,6 +607,51 @@ public WindowInsets onApplyWindowInsets(View v, WindowInsets windowInsets)
}
});
+ // Load E-ink settings
+ mDocView.setAnimationsEnabled(prefs.getBoolean("animationsEnabled", true));
+ mDocView.setEinkRefreshEnabled(prefs.getBoolean("einkRefreshEnabled", false));
+
+ mSettingsButton.setOnClickListener(new View.OnClickListener() {
+ public void onClick(View v) {
+ PopupMenu popup = new PopupMenu(DocumentActivity.this, mSettingsButton);
+ final SharedPreferences prefs = getPreferences(Context.MODE_PRIVATE);
+ boolean animationsEnabled = prefs.getBoolean("animationsEnabled", true);
+ boolean einkRefreshEnabled = prefs.getBoolean("einkRefreshEnabled", false);
+
+ MenuItem animItem = popup.getMenu().add(0, 1, 0, "Enable Animations");
+ animItem.setCheckable(true);
+ animItem.setChecked(animationsEnabled);
+
+ MenuItem refreshItem = popup.getMenu().add(0, 2, 0, "E-ink Refresh");
+ refreshItem.setCheckable(true);
+ refreshItem.setChecked(einkRefreshEnabled);
+
+ popup.getMenu().add(0, 3, 0, "Set OpenAI API Key");
+
+ popup.setOnMenuItemClickListener(new PopupMenu.OnMenuItemClickListener() {
+ public boolean onMenuItemClick(MenuItem item) {
+ SharedPreferences.Editor edit = prefs.edit();
+ if (item.getItemId() == 1) {
+ boolean val = !item.isChecked();
+ item.setChecked(val);
+ edit.putBoolean("animationsEnabled", val);
+ mDocView.setAnimationsEnabled(val);
+ } else if (item.getItemId() == 2) {
+ boolean val = !item.isChecked();
+ item.setChecked(val);
+ edit.putBoolean("einkRefreshEnabled", val);
+ mDocView.setEinkRefreshEnabled(val);
+ } else if (item.getItemId() == 3) {
+ showApiKeyDialog();
+ }
+ edit.apply();
+ return true;
+ }
+ });
+ popup.show();
+ }
+ });
+
if (Build.VERSION.SDK_INT >= 29)
mBottomBar.addOnLayoutChangeListener(new View.OnLayoutChangeListener() {
public void onLayoutChange(View v, int left, int top, int right, int bottom, int oldLeft, int oldTop, int oldRight, int oldBottom) {
@@ -831,6 +877,7 @@ private void makeButtonsView() {
mSearchText = (EditText)mButtonsView.findViewById(R.id.searchText);
mLinkButton = (ImageButton)mButtonsView.findViewById(R.id.linkButton);
mLayoutButton = mButtonsView.findViewById(R.id.layoutButton);
+ mSettingsButton = mButtonsView.findViewById(R.id.settingsButton);
mTopBarSwitcher.setVisibility(View.INVISIBLE);
mPageNumberView.setVisibility(View.INVISIBLE);
mActionBar.setVisibility(View.VISIBLE);
@@ -901,4 +948,25 @@ public void onBackPressed() {
}
}
}
+
+ private void showApiKeyDialog() {
+ final EditText input = new EditText(this);
+ input.setHint("sk-...");
+ String currentKey = SecurePreferences.INSTANCE.getApiKey(this);
+ if (currentKey != null) input.setText(currentKey);
+
+ new AlertDialog.Builder(this)
+ .setTitle("OpenAI API Key")
+ .setMessage("Your key will be encrypted and stored locally.")
+ .setView(input)
+ .setPositiveButton("Save", new DialogInterface.OnClickListener() {
+ @Override
+ public void onClick(DialogInterface dialog, int which) {
+ SecurePreferences.INSTANCE.saveApiKey(DocumentActivity.this, input.getText().toString().trim());
+ Toast.makeText(DocumentActivity.this, "API Key saved securely", Toast.LENGTH_SHORT).show();
+ }
+ })
+ .setNegativeButton("Cancel", null)
+ .show();
+ }
}
diff --git a/lib/src/main/java/com/artifex/mupdf/viewer/MuPDFCore.java b/lib/src/main/java/com/artifex/mupdf/viewer/MuPDFCore.java
index b31e653..afdadc2 100644
--- a/lib/src/main/java/com/artifex/mupdf/viewer/MuPDFCore.java
+++ b/lib/src/main/java/com/artifex/mupdf/viewer/MuPDFCore.java
@@ -247,4 +247,16 @@ public synchronized boolean authenticatePassword(String password) {
reflowable = doc.isReflowable();
return authenticated;
}
+
+ public synchronized String getPageText(int pageNum) {
+ gotoPage(pageNum);
+ if (page == null) return "";
+ try {
+ com.artifex.mupdf.fitz.StructuredText st = page.toStructuredText("preserve-whitespace");
+ return st.asText();
+ } catch (Exception e) {
+ Log.e(APP, "Failed to extract page text", e);
+ return "";
+ }
+ }
}
diff --git a/lib/src/main/java/com/artifex/mupdf/viewer/ReaderView.java b/lib/src/main/java/com/artifex/mupdf/viewer/ReaderView.java
index 0517c83..f8f0a76 100644
--- a/lib/src/main/java/com/artifex/mupdf/viewer/ReaderView.java
+++ b/lib/src/main/java/com/artifex/mupdf/viewer/ReaderView.java
@@ -71,6 +71,8 @@ public class ReaderView
private float mLastScaleFocusY;
protected Stack mHistory;
+ private boolean mAnimationsEnabled = true;
+ private boolean mEinkRefreshEnabled = false;
public interface ViewMapper {
void applyToView(View view);
@@ -146,6 +148,14 @@ public void setDisplayedViewIndex(int i) {
}
}
+ public void setAnimationsEnabled(boolean enabled) {
+ mAnimationsEnabled = enabled;
+ }
+
+ public void setEinkRefreshEnabled(boolean enabled) {
+ mEinkRefreshEnabled = enabled;
+ }
+
public void moveToNext() {
View v = mChildViews.get(mCurrent+1);
if (v != null)
@@ -252,7 +262,7 @@ public void smartMoveForwards() {
yOffset = smartAdvanceAmount(screenHeight, docHeight - bottom);
}
mScrollerLastX = mScrollerLastY = 0;
- mScroller.startScroll(0, 0, remainingX - xOffset, remainingY - yOffset, 400);
+ mScroller.startScroll(0, 0, remainingX - xOffset, remainingY - yOffset, mAnimationsEnabled ? 400 : 1);
mStepper.prod();
}
@@ -324,7 +334,7 @@ public void smartMoveBackwards() {
yOffset = -smartAdvanceAmount(screenHeight, top);
}
mScrollerLastX = mScrollerLastY = 0;
- mScroller.startScroll(0, 0, remainingX - xOffset, remainingY - yOffset, 400);
+ mScroller.startScroll(0, 0, remainingX - xOffset, remainingY - yOffset, mAnimationsEnabled ? 400 : 1);
mStepper.prod();
}
@@ -398,6 +408,19 @@ public boolean onFling(MotionEvent e1, MotionEvent e2, float velocityX,
if (mScaling)
return true;
+ if (!mAnimationsEnabled) {
+ switch(directionOfTravel(velocityX, velocityY)) {
+ case MOVING_LEFT:
+ case MOVING_UP:
+ smartMoveForwards();
+ return true;
+ case MOVING_RIGHT:
+ case MOVING_DOWN:
+ smartMoveBackwards();
+ return true;
+ }
+ }
+
View v = mChildViews.get(mCurrent);
if (v != null) {
Rect bounds = getScrollBounds(v);
@@ -477,6 +500,10 @@ public boolean onScroll(MotionEvent e1, MotionEvent e2, float distanceX,
PageView pageView = (PageView)getDisplayedView();
if (!tapDisabled)
onDocMotion();
+
+ if (!mAnimationsEnabled)
+ return true;
+
if (!mScaling) {
mXScroll -= distanceX;
mYScroll -= distanceY;
@@ -875,7 +902,7 @@ private void slideViewOntoScreen(View v) {
Point corr = getCorrection(getScrollBounds(v));
if (corr.x != 0 || corr.y != 0) {
mScrollerLastX = mScrollerLastY = 0;
- mScroller.startScroll(0, 0, corr.x, corr.y, 400);
+ mScroller.startScroll(0, 0, corr.x, corr.y, mAnimationsEnabled ? 400 : 1);
mStepper.prod();
}
}
@@ -957,6 +984,22 @@ protected void onMoveToChild(int i) {
SearchTaskResult.set(null);
resetupChildren();
}
+ if (mEinkRefreshEnabled) {
+ triggerEinkRefresh();
+ }
+ }
+
+ private void triggerEinkRefresh() {
+ // Generic invalidation to trigger a redraw.
+ // For specific E-ink devices, you might need to use device-specific APIs here.
+ post(new Runnable() {
+ @Override
+ public void run() {
+ invalidate();
+ // Some E-ink devices respond well to a visibility toggle or similar hacks
+ // if a full refresh API is not available.
+ }
+ });
}
protected void onMoveOffChild(int i) {
diff --git a/lib/src/main/java/com/artifex/mupdf/viewer/SecurePreferences.kt b/lib/src/main/java/com/artifex/mupdf/viewer/SecurePreferences.kt
new file mode 100644
index 0000000..153656b
--- /dev/null
+++ b/lib/src/main/java/com/artifex/mupdf/viewer/SecurePreferences.kt
@@ -0,0 +1,30 @@
+package com.artifex.mupdf.viewer
+
+import android.content.Context
+import androidx.security.crypto.EncryptedSharedPreferences
+import androidx.security.crypto.MasterKey
+
+object SecurePreferences {
+ private const val PREFS_NAME = "secure_prefs"
+ private const val KEY_OPENAI_API_KEY = "openai_api_key"
+
+ private fun getEncryptedPrefs(context: Context) = EncryptedSharedPreferences.create(
+ context,
+ PREFS_NAME,
+ MasterKey.Builder(context).setKeyScheme(MasterKey.KeyScheme.AES256_GCM).build(),
+ EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
+ EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
+ )
+
+ fun saveApiKey(context: Context, apiKey: String) {
+ getEncryptedPrefs(context).edit().putString(KEY_OPENAI_API_KEY, apiKey).apply()
+ }
+
+ fun getApiKey(context: Context): String? {
+ return getEncryptedPrefs(context).getString(KEY_OPENAI_API_KEY, null)
+ }
+
+ fun hasApiKey(context: Context): Boolean {
+ return !getApiKey(context).isNullOrEmpty()
+ }
+}
diff --git a/lib/src/main/res/drawable/ic_settings_white_24dp.xml b/lib/src/main/res/drawable/ic_settings_white_24dp.xml
new file mode 100644
index 0000000..0e7d8df
--- /dev/null
+++ b/lib/src/main/res/drawable/ic_settings_white_24dp.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/lib/src/main/res/layout/document_activity.xml b/lib/src/main/res/layout/document_activity.xml
index 6020439..dd12370 100644
--- a/lib/src/main/res/layout/document_activity.xml
+++ b/lib/src/main/res/layout/document_activity.xml
@@ -76,6 +76,14 @@
android:src="@drawable/ic_toc_white_24dp"
/>
+
+