Skip to content

Commit 42bb636

Browse files
committed
add language setting
1 parent 143f68b commit 42bb636

8 files changed

Lines changed: 328 additions & 4 deletions

File tree

app/src/main/AndroidManifest.xml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@
3535
android:fullBackupContent="@xml/backup_rules"
3636
android:icon="@mipmap/ic_launcher"
3737
android:label="@string/app_name"
38+
android:localeConfig="@xml/locales_config"
3839
android:roundIcon="@mipmap/ic_launcher_round"
3940
android:supportsRtl="true"
4041
android:theme="@style/Theme.UniversalInstaller">

app/src/main/java/app/pwhs/universalinstaller/MainActivity.kt

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package app.pwhs.universalinstaller
22

33
import android.Manifest
4+
import android.content.Context
45
import android.content.pm.PackageManager
56
import android.os.Build
67
import android.os.Bundle
@@ -23,6 +24,7 @@ import app.pwhs.universalinstaller.presentation.setting.ThemeMode
2324
import app.pwhs.universalinstaller.presentation.setting.dataStore
2425
import app.pwhs.universalinstaller.presentation.splash.SplashScreen
2526
import app.pwhs.universalinstaller.ui.theme.UniversalInstallerTheme
27+
import app.pwhs.universalinstaller.util.LocaleHelper
2628
import androidx.datastore.preferences.core.stringPreferencesKey
2729
import kotlinx.coroutines.flow.map
2830

@@ -34,6 +36,10 @@ class MainActivity : ComponentActivity() {
3436
ActivityResultContracts.RequestPermission()
3537
) { /* granted or not — uninstall flow works either way, notifications just won't show */ }
3638

39+
override fun attachBaseContext(newBase: Context) {
40+
super.attachBaseContext(LocaleHelper.wrap(newBase))
41+
}
42+
3743
override fun onCreate(savedInstanceState: Bundle?) {
3844
super.onCreate(savedInstanceState)
3945
enableEdgeToEdge()

app/src/main/java/app/pwhs/universalinstaller/presentation/setting/SettingScreen.kt

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,9 @@ import androidx.compose.material.icons.rounded.AdminPanelSettings
1919
import androidx.compose.material.icons.rounded.CleaningServices
2020
import androidx.compose.material.icons.rounded.Code
2121
import androidx.compose.material.icons.rounded.DarkMode
22+
import androidx.compose.material.icons.automirrored.rounded.KeyboardArrowRight
2223
import androidx.compose.material.icons.rounded.Info
24+
import androidx.compose.material.icons.rounded.Language
2325
import androidx.compose.material.icons.rounded.LightMode
2426
import androidx.compose.material.icons.rounded.Palette
2527
import androidx.compose.material.icons.rounded.Security
@@ -61,11 +63,17 @@ import androidx.compose.ui.text.input.PasswordVisualTransformation
6163
import androidx.compose.ui.unit.dp
6264
import com.ramcosta.composedestinations.annotation.Destination
6365
import com.ramcosta.composedestinations.annotation.RootGraph
66+
import com.ramcosta.composedestinations.generated.destinations.LanguageScreenDestination
67+
import com.ramcosta.composedestinations.navigation.DestinationsNavigator
6468
import org.koin.androidx.compose.koinViewModel
6569

6670
@Destination<RootGraph>
6771
@Composable
68-
fun SettingScreen(modifier: Modifier = Modifier, viewModel: SettingViewModel = koinViewModel()) {
72+
fun SettingScreen(
73+
navigator: DestinationsNavigator,
74+
modifier: Modifier = Modifier,
75+
viewModel: SettingViewModel = koinViewModel(),
76+
) {
6977
val uiState by viewModel.uiState.collectAsState()
7078

7179
SettingUi(
@@ -76,6 +84,7 @@ fun SettingScreen(modifier: Modifier = Modifier, viewModel: SettingViewModel = k
7684
onVirusTotalKeyChanged = viewModel::setVirusTotalApiKey,
7785
onShizukuOptionChanged = viewModel::setShizukuOption,
7886
onDeleteApkChanged = viewModel::setDeleteApkAfterInstall,
87+
onLanguageClick = { navigator.navigate(LanguageScreenDestination) },
7988
)
8089
}
8190

@@ -89,6 +98,7 @@ private fun SettingUi(
8998
onVirusTotalKeyChanged: (String) -> Unit = {},
9099
onShizukuOptionChanged: (Preferences.Key<Boolean>, Boolean) -> Unit = { _, _ -> },
91100
onDeleteApkChanged: (Boolean) -> Unit = {},
101+
onLanguageClick: () -> Unit = {},
92102
) {
93103
val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior()
94104

@@ -334,6 +344,35 @@ private fun SettingUi(
334344
},
335345
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
336346
)
347+
ListItem(
348+
headlineContent = {
349+
Text(stringResource(R.string.setting_language_title), style = MaterialTheme.typography.bodyLarge)
350+
},
351+
supportingContent = {
352+
Text(
353+
text = stringResource(R.string.setting_language_subtitle),
354+
style = MaterialTheme.typography.bodySmall,
355+
color = MaterialTheme.colorScheme.onSurfaceVariant,
356+
)
357+
},
358+
leadingContent = {
359+
Icon(
360+
imageVector = Icons.Rounded.Language,
361+
contentDescription = null,
362+
tint = MaterialTheme.colorScheme.primary,
363+
modifier = Modifier.size(24.dp),
364+
)
365+
},
366+
trailingContent = {
367+
Icon(
368+
imageVector = Icons.AutoMirrored.Rounded.KeyboardArrowRight,
369+
contentDescription = null,
370+
tint = MaterialTheme.colorScheme.onSurfaceVariant,
371+
)
372+
},
373+
modifier = Modifier.clickable(onClick = onLanguageClick),
374+
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
375+
)
337376
}
338377
}
339378

Lines changed: 195 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,195 @@
1+
package app.pwhs.universalinstaller.presentation.setting.language
2+
3+
import android.app.Activity
4+
import android.os.Build
5+
import androidx.compose.foundation.clickable
6+
import androidx.compose.foundation.layout.Arrangement
7+
import androidx.compose.foundation.layout.Column
8+
import androidx.compose.foundation.layout.PaddingValues
9+
import androidx.compose.foundation.layout.WindowInsets
10+
import androidx.compose.foundation.layout.fillMaxSize
11+
import androidx.compose.foundation.layout.fillMaxWidth
12+
import androidx.compose.foundation.layout.padding
13+
import androidx.compose.foundation.layout.size
14+
import androidx.compose.foundation.lazy.LazyColumn
15+
import androidx.compose.foundation.lazy.items
16+
import androidx.compose.material.icons.Icons
17+
import androidx.compose.material.icons.automirrored.rounded.ArrowBack
18+
import androidx.compose.material.icons.rounded.Check
19+
import androidx.compose.material3.ElevatedCard
20+
import androidx.compose.material3.CardDefaults
21+
import androidx.compose.material3.ExperimentalMaterial3Api
22+
import androidx.compose.material3.Icon
23+
import androidx.compose.material3.IconButton
24+
import androidx.compose.material3.LargeTopAppBar
25+
import androidx.compose.material3.ListItem
26+
import androidx.compose.material3.ListItemDefaults
27+
import androidx.compose.material3.MaterialTheme
28+
import androidx.compose.material3.RadioButton
29+
import androidx.compose.material3.Scaffold
30+
import androidx.compose.material3.Text
31+
import androidx.compose.material3.TopAppBarDefaults
32+
import androidx.compose.runtime.Composable
33+
import androidx.compose.runtime.getValue
34+
import androidx.compose.runtime.mutableStateOf
35+
import androidx.compose.runtime.remember
36+
import androidx.compose.runtime.saveable.rememberSaveable
37+
import androidx.compose.runtime.setValue
38+
import androidx.compose.ui.Modifier
39+
import androidx.compose.ui.graphics.Color
40+
import androidx.compose.ui.input.nestedscroll.nestedScroll
41+
import androidx.compose.ui.platform.LocalContext
42+
import androidx.compose.ui.res.stringResource
43+
import androidx.compose.ui.unit.dp
44+
import app.pwhs.universalinstaller.R
45+
import app.pwhs.universalinstaller.util.LocaleHelper
46+
import com.ramcosta.composedestinations.annotation.Destination
47+
import com.ramcosta.composedestinations.annotation.RootGraph
48+
import com.ramcosta.composedestinations.navigation.DestinationsNavigator
49+
50+
data class AppLanguage(val tag: String, val nativeName: String)
51+
52+
private val SUPPORTED_LANGUAGES = listOf(
53+
AppLanguage("en", "English"),
54+
AppLanguage("ar", "العربية"),
55+
AppLanguage("de", "Deutsch"),
56+
AppLanguage("es", "Español"),
57+
AppLanguage("fr", "Français"),
58+
AppLanguage("hi", "हिन्दी"),
59+
AppLanguage("in", "Bahasa Indonesia"),
60+
AppLanguage("ja", "日本語"),
61+
AppLanguage("ko", "한국어"),
62+
AppLanguage("pt-BR", "Português (Brasil)"),
63+
AppLanguage("ru", "Русский"),
64+
AppLanguage("tr", "Türkçe"),
65+
AppLanguage("vi", "Tiếng Việt"),
66+
AppLanguage("zh", "中文"),
67+
)
68+
69+
@Destination<RootGraph>
70+
@Composable
71+
fun LanguageScreen(
72+
navigator: DestinationsNavigator,
73+
modifier: Modifier = Modifier,
74+
) {
75+
val context = LocalContext.current
76+
val initial = remember { LocaleHelper.getStoredLanguage(context) }
77+
var selected by rememberSaveable { mutableStateOf(initial) }
78+
79+
LanguageUi(
80+
modifier = modifier,
81+
selected = selected,
82+
onSelected = { selected = it },
83+
onBack = { navigator.popBackStack() },
84+
onDone = {
85+
if (selected != initial) {
86+
LocaleHelper.setAppLanguage(context, selected)
87+
// On API < 33 we recreate manually; on API 33+ LocaleManager does it for us.
88+
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) {
89+
(context as? Activity)?.recreate()
90+
}
91+
}
92+
navigator.popBackStack()
93+
},
94+
)
95+
}
96+
97+
@OptIn(ExperimentalMaterial3Api::class)
98+
@Composable
99+
private fun LanguageUi(
100+
modifier: Modifier = Modifier,
101+
selected: String = "",
102+
onSelected: (String) -> Unit = {},
103+
onBack: () -> Unit = {},
104+
onDone: () -> Unit = {},
105+
) {
106+
val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior()
107+
108+
Scaffold(
109+
modifier = modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
110+
contentWindowInsets = WindowInsets(0, 0, 0, 0),
111+
topBar = {
112+
LargeTopAppBar(
113+
expandedHeight = 120.dp,
114+
title = {
115+
Text(
116+
text = stringResource(R.string.language_screen_title),
117+
style = MaterialTheme.typography.headlineMedium,
118+
)
119+
},
120+
navigationIcon = {
121+
IconButton(onClick = onBack) {
122+
Icon(
123+
imageVector = Icons.AutoMirrored.Rounded.ArrowBack,
124+
contentDescription = stringResource(R.string.back_cd),
125+
)
126+
}
127+
},
128+
actions = {
129+
IconButton(onClick = onDone) {
130+
Icon(
131+
imageVector = Icons.Rounded.Check,
132+
contentDescription = stringResource(R.string.done_cd),
133+
tint = MaterialTheme.colorScheme.primary,
134+
)
135+
}
136+
},
137+
scrollBehavior = scrollBehavior,
138+
colors = TopAppBarDefaults.topAppBarColors(
139+
containerColor = MaterialTheme.colorScheme.background,
140+
scrolledContainerColor = MaterialTheme.colorScheme.surfaceContainer,
141+
),
142+
)
143+
},
144+
) { innerPadding ->
145+
LazyColumn(
146+
modifier = Modifier
147+
.padding(innerPadding)
148+
.fillMaxSize(),
149+
contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp),
150+
verticalArrangement = Arrangement.spacedBy(12.dp),
151+
) {
152+
item {
153+
ElevatedCard(
154+
modifier = Modifier.fillMaxWidth(),
155+
shape = MaterialTheme.shapes.large,
156+
colors = CardDefaults.elevatedCardColors(
157+
containerColor = MaterialTheme.colorScheme.surfaceContainerLow,
158+
),
159+
) {
160+
Column(modifier = Modifier.padding(vertical = 4.dp)) {
161+
SUPPORTED_LANGUAGES.forEach { lang ->
162+
LanguageRow(
163+
language = lang,
164+
selected = selected == lang.tag,
165+
onClick = { onSelected(lang.tag) },
166+
)
167+
}
168+
}
169+
}
170+
}
171+
}
172+
}
173+
}
174+
175+
@Composable
176+
private fun LanguageRow(
177+
language: AppLanguage,
178+
selected: Boolean,
179+
onClick: () -> Unit,
180+
) {
181+
ListItem(
182+
headlineContent = {
183+
Text(language.nativeName, style = MaterialTheme.typography.bodyLarge)
184+
},
185+
trailingContent = {
186+
RadioButton(
187+
selected = selected,
188+
onClick = onClick,
189+
modifier = Modifier.size(24.dp),
190+
)
191+
},
192+
modifier = Modifier.clickable(onClick = onClick),
193+
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
194+
)
195+
}
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
package app.pwhs.universalinstaller.util
2+
3+
import android.app.LocaleManager
4+
import android.content.Context
5+
import android.content.res.Configuration
6+
import android.os.Build
7+
import android.os.LocaleList
8+
import java.util.Locale
9+
10+
/**
11+
* Per-app language.
12+
*
13+
* On API 33+ we delegate to the platform [LocaleManager] so the choice shows up in the system
14+
* per-app language screen and the activity is recreated by the framework. On older API levels we
15+
* persist the tag ourselves and wrap [MainActivity.attachBaseContext] so resources resolve with
16+
* the chosen locale.
17+
*/
18+
object LocaleHelper {
19+
20+
private const val PREFS = "locale_prefs"
21+
private const val KEY_LANG = "app_language"
22+
23+
/** BCP-47 tag (e.g. "vi", "pt-BR") or empty string meaning "follow system". */
24+
fun getStoredLanguage(context: Context): String {
25+
return context.applicationContext
26+
.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
27+
.getString(KEY_LANG, "") ?: ""
28+
}
29+
30+
fun setAppLanguage(context: Context, tag: String) {
31+
context.applicationContext
32+
.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
33+
.edit()
34+
.putString(KEY_LANG, tag)
35+
.apply()
36+
37+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
38+
val localeList = if (tag.isEmpty()) LocaleList.getEmptyLocaleList()
39+
else LocaleList.forLanguageTags(tag)
40+
context.getSystemService(LocaleManager::class.java).applicationLocales = localeList
41+
}
42+
}
43+
44+
/**
45+
* Wrap [base] so resources use the stored locale. On API 33+ the system already applies the
46+
* per-app locale, so this is a no-op there.
47+
*/
48+
fun wrap(base: Context): Context {
49+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) return base
50+
val tag = getStoredLanguage(base)
51+
if (tag.isEmpty()) return base
52+
53+
val locale = Locale.forLanguageTag(tag)
54+
Locale.setDefault(locale)
55+
56+
val config = Configuration(base.resources.configuration)
57+
config.setLocale(locale)
58+
config.setLayoutDirection(locale)
59+
return base.createConfigurationContext(config)
60+
}
61+
}

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

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -143,7 +143,7 @@
143143
<string name="installer_mode_using">%s का उपयोग कर रहा है</string>
144144

145145
<string name="install_error_cancelled_title">इंस्टॉलेशन रद्द हो गया</string>
146-
<string name="install_error_cancelled_guidance">इंस्टॉलेशन को उपयोगकर्ता या सिस्टम द्वारा रद्द कर दिया गया था। पुनः प्रयास करने के लिए \'पुनः प्रयास करें' पर टैप करें</string>
146+
<string name="install_error_cancelled_guidance">इंस्टॉलेशन को उपयोगकर्ता या सिस्टम द्वारा रद्द कर दिया गया था। पुनः प्रयास करने के लिए \'पुनः प्रयास करें\' पर टैप करें</string>
147147
<string name="install_error_blocked_title">इंस्टॉलेशन ब्लॉक हो गया</string>
148148
<string name="install_error_blocked_guidance">आपकी डिवाइस नीति या सुरक्षा सेटिंग्स ने इस इंस्टॉल को ब्लॉक कर दिया है। जांचें कि सेटिंग्स &gt; ऐप्स &gt; विशेष एक्सेस में अज्ञात स्रोतों की अनुमति है या नहीं।</string>
149149
<string name="install_error_conflict_title">पैकेज में विरोध</string>
@@ -155,11 +155,11 @@
155155
<string name="install_error_storage_title">पर्याप्त स्टोरेज नहीं है</string>
156156
<string name="install_error_storage_guidance">आपके डिवाइस में पर्याप्त खाली स्थान नहीं है। सेटिंग्स &gt; स्टोरेज में स्थान खाली करें और पुनः प्रयास करें।</string>
157157
<string name="install_error_timeout_title">इंस्टॉलेशन का समय समाप्त</string>
158-
<string name="install_error_timeout_guidance">इंस्टॉलेशन में बहुत अधिक समय लगा और उसे रद्द कर दिया गया। पुनः प्रयास करने के लिए \'पुनः प्रयास करें' पर टैप करें।</string>
158+
<string name="install_error_timeout_guidance">इंस्टॉलेशन में बहुत अधिक समय लगा और उसे रद्द कर दिया गया। पुनः प्रयास करने के लिए \'पुनः प्रयास करें\' पर टैप करें।</string>
159159
<string name="install_error_unexpected_title">अप्रत्याशित त्रुटि</string>
160160
<string name="install_error_unexpected_guidance">एक अप्रत्याशित त्रुटि हुई: %s. ऐप या अपने डिवाइस को पुनरारंभ करने का प्रयास करें।</string>
161161
<string name="install_error_failed_title">इंस्टॉलेशन विफल</string>
162-
<string name="install_error_unknown_guidance">एक अज्ञात त्रुटि हुई। पुनः प्रयास करने के लिए \'पुनः प्रयास करें' पर टैप करें।</string>
162+
<string name="install_error_unknown_guidance">एक अज्ञात त्रुटि हुई। पुनः प्रयास करने के लिए \'पुनः प्रयास करें\' पर टैप करें।</string>
163163
<string name="install_error_unknown_guidance_short">एक अज्ञात त्रुटि हुई।</string>
164164

165165
<string name="splash_tagline">कुछ भी इंस्टॉल करें, कहीं भी</string>

0 commit comments

Comments
 (0)