Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
0f2f829
Add Nav3 dependencies and route keys
jocmp Jun 14, 2026
9952aa4
Replace app NavHost with Nav3 NavDisplay
jocmp Jun 14, 2026
d257b28
Upgrade to AGP 9.1 and compileSdk 37
jocmp Jun 14, 2026
2078d2b
Decouple ArticleView from the paging list
jocmp Jun 14, 2026
9334ca6
Split list and detail into Nav3 entries
jocmp Jun 14, 2026
8a23258
Make the reader a persistent surface
jocmp Jun 15, 2026
ed87693
Add reader next/prev via cursor neighbor query
jocmp Jun 15, 2026
95aac6d
Extend reader neighbors to feed/folder/saved-search/today
jocmp Jun 15, 2026
fb40d4f
Share the list's session cutoff with the reader
jocmp Jun 15, 2026
9473fcb
Add capy:// deep link support
jocmp Jun 15, 2026
c73f20e
Restore pane drag handle, close icon, center placeholder
jocmp Jun 15, 2026
8b06235
Drive pane fullscreen toggle from reader top bar
jocmp Jun 15, 2026
ba01260
Inset drag handle from edge when pane collapsed
jocmp Jun 15, 2026
c52bc9d
Replace pendingArticleID trampoline with deep links
jocmp Jun 15, 2026
1a8398e
Use shared-axis-X for list/detail nav on phone
jocmp Jun 15, 2026
fd5f070
Extract search into its own overlay
jocmp Jun 15, 2026
ed0ec32
Scroll list to the selected article in two-pane
jocmp Jun 15, 2026
96f1093
Always inset the pane drag handle from its edge
jocmp Jun 15, 2026
2e9c534
Host the nav drawer at the window level
jocmp Jun 15, 2026
a426e9a
Render media viewer as a full-window OverlayScene
jocmp Jun 15, 2026
d292f71
Keep the nav drawer closed on launch
jocmp Jun 15, 2026
40f3d06
Fade the media overlay and handle its back
jocmp Jun 15, 2026
e4fb8ff
Fix ArticleScreenViewModel test for articleCutoff param
jocmp Jun 16, 2026
ee7f768
Followups
jocmp Jun 16, 2026
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
10 changes: 6 additions & 4 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -1,9 +1,7 @@
import com.android.build.gradle.internal.tasks.factory.dependsOn
import java.util.Properties

plugins {
id("com.android.application")
id("org.jetbrains.kotlin.android")
id("kotlin-parcelize")
kotlin("plugin.serialization") version libs.versions.kotlin
alias(libs.plugins.compose.compiler)
Expand All @@ -23,7 +21,7 @@ if (rootProject.file("secrets.properties").exists()) {

android {
namespace = "com.capyreader.app"
compileSdk = 36
compileSdk = 37

defaultConfig {
applicationId = "com.capyreader.app"
Expand Down Expand Up @@ -126,10 +124,12 @@ dependencies {
implementation(libs.androidx.adaptive)
implementation(libs.androidx.adaptive.layout)
implementation(libs.androidx.adaptive.navigation)
implementation(libs.androidx.adaptive.navigation3)
implementation(libs.androidx.browser)
implementation(libs.androidx.core.ktx)
implementation(libs.androidx.lifecycle.runtime.compose)
implementation(libs.androidx.lifecycle.runtime.ktx)
implementation(libs.androidx.lifecycle.viewmodel.navigation3)
implementation(libs.androidx.material)
implementation(libs.androidx.material.icons.extended)
implementation(libs.androidx.material3)
Expand All @@ -138,6 +138,8 @@ dependencies {
implementation(libs.androidx.media3.exoplayer)
implementation(libs.androidx.media3.session)
implementation(libs.androidx.navigation.compose)
implementation(libs.androidx.navigation3.runtime)
implementation(libs.androidx.navigation3.ui)
implementation(libs.androidx.palette)
implementation(libs.androidx.paging.compose)
implementation(libs.androidx.paging.runtime.ktx)
Expand Down Expand Up @@ -204,4 +206,4 @@ tasks.register("useGMSDebugFile") {
}
}

project.tasks.preBuild.dependsOn("useGMSDebugFile")
tasks.named("preBuild") { dependsOn("useGMSDebugFile") }
8 changes: 8 additions & 0 deletions app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,14 @@

<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<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:scheme="capy" />
</intent-filter>
</activity>

<activity
Expand Down
56 changes: 40 additions & 16 deletions app/src/main/java/com/capyreader/app/MainActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -6,46 +6,70 @@ import androidx.activity.compose.setContent
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import com.capyreader.app.notifications.NotificationHelper
import androidx.navigation3.runtime.NavKey
import com.capyreader.app.preferences.AppPreferences
import com.capyreader.app.ui.App
import com.capyreader.app.ui.DeepLink
import com.capyreader.app.ui.Route
import org.koin.android.ext.android.get
import com.jocmp.capy.ArticleStatus
import org.koin.android.ext.android.inject

class MainActivity : BaseActivity() {
val appPreferences by inject<AppPreferences>()

private var pendingArticleID by mutableStateOf<String?>(null)
private var deepLink by mutableStateOf<List<NavKey>?>(null)

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
pendingArticleID = NotificationHelper.openFromIntent(intent, appPreferences = appPreferences)
val startBackStack = resolveBackStack(intent)
applyListFilter(startBackStack)

setContent {
App(
startDestination = startDestination(),
startBackStack = startBackStack,
appPreferences = appPreferences,
pendingArticleID = pendingArticleID,
onPendingArticleSelected = { pendingArticleID = null },
deepLink = deepLink,
onDeepLinkConsumed = { deepLink = null },
)
}
}

override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
pendingArticleID = NotificationHelper.openFromIntent(intent, appPreferences = appPreferences)
// A deep link must never bypass the add-account flow when no account is configured.
if (!hasAccount) return
DeepLink.parse(intent.data, currentStatus)?.let { parsed ->
applyListFilter(parsed)
deepLink = parsed
}
}

private fun startDestination(): Route {
val appPreferences = get<AppPreferences>()

val accountID = appPreferences.accountID.get()
/**
* Keep the persisted filter in sync with the deep link's list, so the list and the reader's
* neighbor query (both read [AppPreferences.filter]) agree on which articles are siblings.
*/
private fun applyListFilter(backStack: List<NavKey>) {
(backStack.firstOrNull() as? Route.ArticleList)?.let {
appPreferences.filter.set(it.filter)
}
}

return if (accountID.isBlank()) {
Route.AddAccount
} else {
Route.Articles
/**
* Resolve the launch back stack. The account gate comes first: a deep link only applies once an
* account exists, otherwise we always land on the add-account flow.
*/
private fun resolveBackStack(intent: Intent): List<NavKey> {
if (!hasAccount) {
return listOf(Route.AddAccount)
}

return DeepLink.parse(intent.data, currentStatus)
?: listOf(Route.ArticleList(appPreferences.filter.get()))
}

private val hasAccount: Boolean
get() = appPreferences.accountID.get().isNotBlank()

private val currentStatus: ArticleStatus
get() = appPreferences.filter.get().status
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,21 +6,16 @@ import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.os.Bundle
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import com.capyreader.app.ArticleStatusBroadcastReceiver
import com.capyreader.app.MainActivity
import com.capyreader.app.OpenArticleInBrowserActivity
import com.capyreader.app.R
import com.capyreader.app.notifications.NotificationHelper.Companion.ARTICLE_ID_KEY
import com.capyreader.app.notifications.NotificationHelper.Companion.FEED_ID_KEY
import com.capyreader.app.preferences.AppPreferences
import com.capyreader.app.ui.DeepLink
import com.jocmp.capy.Account
import com.jocmp.capy.ArticleFilter
import com.jocmp.capy.ArticleStatus
import com.jocmp.capy.ArticleNotification
import com.jocmp.capy.logging.CapyLog
import com.jocmp.capy.preferences.getAndSet
import java.time.ZonedDateTime

class NotificationHelper(
Expand Down Expand Up @@ -136,9 +131,6 @@ class NotificationHelper(

companion object {
const val ARTICLE_ID_KEY = "article_id"
const val FEED_ID_KEY = "feed_id"
const val UNREAD_ONLY_KEY = "unread_only"
const val SHOW_ALL_KEY = "show_all"

private const val ARTICLE_REFRESH_GROUP = "article_refresh"

Expand All @@ -155,45 +147,6 @@ class NotificationHelper(
putExtra(ArticleStatusBroadcastReceiver.ARTICLE_ID, articleID)
}
}

fun openFromIntent(intent: Intent, appPreferences: AppPreferences): String? {
val openFromShowMore = intent.getBooleanExtra(UNREAD_ONLY_KEY, false)
val openShowAll = intent.getBooleanExtra(SHOW_ALL_KEY, false)
val articleID = intent.getStringExtra(ARTICLE_ID_KEY)
val feedID = intent.getStringExtra(FEED_ID_KEY)

if (openFromShowMore) {
intent.replaceExtras(Bundle())

appPreferences.filter.set(
ArticleFilter.Articles(articleStatus = ArticleStatus.UNREAD)
)

return null
} else if (openShowAll) {
intent.replaceExtras(Bundle())

appPreferences.filter.set(
ArticleFilter.Articles(articleStatus = ArticleStatus.ALL)
)

return null
} else if (articleID != null && feedID != null) {
intent.replaceExtras(Bundle())

appPreferences.filter.getAndSet { currentFilter ->
ArticleFilter.Feeds(
feedID,
feedStatus = currentFilter.status,
folderTitle = null
)
}

return articleID
}

return null
}
}
}

Expand All @@ -206,10 +159,23 @@ private fun NotificationManagerCompat.tryNotify(id: Int, notification: Notificat
}

private fun ArticleNotification.contentIntent(context: Context): PendingIntent {
val notifyIntent = Intent(context, MainActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
putExtra(ARTICLE_ID_KEY, articleID)
putExtra(FEED_ID_KEY, feedID)
val notifyIntent = if (openInBrowser && !url.isNullOrBlank()) {
// Feeds flagged "open in browser" route through the activity that opens the link and marks
// the article read, matching the in-app list tap and the widgets.
Intent(context, OpenArticleInBrowserActivity::class.java).apply {
putExtra(NotificationHelper.ARTICLE_ID_KEY, articleID)
putExtra(OpenArticleInBrowserActivity.ARTICLE_URL_KEY, url)
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
}
} else {
Intent(
Intent.ACTION_VIEW,
DeepLink.articleUri(articleID = articleID, feedID = feedID),
context,
MainActivity::class.java,
).apply {
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
}
}

return PendingIntent.getActivity(
Expand Down
Loading
Loading