Skip to content
Open
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
6 changes: 6 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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
39 changes: 37 additions & 2 deletions app/build.gradle
Original file line number Diff line number Diff line change
@@ -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
Expand Down
21 changes: 21 additions & 0 deletions app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,11 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
android:installLocation="auto"
>
<uses-permission android:name="android.permission.VIBRATE" />
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />

<application
android:label="MuPDF viewer"
android:icon="@drawable/ic_mupdf"
Expand All @@ -15,5 +20,21 @@
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<activity
android:name=".AiDocumentActivity"
android:exported="true"
android:theme="@android:style/Theme.NoTitleBar.Fullscreen"
></activity>
<activity
android:name=".AtomReadActivity"
android:exported="true"
>
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:mimeType="application/pdf" />
</intent-filter>
</activity>
</application>
</manifest>
238 changes: 238 additions & 0 deletions app/src/main/java/com/artifex/mupdf/viewer/app/AiDocumentActivity.kt
Original file line number Diff line number Diff line change
@@ -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)
}
}
}
Loading