From 45d47f42ce7ce05bd1afcac73713b71a1fbc2a6b Mon Sep 17 00:00:00 2001 From: kkbin505 <19462942@qq.com> Date: Sat, 9 May 2026 09:56:48 -0700 Subject: [PATCH 1/2] Add e-ink screen optimizations and AI-assisted reading feature MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add two major feature areas on top of the MuPDF Android viewer base. ### E-ink Screen Optimizations - Add `setAnimationsEnabled(bool)` to ReaderView: when disabled, scroll duration drops from 400 ms to 1 ms and drag scrolling is suppressed, eliminating ghosting on e-ink panels. - Add `setEinkRefreshEnabled(bool)` to ReaderView: posts an invalidate() after each page settlement to trigger a full-screen refresh cycle. - Fling gestures now jump directly to the next/previous page instead of smooth-scrolling when animations are off. - Add a Settings button (wrench icon) to the top bar with a popup menu for toggling animations, e-ink refresh, and setting the OpenAI API key. All settings are persisted via SharedPreferences. ### AI-Assisted Reading - Add AiDocumentActivity (extends DocumentActivity) that overlays a Jetpack Compose UI onto the existing PDF reader without replacing it. - Add IonizationOverlay: a full-screen Canvas gesture layer that detects free-form draw gestures and fires a vibration + AI request on release. - Add OpenAiClient: streams chat completions from the OpenAI API over SSE using OkHttp. The system prompt produces two structured sections — an AI Explanation and an Atomic Note (core concept / key insight / connections). - Add AiPanel: a bottom sheet built in Compose that slides up and renders the streaming response token-by-token in real time. - Expose MuPDFCore.getPageText() to extract structured text from the current page via MuPDF's StructuredText API for use as AI context. - Add SecurePreferences: stores the OpenAI API key in EncryptedSharedPreferences (AES256-GCM) so the key is never stored in plain text. - Add ObsidianExporter: saves AI responses as Markdown files under Documents/ObsidianVault/Atoms/ with #atom #atomread tags for integration with Obsidian. - Add AtomReadActivity registered as an alternate PDF intent handler (application/pdf MIME), combining the PDF viewer with the AI overlay. - Add BondingView: prototype Compose component for visually linking atomic notes via drag-and-drop collision (not yet wired to main flow). --- .gitignore | 5 + app/build.gradle | 39 ++- app/src/main/AndroidManifest.xml | 21 ++ .../mupdf/viewer/app/AiDocumentActivity.kt | 238 ++++++++++++++++++ .../mupdf/viewer/app/AiFloatingDialog.kt | 101 ++++++++ .../com/artifex/mupdf/viewer/app/AiPanel.kt | 139 ++++++++++ .../mupdf/viewer/app/AtomReadActivity.kt | 86 +++++++ .../artifex/mupdf/viewer/app/BondingView.kt | 96 +++++++ .../mupdf/viewer/app/IonizationOverlay.kt | 92 +++++++ .../mupdf/viewer/app/LibraryActivity.java | 2 +- .../mupdf/viewer/app/ObsidianExporter.kt | 42 ++++ .../artifex/mupdf/viewer/app/OpenAiClient.kt | 97 +++++++ build.gradle | 5 +- gradle.properties | 1 + gradle/wrapper/gradle-wrapper.properties | 2 +- lib/build.gradle | 12 + .../mupdf/viewer/DocumentActivity.java | 72 +++++- .../com/artifex/mupdf/viewer/MuPDFCore.java | 12 + .../com/artifex/mupdf/viewer/ReaderView.java | 49 +++- .../artifex/mupdf/viewer/SecurePreferences.kt | 30 +++ .../res/drawable/ic_settings_white_24dp.xml | 9 + lib/src/main/res/layout/document_activity.xml | 8 + 22 files changed, 1147 insertions(+), 11 deletions(-) create mode 100644 app/src/main/java/com/artifex/mupdf/viewer/app/AiDocumentActivity.kt create mode 100644 app/src/main/java/com/artifex/mupdf/viewer/app/AiFloatingDialog.kt create mode 100644 app/src/main/java/com/artifex/mupdf/viewer/app/AiPanel.kt create mode 100644 app/src/main/java/com/artifex/mupdf/viewer/app/AtomReadActivity.kt create mode 100644 app/src/main/java/com/artifex/mupdf/viewer/app/BondingView.kt create mode 100644 app/src/main/java/com/artifex/mupdf/viewer/app/IonizationOverlay.kt create mode 100644 app/src/main/java/com/artifex/mupdf/viewer/app/ObsidianExporter.kt create mode 100644 app/src/main/java/com/artifex/mupdf/viewer/app/OpenAiClient.kt create mode 100644 lib/src/main/java/com/artifex/mupdf/viewer/SecurePreferences.kt create mode 100644 lib/src/main/res/drawable/ic_settings_white_24dp.xml diff --git a/.gitignore b/.gitignore index ee8bdf4..4b383fe 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,8 @@ MAVEN tags mupdf-*-android-viewer.apk mupdf-*-android-viewer--app-release.aab +commit.md +/.claude +/.vscode +.vscode/settings.json +*.hprof 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" /> + + Date: Sat, 9 May 2026 09:58:39 -0700 Subject: [PATCH 2/2] Update .gitignore --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 4b383fe..751d85a 100644 --- a/.gitignore +++ b/.gitignore @@ -14,3 +14,4 @@ commit.md /.vscode .vscode/settings.json *.hprof +pull_request.md