Skip to content

Commit 004cb13

Browse files
authored
Merge pull request #157 from AnySoftKeyboard/feature/colophon-about-licenses
[LLM] feat: Implement 'About' Section via Bottom Tab & License Collection
2 parents 2da3477 + 86ec610 commit 004cb13

19 files changed

Lines changed: 444 additions & 26 deletions

File tree

app/build.gradle.kts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ plugins {
55
id("dagger.hilt.android.plugin")
66
alias(libs.plugins.compose.compiler)
77
alias(libs.plugins.dropshots)
8+
id("com.google.android.gms.oss-licenses-plugin")
89
}
910

1011
android {
@@ -49,7 +50,10 @@ android {
4950
create("foss") { dimension = "store" }
5051
}
5152
kotlin { jvmToolchain(21) }
52-
buildFeatures { compose = true }
53+
buildFeatures {
54+
compose = true
55+
buildConfig = true
56+
}
5357
configurations.all {
5458
resolutionStrategy.force("org.jetbrains.kotlin:kotlin-metadata-jvm:2.3.0")
5559
exclude(group = "com.google.guava", module = "listenablefuture")
@@ -76,6 +80,7 @@ dependencies {
7680
implementation(project(":database"))
7781
implementation(project(":network"))
7882
implementation(libs.androidx.core.splashscreen)
83+
implementation(libs.play.services.oss.licenses)
7984
implementation(libs.androidx.core.ktx)
8085
implementation(libs.androidx.appcompat)
8186
implementation(libs.com.google.android.material)

app/src/androidTest/java/com/anysoftkeyboard/janus/app/ScreenshotGenerator.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,7 @@ class ScreenshotGenerator {
7575
composeTestRule.onNodeWithTag("source_lang_selector").performClick()
7676
composeTestRule
7777
.onNodeWithTag("language_list")
78-
.performScrollToNode(androidx.compose.ui.test.hasTestTag("language_menu_item_auto"))
78+
.performScrollToNode(hasTestTag("language_menu_item_auto"))
7979
Thread.sleep(500)
8080
dropshots.assertSnapshot(name = "b_1_auto_detect_option")
8181

app/src/main/java/com/anysoftkeyboard/janus/app/MainActivity.kt

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import androidx.compose.foundation.layout.padding
88
import androidx.compose.material.icons.Icons
99
import androidx.compose.material.icons.filled.Favorite
1010
import androidx.compose.material.icons.filled.History
11+
import androidx.compose.material.icons.filled.Info
1112
import androidx.compose.material.icons.filled.Translate
1213
import androidx.compose.material3.Icon
1314
import androidx.compose.material3.NavigationBar
@@ -28,6 +29,7 @@ import androidx.hilt.navigation.compose.hiltViewModel
2829
import androidx.navigation.compose.NavHost
2930
import androidx.navigation.compose.composable
3031
import androidx.navigation.compose.rememberNavController
32+
import com.anysoftkeyboard.janus.app.ui.AboutScreen
3133
import com.anysoftkeyboard.janus.app.ui.BookmarksScreen
3234
import com.anysoftkeyboard.janus.app.ui.HistoryScreen
3335
import com.anysoftkeyboard.janus.app.ui.TranslateScreen
@@ -91,6 +93,7 @@ fun JanusApp(initialText: String? = null) {
9193
composable(TabScreen.Translate.route) { TranslateScreen(hiltViewModel(), initialText) }
9294
composable(TabScreen.History.route) { HistoryScreen(hiltViewModel()) }
9395
composable(TabScreen.Bookmarks.route) { BookmarksScreen(hiltViewModel()) }
96+
composable(TabScreen.About.route) { AboutScreen() }
9497
}
9598
}
9699
}
@@ -103,6 +106,7 @@ enum class TabScreen(
103106
Translate("translate", R.string.tab_translate, Icons.Default.Translate),
104107
History("history", R.string.tab_history, Icons.Default.History),
105108
Bookmarks("bookmarks", R.string.tab_bookmarks, Icons.Default.Favorite),
109+
About("about", R.string.tab_about, Icons.Default.Info),
106110
}
107111

108112
@Preview(showBackground = true)
Lines changed: 294 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,294 @@
1+
package com.anysoftkeyboard.janus.app.ui
2+
3+
import android.content.Context
4+
import android.net.Uri
5+
import androidx.compose.foundation.BorderStroke
6+
import androidx.compose.foundation.clickable
7+
import androidx.compose.foundation.layout.Arrangement
8+
import androidx.compose.foundation.layout.Box
9+
import androidx.compose.foundation.layout.Column
10+
import androidx.compose.foundation.layout.Row
11+
import androidx.compose.foundation.layout.Spacer
12+
import androidx.compose.foundation.layout.fillMaxSize
13+
import androidx.compose.foundation.layout.fillMaxWidth
14+
import androidx.compose.foundation.layout.height
15+
import androidx.compose.foundation.layout.padding
16+
import androidx.compose.foundation.layout.size
17+
import androidx.compose.foundation.layout.statusBarsPadding
18+
import androidx.compose.foundation.layout.width
19+
import androidx.compose.foundation.lazy.LazyColumn
20+
import androidx.compose.foundation.lazy.items
21+
import androidx.compose.foundation.rememberScrollState
22+
import androidx.compose.foundation.verticalScroll
23+
import androidx.compose.material.icons.Icons
24+
import androidx.compose.material.icons.automirrored.filled.ArrowBack
25+
import androidx.compose.material.icons.automirrored.filled.ArrowForward
26+
import androidx.compose.material3.ButtonDefaults
27+
import androidx.compose.material3.Card
28+
import androidx.compose.material3.CardDefaults
29+
import androidx.compose.material3.Icon
30+
import androidx.compose.material3.IconButton
31+
import androidx.compose.material3.MaterialTheme
32+
import androidx.compose.material3.OutlinedButton
33+
import androidx.compose.material3.Scaffold
34+
import androidx.compose.material3.Text
35+
import androidx.compose.runtime.Composable
36+
import androidx.compose.runtime.getValue
37+
import androidx.compose.runtime.mutableStateOf
38+
import androidx.compose.runtime.remember
39+
import androidx.compose.runtime.setValue
40+
import androidx.compose.ui.Alignment
41+
import androidx.compose.ui.Modifier
42+
import androidx.compose.ui.platform.LocalContext
43+
import androidx.compose.ui.res.painterResource
44+
import androidx.compose.ui.res.stringResource
45+
import androidx.compose.ui.text.font.FontFamily
46+
import androidx.compose.ui.text.style.TextAlign
47+
import androidx.compose.ui.unit.dp
48+
import com.anysoftkeyboard.janus.app.BuildConfig
49+
import com.anysoftkeyboard.janus.app.R
50+
51+
data class LicenseItem(val name: String, val licenseText: String)
52+
53+
private fun loadLicenses(context: Context): List<LicenseItem> {
54+
val licenses = mutableListOf<LicenseItem>()
55+
try {
56+
val metadataId =
57+
context.resources.getIdentifier("third_party_license_metadata", "raw", context.packageName)
58+
val licensesId =
59+
context.resources.getIdentifier("third_party_licenses", "raw", context.packageName)
60+
61+
if (metadataId != 0 && licensesId != 0) {
62+
val metadataBytes = context.resources.openRawResource(metadataId).use { it.readBytes() }
63+
val metadataString = String(metadataBytes, Charsets.UTF_8)
64+
65+
val licensesBytes = context.resources.openRawResource(licensesId).use { it.readBytes() }
66+
67+
metadataString.lineSequence().forEach { line ->
68+
if (line.isBlank()) return@forEach
69+
val parts = line.split(" ", limit = 2)
70+
if (parts.size == 2) {
71+
val range = parts[0].split(":")
72+
if (range.size == 2) {
73+
val offset = range[0].toIntOrNull() ?: 0
74+
val length = range[1].toIntOrNull() ?: 0
75+
val name = parts[1]
76+
77+
if (offset >= 0 && offset + length <= licensesBytes.size) {
78+
val text = String(licensesBytes, offset, length, Charsets.UTF_8)
79+
licenses.add(LicenseItem(name, text))
80+
}
81+
}
82+
}
83+
}
84+
}
85+
} catch (e: Exception) {
86+
e.printStackTrace()
87+
}
88+
return licenses
89+
}
90+
91+
/**
92+
* About screen displaying application information, explanation of the unique translation mechanism
93+
* (The Colophon), links to GitHub repository and issue tracker, and the open-source licenses
94+
* attribution page.
95+
*/
96+
@Composable
97+
fun AboutScreen(modifier: Modifier = Modifier) {
98+
val context = LocalContext.current
99+
val scrollState = rememberScrollState()
100+
var showLicenses by remember { mutableStateOf(false) }
101+
102+
if (showLicenses) {
103+
Scaffold(
104+
modifier = modifier.statusBarsPadding(),
105+
topBar = {
106+
Row(
107+
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 16.dp),
108+
verticalAlignment = Alignment.CenterVertically,
109+
) {
110+
IconButton(onClick = { showLicenses = false }) {
111+
Icon(
112+
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
113+
contentDescription = "Back",
114+
)
115+
}
116+
Spacer(modifier = Modifier.width(8.dp))
117+
Text(
118+
text = stringResource(R.string.about_button_licenses),
119+
style = MaterialTheme.typography.titleLarge,
120+
color = MaterialTheme.colorScheme.onSurface,
121+
)
122+
}
123+
},
124+
) { paddingValues ->
125+
val licenses = remember { loadLicenses(context) }
126+
127+
if (licenses.isEmpty()) {
128+
Box(
129+
modifier = Modifier.fillMaxSize().padding(paddingValues),
130+
contentAlignment = Alignment.Center,
131+
) {
132+
Text(
133+
text = "No licenses found",
134+
style = MaterialTheme.typography.bodyLarge,
135+
color = MaterialTheme.colorScheme.onSurfaceVariant,
136+
)
137+
}
138+
} else {
139+
LazyColumn(
140+
modifier = Modifier.fillMaxSize().padding(paddingValues).padding(horizontal = 16.dp),
141+
verticalArrangement = Arrangement.spacedBy(16.dp),
142+
) {
143+
items(licenses) { license ->
144+
Card(
145+
modifier = Modifier.fillMaxWidth(),
146+
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant),
147+
colors =
148+
CardDefaults.cardColors(
149+
containerColor = MaterialTheme.colorScheme.surfaceVariant
150+
),
151+
) {
152+
Column(modifier = Modifier.padding(16.dp)) {
153+
Text(
154+
text = license.name,
155+
style = MaterialTheme.typography.titleMedium,
156+
color = MaterialTheme.colorScheme.primary,
157+
)
158+
Spacer(modifier = Modifier.height(8.dp))
159+
Text(
160+
text = license.licenseText,
161+
style = MaterialTheme.typography.bodySmall,
162+
color = MaterialTheme.colorScheme.onSurfaceVariant,
163+
fontFamily = FontFamily.Monospace,
164+
)
165+
}
166+
}
167+
}
168+
}
169+
}
170+
}
171+
} else {
172+
Scaffold(modifier = modifier.statusBarsPadding()) { paddingValues ->
173+
Column(
174+
modifier =
175+
Modifier.fillMaxSize()
176+
.verticalScroll(scrollState)
177+
.padding(paddingValues)
178+
.padding(horizontal = 24.dp, vertical = 24.dp),
179+
horizontalAlignment = Alignment.CenterHorizontally,
180+
) {
181+
// 1. Header
182+
Icon(
183+
painter = painterResource(R.mipmap.ic_launcher_foreground),
184+
contentDescription = null,
185+
modifier = Modifier.size(96.dp),
186+
tint = MaterialTheme.colorScheme.primary,
187+
)
188+
Spacer(modifier = Modifier.height(12.dp))
189+
Text(
190+
text = stringResource(R.string.about_title),
191+
style = MaterialTheme.typography.headlineMedium,
192+
color = MaterialTheme.colorScheme.onSurface,
193+
)
194+
Spacer(modifier = Modifier.height(4.dp))
195+
Text(
196+
text = stringResource(R.string.about_version, BuildConfig.VERSION_NAME),
197+
style = MaterialTheme.typography.labelLarge,
198+
color = MaterialTheme.colorScheme.secondary,
199+
)
200+
Spacer(modifier = Modifier.height(28.dp))
201+
202+
// 2. Abstract
203+
Text(
204+
text = stringResource(R.string.about_abstract),
205+
style = MaterialTheme.typography.bodyLarge,
206+
color = MaterialTheme.colorScheme.onSurface,
207+
textAlign = TextAlign.Center,
208+
)
209+
Spacer(modifier = Modifier.height(28.dp))
210+
211+
// 3. Methodology
212+
Text(
213+
text = stringResource(R.string.about_methodology_title),
214+
style = MaterialTheme.typography.titleMedium,
215+
color = MaterialTheme.colorScheme.primary,
216+
modifier = Modifier.align(Alignment.Start),
217+
)
218+
Spacer(modifier = Modifier.height(8.dp))
219+
Text(
220+
text = stringResource(R.string.about_methodology_text),
221+
style = MaterialTheme.typography.bodyMedium,
222+
color = MaterialTheme.colorScheme.onSurface,
223+
modifier = Modifier.align(Alignment.Start),
224+
)
225+
Spacer(modifier = Modifier.height(28.dp))
226+
227+
// 4. References
228+
ReferenceLinkRow(
229+
label = stringResource(R.string.about_link_source_code),
230+
url = "https://github.com/AnySoftKeyboard/janus",
231+
context = context,
232+
)
233+
Spacer(modifier = Modifier.height(4.dp))
234+
ReferenceLinkRow(
235+
label = stringResource(R.string.about_link_issue_tracker),
236+
url = "https://github.com/AnySoftKeyboard/janus/issues",
237+
context = context,
238+
)
239+
Spacer(modifier = Modifier.height(28.dp))
240+
241+
// 5. Attributions
242+
OutlinedButton(
243+
onClick = { showLicenses = true },
244+
colors =
245+
ButtonDefaults.outlinedButtonColors(
246+
contentColor = MaterialTheme.colorScheme.secondary
247+
),
248+
border = BorderStroke(1.dp, MaterialTheme.colorScheme.secondary),
249+
modifier = Modifier.fillMaxWidth(),
250+
) {
251+
Text(
252+
text = stringResource(R.string.about_button_licenses),
253+
style = MaterialTheme.typography.labelLarge,
254+
)
255+
}
256+
Spacer(modifier = Modifier.height(16.dp))
257+
}
258+
}
259+
}
260+
}
261+
262+
@Composable
263+
private fun ReferenceLinkRow(
264+
label: String,
265+
url: String,
266+
context: Context,
267+
modifier: Modifier = Modifier,
268+
) {
269+
Row(
270+
modifier =
271+
modifier
272+
.fillMaxWidth()
273+
.clickable {
274+
val intent =
275+
android.content.Intent(android.content.Intent.ACTION_VIEW, Uri.parse(url))
276+
context.startActivity(intent)
277+
}
278+
.padding(vertical = 12.dp),
279+
verticalAlignment = Alignment.CenterVertically,
280+
) {
281+
Text(
282+
text = label,
283+
style = MaterialTheme.typography.bodyLarge,
284+
color = MaterialTheme.colorScheme.primary,
285+
modifier = Modifier.weight(1f),
286+
)
287+
Icon(
288+
imageVector = Icons.AutoMirrored.Filled.ArrowForward,
289+
contentDescription = null,
290+
tint = MaterialTheme.colorScheme.secondary,
291+
modifier = Modifier.size(20.dp),
292+
)
293+
}
294+
}

0 commit comments

Comments
 (0)