Skip to content

Commit 05e92ca

Browse files
author
Josh
committed
progress towards modularizing the applicaton
1 parent b4ed9d9 commit 05e92ca

39 files changed

Lines changed: 696 additions & 38 deletions

File tree

app/src/main/AndroidManifest.xml

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,9 @@
5050
android:name=".MainActivity"
5151
android:exported="true"
5252
android:theme="@style/Theme.OpenLetters.Starting">
53+
<meta-data
54+
android:name="android.app.shortcuts"
55+
android:resource="@xml/shortcuts" />
5356
<intent-filter>
5457
<action android:name="android.intent.action.MAIN" />
5558
<category android:name="android.intent.category.LAUNCHER" />
@@ -59,8 +62,7 @@
5962
<category android:name="android.intent.category.DEFAULT" />
6063
<category android:name="android.intent.category.BROWSABLE" />
6164
<data android:scheme="openletters"
62-
android:host="net.frozendevelopment.openletters"
63-
android:pathPrefix="/reminder" />
65+
android:host="net.frozendevelopment.openletters" />
6466
</intent-filter>
6567
</activity>
6668
</application>

app/src/main/java/net/frozendevelopment/openletters/MainActivity.kt

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,16 +5,31 @@ import androidx.activity.ComponentActivity
55
import androidx.activity.compose.setContent
66
import androidx.activity.enableEdgeToEdge
77
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
8+
import net.frozendevelopment.core.architecture.navigation.DeeplinkResolver
9+
import net.frozendevelopment.feature.letters.api.LetterListDestination
10+
import net.frozendevelopment.feature.letters.api.ScanLetterDestination
11+
import net.frozendevelopment.feature.reminders.api.ReminderDetailDestination
12+
import net.frozendevelopment.feature.reminders.api.ReminderFormDestination
13+
import net.frozendevelopment.feature.reminders.api.ReminderListDestination
814
import net.frozendevelopment.openletters.ui.OpenLettersApp
915

1016
class MainActivity : ComponentActivity() {
17+
18+
private val deeplinkResolver = DeeplinkResolver(
19+
ScanLetterDestination.deeplink,
20+
ReminderListDestination.deeplink,
21+
ReminderDetailDestination.deeplink,
22+
ReminderFormDestination.deeplink
23+
)
24+
1125
override fun onCreate(savedInstanceState: Bundle?) {
1226
installSplashScreen()
1327
super.onCreate(savedInstanceState)
1428
enableEdgeToEdge()
1529

1630
setContent {
1731
OpenLettersApp(
32+
start = intent.data?.let { deeplinkResolver.resolve(it) } ?: LetterListDestination,
1833
activity = this,
1934
onBackPressedDispatcher = onBackPressedDispatcher,
2035
)

app/src/main/java/net/frozendevelopment/openletters/ui/OpenLettersApp.kt

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@ import org.koin.core.annotation.KoinExperimentalAPI
5858
@OptIn(ExperimentalMaterial3AdaptiveApi::class, KoinExperimentalAPI::class)
5959
@Composable
6060
internal fun OpenLettersApp(
61+
start: NavKey,
6162
activity: Activity,
6263
onBackPressedDispatcher: OnBackPressedDispatcher,
6364
themeManager: ThemeManagerType = koinInject(),
@@ -67,7 +68,7 @@ internal fun OpenLettersApp(
6768
val coroutineScope = rememberCoroutineScope()
6869
val drawerState = rememberDrawerState(DrawerValue.Closed)
6970
val navigationState = rememberNavigationState(
70-
LetterListDestination,
71+
start,
7172
setOf(
7273
LetterListDestination,
7374
ManageCategoryDestination,

app/src/main/res/values/strings.xml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,7 @@
6868
<string name="no_letters_yet">No Letters Yet</string>
6969
<string name="scan_your_first">Scan your first bill, receipt, or birthday card to begin your organized and paper-free life.</string>
7070
<string name="scan_letter">Scan Letter</string>
71+
<string name="view_reminders">View Reminders</string>
7172
<string name="tap_to_type_your_label">Tap to type your label</string>
7273
<string name="advertisement">Advertisement</string>
7374
<string name="card">Card</string>

app/src/main/res/xml/shortcuts.xml

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
<?xml version="1.0" encoding="utf-8"?>
2+
<shortcuts xmlns:android="http://schemas.android.com/apk/res/android">
3+
<shortcut
4+
android:shortcutId="scan_letter"
5+
android:enabled="true"
6+
android:shortcutShortLabel="@string/scan_letter">
7+
<intent
8+
android:action="android.intent.action.VIEW"
9+
android:targetPackage="net.frozendevelopment.openletters"
10+
android:targetClass="net.frozendevelopment.openletters.MainActivity"
11+
android:data="openletters://net.frozendevelopment.openletters/scan" />
12+
</shortcut>
13+
<shortcut
14+
android:shortcutId="create_reminder"
15+
android:enabled="true"
16+
android:shortcutShortLabel="@string/create_reminder">
17+
<intent
18+
android:action="android.intent.action.VIEW"
19+
android:targetPackage="net.frozendevelopment.openletters"
20+
android:targetClass="net.frozendevelopment.openletters.MainActivity"
21+
android:data="openletters://net.frozendevelopment.openletters/create/reminder" />
22+
</shortcut>
23+
<shortcut
24+
android:shortcutId="view_reminders"
25+
android:enabled="true"
26+
android:shortcutShortLabel="@string/view_reminders">
27+
<intent
28+
android:action="android.intent.action.VIEW"
29+
android:targetPackage="net.frozendevelopment.openletters"
30+
android:targetClass="net.frozendevelopment.openletters.MainActivity"
31+
android:data="openletters://net.frozendevelopment.openletters/reminders" />
32+
</shortcut>
33+
</shortcuts>

build-logic/.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
*/build/

build-logic/convention/src/main/kotlin/OLFeatureLibraryPlugin.kt

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,6 @@ class OLFeatureLibraryPlugin : Plugin<Project> {
1212
add("implementation", libs.findLibrary("androidx-lifecycle-runtime-ktx").get())
1313
add("implementation", libs.findLibrary("androidx-lifecycle-viewmodel").get())
1414
add("implementation", libs.findLibrary("koin-nav3").get())
15-
add("testImplementation", libs.findLibrary("robolectric").get())
16-
add("testImplementation", libs.findLibrary("androidx-test-core").get())
1715
}
1816
}
1917
}

build-logic/convention/src/main/kotlin/OLLibraryPlugin.kt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,8 @@ class OLLibraryPlugin : Plugin<Project> {
5454
add("testImplementation", libs.findLibrary("kotlinx-coroutines-test").get())
5555
add("testImplementation", libs.findLibrary("mockk-android").get())
5656
add("testImplementation", libs.findLibrary("mockk-agent").get())
57+
add("testImplementation", libs.findLibrary("robolectric").get())
58+
add("testImplementation", libs.findLibrary("androidx-test-core").get())
5759
}
5860
}
5961
}
Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,169 @@
11
package net.frozendevelopment.core.architecture.navigation
22

3+
import android.net.Uri
4+
import androidx.navigation3.runtime.NavKey
5+
import kotlinx.serialization.ExperimentalSerializationApi
6+
import kotlinx.serialization.KSerializer
7+
import kotlinx.serialization.descriptors.SerialDescriptor
8+
import kotlinx.serialization.encoding.AbstractDecoder
9+
import kotlinx.serialization.encoding.CompositeDecoder
10+
import kotlinx.serialization.modules.EmptySerializersModule
11+
import kotlinx.serialization.modules.SerializersModule
12+
313
const val DEEP_LINK_URI: String = "openletters://net.frozendevelopment.openletters"
14+
15+
data class Deeplink<T : NavKey>(
16+
val serializer: KSerializer<T>,
17+
val uri: Uri,
18+
)
19+
20+
class DeeplinkResolver(
21+
private val deepLinks: Collection<Deeplink<out NavKey>>,
22+
) {
23+
constructor(vararg deepLinks: Deeplink<out NavKey>) : this(deepLinks.toList())
24+
25+
fun resolve(uri: Uri): NavKey? {
26+
for (deeplink in deepLinks) {
27+
val params = match(deeplink.uri, uri)
28+
if (params != null) {
29+
return try {
30+
val decoder = MapDecoder(params)
31+
deeplink.serializer.deserialize(decoder)
32+
} catch (e: Exception) {
33+
null
34+
}
35+
}
36+
}
37+
return null
38+
}
39+
40+
private fun match(
41+
template: Uri,
42+
actual: Uri,
43+
): Map<String, String>? {
44+
if (template.scheme != actual.scheme) return null
45+
val params = mutableMapOf<String, String>()
46+
47+
if (template.host != actual.host) {
48+
if (template.host != null && template.host!!.startsWith("{") && template.host!!.endsWith("}")) {
49+
val key = template.host!!.substring(1, template.host!!.length - 1)
50+
if (actual.host != null) {
51+
params[key] = actual.host!!
52+
}
53+
} else if (template.host != actual.host) {
54+
return null
55+
}
56+
}
57+
58+
val templatePathSegments = template.pathSegments
59+
val actualPathSegments = actual.pathSegments
60+
61+
var templateIdx = 0
62+
var actualIdx = 0
63+
64+
while (templateIdx < templatePathSegments.size && actualIdx < actualPathSegments.size) {
65+
val templateSegment = templatePathSegments[templateIdx]
66+
val actualSegment = actualPathSegments[actualIdx]
67+
68+
if (templateSegment.startsWith("{") && templateSegment.endsWith("}")) {
69+
val key = templateSegment.substring(1, templateSegment.length - 1)
70+
params[key] = actualSegment
71+
templateIdx++
72+
actualIdx++
73+
} else if (templateSegment == actualSegment) {
74+
templateIdx++
75+
actualIdx++
76+
} else {
77+
return null
78+
}
79+
}
80+
81+
if (actualIdx < actualPathSegments.size) return null
82+
if (templateIdx < templatePathSegments.size) return null
83+
84+
val templateQueryNames = template.queryParameterNames
85+
for (queryName in templateQueryNames) {
86+
val templateValue = template.getQueryParameter(queryName)
87+
if (templateValue != null && templateValue.startsWith("{") && templateValue.endsWith("}")) {
88+
val key = templateValue.substring(1, templateValue.length - 1)
89+
val actualValue = actual.getQueryParameter(queryName)
90+
if (actualValue != null) {
91+
params[key] = actualValue
92+
}
93+
} else if (templateValue != null) {
94+
if (actual.getQueryParameter(queryName) != templateValue) return null
95+
}
96+
}
97+
98+
actual.queryParameterNames.forEach { name ->
99+
if (!params.containsKey(name)) {
100+
val value = actual.getQueryParameter(name)
101+
if (value != null) {
102+
params[name] = value
103+
}
104+
}
105+
}
106+
107+
return params
108+
}
109+
}
110+
111+
@OptIn(ExperimentalSerializationApi::class)
112+
private class MapDecoder(
113+
private val map: Map<String, String>,
114+
) : AbstractDecoder() {
115+
private var index = 0
116+
private lateinit var descriptor: SerialDescriptor
117+
118+
override val serializersModule: SerializersModule = EmptySerializersModule()
119+
120+
override fun decodeElementIndex(descriptor: SerialDescriptor): Int {
121+
this.descriptor = descriptor
122+
while (index < descriptor.elementsCount) {
123+
val name = descriptor.getElementName(index)
124+
if (map.containsKey(name)) {
125+
return index++
126+
}
127+
if (descriptor.isElementOptional(index)) {
128+
index++
129+
continue
130+
}
131+
return index++
132+
}
133+
return CompositeDecoder.DECODE_DONE
134+
}
135+
136+
private fun currentValue(): String? {
137+
val name = descriptor.getElementName(index - 1)
138+
return map[name]
139+
}
140+
141+
override fun decodeString(): String = currentValue() ?: ""
142+
143+
override fun decodeBoolean(): Boolean = currentValue()?.toBoolean() ?: false
144+
145+
override fun decodeByte(): Byte = currentValue()?.toByte() ?: 0
146+
147+
override fun decodeShort(): Short = currentValue()?.toShort() ?: 0
148+
149+
override fun decodeInt(): Int = currentValue()?.toInt() ?: 0
150+
151+
override fun decodeLong(): Long = currentValue()?.toLong() ?: 0L
152+
153+
override fun decodeFloat(): Float = currentValue()?.toFloat() ?: 0.0f
154+
155+
override fun decodeDouble(): Double = currentValue()?.toDouble() ?: 0.0
156+
157+
override fun decodeChar(): Char = currentValue()?.firstOrNull() ?: '\u0000'
158+
159+
override fun decodeEnum(enumDescriptor: SerialDescriptor): Int {
160+
return enumDescriptor.getElementIndex(currentValue() ?: "")
161+
}
162+
163+
override fun decodeNotNullMark(): Boolean {
164+
val name = descriptor.getElementName(index - 1)
165+
return map.containsKey(name) && map[name] != null
166+
}
167+
168+
override fun decodeNull(): Nothing? = null
169+
}

core/architecture/src/main/java/net/frozendevelopment/core/architecture/navigation/NavigationState.kt

Lines changed: 23 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -27,14 +27,13 @@ fun rememberNavigationState(
2727
startRoute: NavKey,
2828
topLevelRoutes: Set<NavKey>,
2929
): NavigationState {
30-
val topLevelRoute =
31-
rememberSerializable(
32-
startRoute,
33-
topLevelRoutes,
34-
serializer = MutableStateSerializer(NavKeySerializer()),
35-
) {
36-
mutableStateOf(startRoute)
37-
}
30+
val topLevelRoute = rememberSerializable(
31+
startRoute,
32+
topLevelRoutes,
33+
serializer = MutableStateSerializer(NavKeySerializer()),
34+
) {
35+
mutableStateOf(startRoute)
36+
}
3837

3938
val backStacks = topLevelRoutes.associateWith { key -> rememberNavBackStack(key) }
4039

@@ -54,29 +53,26 @@ class NavigationState(
5453
) {
5554
var topLevelRoute: NavKey by topLevelRoute
5655
val stacksInUse: List<NavKey>
57-
get() =
58-
if (topLevelRoute == startRoute) {
59-
listOf(startRoute)
60-
} else {
61-
listOf(startRoute, topLevelRoute)
62-
}
56+
get() = if (topLevelRoute == startRoute) {
57+
listOf(startRoute)
58+
} else {
59+
listOf(startRoute, topLevelRoute)
60+
}
6361
}
6462

6563
@Composable
6664
fun NavigationState.toEntries(entryProvider: (NavKey) -> NavEntry<NavKey>): SnapshotStateList<NavEntry<NavKey>> {
67-
val decoratedEntries =
68-
backStacks.mapValues { (_, stack) ->
69-
val decorators =
70-
listOf(
71-
rememberSaveableStateHolderNavEntryDecorator<NavKey>(),
72-
rememberViewModelStoreNavEntryDecorator(),
73-
)
74-
rememberDecoratedNavEntries(
75-
backStack = stack,
76-
entryDecorators = decorators,
77-
entryProvider = entryProvider,
78-
)
79-
}
65+
val decoratedEntries = backStacks.mapValues { (_, stack) ->
66+
val decorators = listOf(
67+
rememberSaveableStateHolderNavEntryDecorator<NavKey>(),
68+
rememberViewModelStoreNavEntryDecorator(),
69+
)
70+
rememberDecoratedNavEntries(
71+
backStack = stack,
72+
entryDecorators = decorators,
73+
entryProvider = entryProvider,
74+
)
75+
}
8076

8177
return stacksInUse
8278
.flatMap { decoratedEntries[it] ?: emptyList() }

0 commit comments

Comments
 (0)