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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 14 additions & 5 deletions .secrets.baseline

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ credentials are included. See the [screenshot notes](docs/assets/README.md).
- **Keep passages.** Enter Highlight mode to save coloured passages, change
their colour or remove them. Open Highlights to browse the book's saved passages
and return to their exact pages. From a text selection, Copy as quote includes
the book title and author. [Highlights stay on your device](docs/reader-highlights.md),
the book title and author. [Highlights work offline and sync with Quire Server](docs/reader-highlights.md),
including when you remove a download and later import the same book again.
- **Keep a useful shelf.** Search, sort, continue reading, and filter finished
or abandoned books. With Quire Server, Library Stats brings together reading
Expand Down
13 changes: 12 additions & 1 deletion app/src/main/java/io/theficos/ereader/di/AppContainer.kt
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ import io.theficos.ereader.data.opds.BookDownloader
import io.theficos.ereader.data.opds.OpdsClient
import io.theficos.ereader.data.opds.OpdsHttpClient
import io.theficos.ereader.data.opds.OpenLibraryClient
import io.theficos.ereader.data.sync.HighlightSyncer
import io.theficos.ereader.data.sync.HighlightSyncSession
import io.theficos.ereader.data.sync.SyncClient
import io.theficos.ereader.data.sync.SyncDependencies
import io.theficos.ereader.data.sync.SyncEnqueuer
Expand Down Expand Up @@ -94,7 +96,12 @@ class AppContainer(context: Context) {
private val db: EReaderDatabase = EReaderDatabase.build(appContext)
val documentRepository = DocumentRepository(db.documentDao())
val progressRepository = ProgressRepository(db.progressDao())
val highlightRepository = HighlightRepository(db.highlightDao())
val highlightRepository = HighlightRepository(
db.highlightDao(),
scopeProvider = { credentialStore.getAccount().highlightScope() },
scopeFlow = credentialStore.accountFlow.map { it.highlightScope() },
onChanged = { SyncEnqueuer.enqueue(appContext) },
)
val syncStateDao = db.syncStateDao()
val readiumFactory = ReadiumFactory(appContext)
val readerPreferencesStore = ReaderPreferencesStore(appContext)
Expand All @@ -117,6 +124,10 @@ class AppContainer(context: Context) {
progressDao = db.progressDao(),
documentRepo = documentRepository,
syncState = syncStateDao,
highlights = HighlightSyncer(
syncClient, db.highlightSyncDao(), syncStateDao,
sessionProvider = { credentialStore.getAccount().highlightSession() },
),
)

val aiClient: AiClient = AiClient(
Expand Down
36 changes: 36 additions & 0 deletions app/src/main/java/io/theficos/ereader/di/HighlightAccountScope.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
package io.theficos.ereader.di

import io.theficos.ereader.auth.AccountCredentials
import io.theficos.ereader.data.sync.HighlightSyncSession
import java.security.MessageDigest
import java.util.Base64
import okhttp3.HttpUrl.Companion.toHttpUrl

/** Password/token refresh preserves ownership; server, catalog, scheme or subject changes do not. */
internal fun AccountCredentials?.highlightScope(): String {
val account = this ?: return ""
val server = account.quireServerUrlOrNull()
val parts = listOf(
account.scheme.name,
account.baseUrl.toHttpUrl().toString().trimEnd('/'),
server?.toHttpUrl()?.toString()?.trimEnd('/').orEmpty(),
account.subject,
) + if (account is AccountCredentials.OpdsOnly) {
listOf((account.username != null).toString(), account.username.orEmpty())
} else emptyList()
val input = parts.joinToString("") { "${it.length}:$it" }
return MessageDigest.getInstance("SHA-256").digest(input.toByteArray(Charsets.UTF_8))
.joinToString("") { "%02x".format(it) }
}

internal fun AccountCredentials?.highlightSession(): HighlightSyncSession? {
val account = this ?: return null
val base = account.quireServerUrlOrNull() ?: return null
val authorization = when (account) {
is AccountCredentials.Basic -> "Basic " + Base64.getEncoder()
.encodeToString("${account.username}:${account.password}".toByteArray(Charsets.UTF_8))
is AccountCredentials.Bearer -> "Bearer ${account.token}"
is AccountCredentials.OpdsOnly -> return null
}
return HighlightSyncSession(account.highlightScope(), base, authorization)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
package io.theficos.ereader.di

import com.google.common.truth.Truth.assertThat
import io.theficos.ereader.auth.AccountCredentials
import org.junit.Test

class HighlightAccountScopeTest {
private val basic = AccountCredentials.Basic("https://catalog.example", "Reader", "secret", "https://sync.example")

@Test fun `scope separates subjects servers catalogs and schemes while preserving reauthentication`() {
val original = basic.highlightScope()
assertThat(original).hasLength(64)
assertThat(basic.copy(password = "new password").highlightScope()).isEqualTo(original) // pragma: allowlist secret - dummy test credential
assertThat(basic.copy(username = "reader").highlightScope()).isEqualTo(original)
assertThat(basic.copy(baseUrl = "https://catalog.example/").highlightScope()).isEqualTo(original)
assertThat(basic.copy(username = "other").highlightScope()).isNotEqualTo(original)
assertThat(basic.copy(quireServerUrl = "https://other.example").highlightScope()).isNotEqualTo(original)
assertThat(basic.copy(baseUrl = "https://other-catalog.example").highlightScope()).isNotEqualTo(original)
val bearer = AccountCredentials.Bearer("https://sync.example", "reader", "token")
assertThat(bearer.highlightScope()).isNotEqualTo(original)
assertThat(bearer.copy(token = "new token").highlightScope()).isEqualTo(bearer.highlightScope())
}

@Test fun `generic OPDS gets a local account scope and logged out quotes remain unowned`() {
val opds = AccountCredentials.OpdsOnly("https://catalog.example/opds?api_key=secret")
assertThat(opds.highlightScope()).hasLength(64)
assertThat(opds.highlightScope()).isNotEqualTo(basic.highlightScope())
assertThat(opds.highlightScope()).isNotEqualTo(opds.copy(baseUrl = "https://other.example/opds").highlightScope())
assertThat(opds.highlightSession()).isNull()
val authenticated = opds.copy(username = "reader", password = "secret")
assertThat(authenticated.highlightScope()).isNotEqualTo(opds.highlightScope())
assertThat(authenticated.copy(username = "other").highlightScope()).isNotEqualTo(authenticated.highlightScope())
assertThat(authenticated.copy(password = "new secret").highlightScope()).isEqualTo(authenticated.highlightScope()) // pragma: allowlist secret - dummy test credential
assertThat((null as AccountCredentials?).highlightScope()).isEmpty()
assertThat((null as AccountCredentials?).highlightSession()).isNull()
}

@Test fun `session freezes split server destination and authorization without logging credentials`() {
val session = basic.highlightSession()!!
assertThat(session.baseUrl).isEqualTo("https://sync.example")
assertThat(session.authorization).isEqualTo("Basic UmVhZGVyOnNlY3JldA==")
assertThat(session.toString()).doesNotContain("secret")
assertThat(session.toString()).doesNotContain(session.authorization)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -149,11 +149,11 @@ class ReaderHighlightNavigationTest {
private class Rows : HighlightDao {
private val rows = MutableStateFlow<List<HighlightEntity>>(emptyList())
override suspend fun insert(highlight: HighlightEntity) { rows.value += highlight }
override suspend fun findById(id: String) = rows.value.firstOrNull { it.id == id }
override fun observeActive(metadataId: String?, contentHash: String?): Flow<List<HighlightEntity>> = rows.map { all ->
override suspend fun findById(id: String, ownerScope: String) = rows.value.firstOrNull { it.id == id }
override fun observeActive(metadataId: String?, contentHash: String?, ownerScope: String): Flow<List<HighlightEntity>> = rows.map { all ->
all.filter { it.deletedAt == null && it.contentHash == contentHash }
}
override suspend fun setColour(id: String, colour: String, now: Long) {}
override suspend fun softDelete(id: String, now: Long) {}
override suspend fun setColour(id: String, colour: String, now: Long, ownerScope: String) {}
override suspend fun softDelete(id: String, now: Long, ownerScope: String) {}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -186,18 +186,18 @@ class ReaderHighlightsTest {
if (failWrites) throw IOException("test write failure")
rows.value = rows.value + highlight
}
override suspend fun findById(id: String) = rows.value.firstOrNull { it.id == id }
override fun observeActive(metadataId: String?, contentHash: String?): Flow<List<HighlightEntity>> = if (failLoads) flow {
override suspend fun findById(id: String, ownerScope: String) = rows.value.firstOrNull { it.id == id }
override fun observeActive(metadataId: String?, contentHash: String?, ownerScope: String): Flow<List<HighlightEntity>> = if (failLoads) flow {
throw IOException("test load failure")
} else rows.map { all ->
all.filter { it.deletedAt == null && ((metadataId != null && it.metadataId == metadataId) ||
(contentHash != null && it.contentHash == contentHash)) }
}
override suspend fun setColour(id: String, colour: String, now: Long) {
override suspend fun setColour(id: String, colour: String, now: Long, ownerScope: String) {
if (failWrites) throw IOException("test write failure")
rows.value = rows.value.map { if (it.id == id) it.copy(colour = colour, updatedAt = now) else it }
}
override suspend fun softDelete(id: String, now: Long) {
override suspend fun softDelete(id: String, now: Long, ownerScope: String) {
if (failWrites) throw IOException("test write failure")
rows.value = rows.value.map { if (it.id == id) it.copy(deletedAt = now, updatedAt = now) else it }
}
Expand Down
Loading
Loading