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
41 changes: 41 additions & 0 deletions app-keymanager/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
plugins {
alias(libs.plugins.kotlin.multiplatform)
alias(libs.plugins.android.kotlin.multiplatform.library)
alias(libs.plugins.kotlin.serialization)
alias(libs.plugins.compose.multiplatform)
alias(libs.plugins.kotlin.compose)
}

kotlin {
androidLibrary {
namespace = "com.codebutler.farebot.app.keymanager"
compileSdk =
libs.versions.compileSdk
.get()
.toInt()
minSdk =
libs.versions.minSdk
.get()
.toInt()
}

// NO iOS targets

sourceSets {
commonMain.dependencies {
implementation(libs.compose.resources)
implementation(libs.compose.runtime)
implementation(compose.foundation)
implementation(compose.material3)
implementation(compose.materialIconsExtended)
implementation(libs.navigation.compose)
implementation(libs.lifecycle.viewmodel.compose)
implementation(libs.kotlinx.coroutines.core)
implementation(libs.kotlinx.serialization.json)
implementation(project(":base"))
implementation(project(":card"))
implementation(project(":card:classic"))
implementation(project(":keymanager"))
}
}
}
23 changes: 23 additions & 0 deletions app-keymanager/src/commonMain/composeResources/values/strings.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="add_key">Add Key</string>
<string name="back">Back</string>
<string name="cancel">Cancel</string>
<string name="card_id">Card ID</string>
<string name="card_type">Card Type</string>
<string name="delete">Delete</string>
<string name="delete_selected_keys">Delete %1$d selected keys?</string>
<string name="enter_manually">Enter manually</string>
<string name="hold_nfc_card">Hold your NFC card against the device to detect its ID and type.</string>
<string name="import_file_button">Import File</string>
<string name="key_data">Key Data</string>
<string name="keys">Keys</string>
<string name="keys_loaded">Keys are built in.</string>
<string name="keys_required">Encryption keys are required to read this card.</string>
<string name="locked_card">Locked Card</string>
<string name="n_selected">%1$d selected</string>
<string name="nfc">NFC</string>
<string name="no_keys">No keys added yet.</string>
<string name="select_all">Select all</string>
<string name="tap_your_card">Tap your card</string>
</resources>
Original file line number Diff line number Diff line change
@@ -0,0 +1,202 @@
/*
* KeyManagerPluginImpl.kt
*
* This file is part of FareBot.
* Learn more at: https://codebutler.github.io/farebot/
*
* Copyright (C) 2026 Eric Butler <eric@codebutler.com>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/

package com.codebutler.farebot.app.keymanager

import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.NavGraphBuilder
import androidx.navigation.NavHostController
import androidx.navigation.NavType
import androidx.navigation.compose.composable
import androidx.navigation.navArgument
import androidx.savedstate.read
import com.codebutler.farebot.app.keymanager.ui.AddKeyScreen
import com.codebutler.farebot.app.keymanager.ui.KeysScreen
import com.codebutler.farebot.app.keymanager.viewmodel.AddKeyViewModel
import com.codebutler.farebot.app.keymanager.viewmodel.KeysViewModel
import com.codebutler.farebot.card.CardType
import com.codebutler.farebot.card.classic.ClassicKeyRecovery
import com.codebutler.farebot.card.classic.key.ClassicCardKeys
import com.codebutler.farebot.keymanager.NestedAttackKeyRecovery
import com.codebutler.farebot.persist.CardKeysPersister
import com.codebutler.farebot.shared.nfc.CardScanner
import farebot.app_keymanager.generated.resources.Res
import farebot.app_keymanager.generated.resources.add_key
import farebot.app_keymanager.generated.resources.keys
import farebot.app_keymanager.generated.resources.keys_loaded
import farebot.app_keymanager.generated.resources.keys_required
import farebot.app_keymanager.generated.resources.locked_card
import kotlinx.serialization.json.Json
import org.jetbrains.compose.resources.StringResource

/**
* Provides all key management functionality.
*
* This class does NOT implement [com.codebutler.farebot.shared.plugin.KeyManagerPlugin]
* directly to avoid a circular dependency (`:app-keymanager` cannot depend on `:app`).
* Platform AppGraphs wrap this as a [KeyManagerPlugin] adapter.
*/
class KeyManagerPluginImpl(
private val cardKeysPersister: CardKeysPersister,
private val json: Json,
) {
val classicKeyRecovery: ClassicKeyRecovery = NestedAttackKeyRecovery()

fun navigateToKeys(navController: NavHostController) {
navController.navigate(KEYS_ROUTE)
}

fun navigateToAddKey(
navController: NavHostController,
tagId: String? = null,
cardType: CardType? = null,
) {
navController.navigate(buildAddKeyRoute(tagId, cardType))
}

fun NavGraphBuilder.registerKeyRoutes(
navController: NavHostController,
cardKeysPersister: CardKeysPersister,
cardScanner: CardScanner?,
onPickFile: ((ByteArray?) -> Unit) -> Unit,
) {
composable(KEYS_ROUTE) {
val keysViewModel = viewModel { KeysViewModel(cardKeysPersister) }
val uiState by keysViewModel.uiState.collectAsState()

LaunchedEffect(Unit) {
keysViewModel.loadKeys()
}

KeysScreen(
uiState = uiState,
onBack = { navController.popBackStack() },
onNavigateToAddKey = { navController.navigate(buildAddKeyRoute()) },
onDeleteKey = { keyId -> keysViewModel.deleteKey(keyId) },
onToggleSelection = { keyId -> keysViewModel.toggleSelection(keyId) },
onClearSelection = { keysViewModel.clearSelection() },
onSelectAll = { keysViewModel.selectAll() },
onDeleteSelected = { keysViewModel.deleteSelected() },
)
}

composable(
route = ADD_KEY_ROUTE,
arguments =
listOf(
navArgument("tagId") {
type = NavType.StringType
nullable = true
defaultValue = null
},
navArgument("cardType") {
type = NavType.StringType
nullable = true
defaultValue = null
},
),
) { backStackEntry ->
val addKeyViewModel = viewModel { AddKeyViewModel(cardKeysPersister, cardScanner) }
val uiState by addKeyViewModel.uiState.collectAsState()

val prefillTagId = backStackEntry.arguments?.read { getStringOrNull("tagId") }
val prefillCardTypeName = backStackEntry.arguments?.read { getStringOrNull("cardType") }

LaunchedEffect(prefillTagId, prefillCardTypeName) {
if (prefillTagId != null && prefillCardTypeName != null) {
val ct = CardType.entries.firstOrNull { it.name == prefillCardTypeName }
if (ct != null) {
addKeyViewModel.prefillCardData(prefillTagId, ct)
}
}
}

LaunchedEffect(Unit) {
addKeyViewModel.startObservingTags()
}

LaunchedEffect(Unit) {
addKeyViewModel.keySaved.collect {
navController.popBackStack()
}
}

AddKeyScreen(
uiState = uiState,
onBack = { navController.popBackStack() },
onSaveKey = { cardId, ct, keyData ->
addKeyViewModel.saveKey(cardId, ct, keyData)
},
onEnterManually = { addKeyViewModel.enterManualMode() },
onImportFile = {
onPickFile { bytes ->
if (bytes != null) {
addKeyViewModel.importKeyFile(bytes)
}
}
},
)
}
}

fun getCardKeysForTag(tagId: String): ClassicCardKeys? {
val savedKey = cardKeysPersister.getForTagId(tagId) ?: return null
return when (savedKey.cardType) {
CardType.MifareClassic -> json.decodeFromString(ClassicCardKeys.serializer(), savedKey.keyData)
else -> null
}
}

fun getGlobalKeys(): List<ByteArray> =
try {
cardKeysPersister.getGlobalKeys()
} catch (e: Exception) {
println("[KeyManager] Failed to load global keys: ${e.message}")
emptyList()
}

val lockedCardTitle: StringResource get() = Res.string.locked_card
val keysRequiredMessage: StringResource get() = Res.string.keys_required
val addKeyLabel: StringResource get() = Res.string.add_key
val keysLabel: StringResource get() = Res.string.keys
val keysLoadedLabel: StringResource get() = Res.string.keys_loaded

companion object {
private const val KEYS_ROUTE = "keys"
private const val ADD_KEY_ROUTE = "add_key?tagId={tagId}&cardType={cardType}"

private fun buildAddKeyRoute(
tagId: String? = null,
cardType: CardType? = null,
): String =
buildString {
append("add_key")
val params = mutableListOf<String>()
if (tagId != null) params.add("tagId=$tagId")
if (cardType != null) params.add("cardType=${cardType.name}")
if (params.isNotEmpty()) append("?${params.joinToString("&")}")
}
}
}
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
package com.codebutler.farebot.shared.ui.screen
package com.codebutler.farebot.app.keymanager.ui

import androidx.compose.animation.Crossfade
import androidx.compose.foundation.layout.Arrangement
Expand Down Expand Up @@ -35,18 +35,17 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.codebutler.farebot.card.CardType
import com.codebutler.farebot.shared.ui.layout.ContentWidthConstraint
import farebot.app.generated.resources.Res
import farebot.app.generated.resources.add_key
import farebot.app.generated.resources.back
import farebot.app.generated.resources.card_id
import farebot.app.generated.resources.card_type
import farebot.app.generated.resources.enter_manually
import farebot.app.generated.resources.hold_nfc_card
import farebot.app.generated.resources.import_file_button
import farebot.app.generated.resources.key_data
import farebot.app.generated.resources.nfc
import farebot.app.generated.resources.tap_your_card
import farebot.app_keymanager.generated.resources.Res
import farebot.app_keymanager.generated.resources.add_key
import farebot.app_keymanager.generated.resources.back
import farebot.app_keymanager.generated.resources.card_id
import farebot.app_keymanager.generated.resources.card_type
import farebot.app_keymanager.generated.resources.enter_manually
import farebot.app_keymanager.generated.resources.hold_nfc_card
import farebot.app_keymanager.generated.resources.import_file_button
import farebot.app_keymanager.generated.resources.key_data
import farebot.app_keymanager.generated.resources.nfc
import farebot.app_keymanager.generated.resources.tap_your_card
import org.jetbrains.compose.resources.stringResource

data class AddKeyUiState(
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
package com.codebutler.farebot.app.keymanager.ui

import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.widthIn
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp

@Composable
fun ContentWidthConstraint(
maxWidth: Dp = 640.dp,
content: @Composable () -> Unit,
) {
Box(
modifier = Modifier.fillMaxWidth(),
contentAlignment = Alignment.TopCenter,
) {
Box(modifier = Modifier.widthIn(max = maxWidth).fillMaxWidth()) {
content()
}
}
}
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
package com.codebutler.farebot.shared.ui.screen
package com.codebutler.farebot.app.keymanager.ui

import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.combinedClickable
Expand Down Expand Up @@ -39,17 +39,16 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.codebutler.farebot.shared.ui.layout.ContentWidthConstraint
import farebot.app.generated.resources.Res
import farebot.app.generated.resources.add_key
import farebot.app.generated.resources.back
import farebot.app.generated.resources.cancel
import farebot.app.generated.resources.delete
import farebot.app.generated.resources.delete_selected_keys
import farebot.app.generated.resources.keys
import farebot.app.generated.resources.n_selected
import farebot.app.generated.resources.no_keys
import farebot.app.generated.resources.select_all
import farebot.app_keymanager.generated.resources.Res
import farebot.app_keymanager.generated.resources.add_key
import farebot.app_keymanager.generated.resources.back
import farebot.app_keymanager.generated.resources.cancel
import farebot.app_keymanager.generated.resources.delete
import farebot.app_keymanager.generated.resources.delete_selected_keys
import farebot.app_keymanager.generated.resources.keys
import farebot.app_keymanager.generated.resources.n_selected
import farebot.app_keymanager.generated.resources.no_keys
import farebot.app_keymanager.generated.resources.select_all
import org.jetbrains.compose.resources.stringResource

@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
package com.codebutler.farebot.shared.ui.screen
package com.codebutler.farebot.app.keymanager.ui

data class KeysUiState(
val isLoading: Boolean = true,
Expand Down
Loading
Loading