forked from therealaleph/MasterHttpRelayVPN-RUST
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathHomeScreen.kt
More file actions
1586 lines (1494 loc) · 63.7 KB
/
Copy pathHomeScreen.kt
File metadata and controls
1586 lines (1494 loc) · 63.7 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
package com.therealaleph.mhrv.ui
import android.widget.Toast
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.material.icons.filled.ErrorOutline
import androidx.compose.material.icons.filled.ExpandLess
import androidx.compose.material.icons.filled.ExpandMore
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material.icons.filled.HourglassBottom
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.therealaleph.mhrv.CaInstall
import com.therealaleph.mhrv.ConfigStore
import com.therealaleph.mhrv.DEFAULT_SNI_POOL
import com.therealaleph.mhrv.MhrvConfig
import com.therealaleph.mhrv.Mode
import com.therealaleph.mhrv.Native
import com.therealaleph.mhrv.ConnectionMode
import com.therealaleph.mhrv.NetworkDetect
import com.therealaleph.mhrv.R
import com.therealaleph.mhrv.SplitMode
import com.therealaleph.mhrv.UiLang
import com.therealaleph.mhrv.VpnState
import androidx.compose.ui.res.stringResource
import com.therealaleph.mhrv.ui.theme.ErrRed
import com.therealaleph.mhrv.ui.theme.OkGreen
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeoutOrNull
import org.json.JSONObject
/**
* UI state returned by the Activity after the CA install flow finishes,
* so the screen can show a matching snackbar. Kept as a sum type — a raw
* string message would conflate "installed" vs. "failed to export".
*/
sealed class CaInstallOutcome {
object Installed : CaInstallOutcome()
/**
* Cert not found in the AndroidCAStore after the Settings activity
* returned. Carries an optional downloadPath so the snackbar can tell
* the user where the file landed (Downloads or app-private external).
*/
data class NotInstalled(val downloadPath: String?) : CaInstallOutcome()
data class Failed(val message: String) : CaInstallOutcome()
}
/**
* Top-level screen. Intentionally one scrollable page rather than tabs —
* first-run users need to see everything (deployment IDs, cert button,
* Connect) on one surface. The Connect/Disconnect button sits right under
* the Mode dropdown so a long deployment-ID list can't push it off-screen
* for daily-use taps. Anything that isn't first-run critical (Apps Script
* setup once filled, SNI pool, Advanced, Logs) lives in collapsible
* sections so the default view stays short.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun HomeScreen(
onStart: () -> Unit,
onStop: () -> Unit,
onInstallCaConfirmed: () -> Unit,
caOutcome: CaInstallOutcome?,
onCaOutcomeConsumed: () -> Unit,
onLangChange: (UiLang) -> Unit = {},
) {
val ctx = LocalContext.current
val scope = rememberCoroutineScope()
val snackbar = remember { SnackbarHostState() }
// Persisted form state. Any edit writes back to disk immediately —
// cheap at this write rate, avoids "I tapped Start before saving" bugs.
var cfg by remember { mutableStateOf(ConfigStore.load(ctx)) }
fun persist(new: MhrvConfig) {
cfg = new
ConfigStore.save(ctx, new)
}
// CA install dialog visibility.
var showInstallDialog by rememberSaveable { mutableStateOf(false) }
// One-shot auto update check on first composition. Silent if we're
// already on the latest (no point nagging about a network miss or an
// up-to-date install); surfaces a snackbar only when a newer tag is
// available. rememberSaveable so it doesn't re-fire on every config
// change / rotation.
var autoUpdateChecked by rememberSaveable { mutableStateOf(false) }
LaunchedEffect(autoUpdateChecked) {
if (autoUpdateChecked) return@LaunchedEffect
autoUpdateChecked = true
val json = withContext(Dispatchers.IO) {
runCatching { Native.checkUpdate() }.getOrNull()
}
if (json != null) {
val obj = runCatching { JSONObject(json) }.getOrNull()
if (obj?.optString("kind") == "updateAvailable") {
snackbar.showSnackbar(
"Update available: v${obj.optString("current")} → " +
"v${obj.optString("latest")} ${obj.optString("url")}",
withDismissAction = true,
)
}
}
}
// Gate Start/Stop on the service's actual state transition rather
// than a fixed timer. The previous 2s cooldown was shorter than the
// worst-case teardown (Tun2proxy.stop + 4s join + 5s rt.shutdown_timeout
// ≈ 9s on the slowest path), which let the user fire a fresh Connect
// while the previous Stop's native cleanup was still releasing the
// listener port — the new startProxy then failed with "Address already
// in use".
//
// `awaitingRunning` holds the value we expect VpnState.isRunning to
// settle on after the user's action; null means "no transition in
// flight". The LaunchedEffect below suspends on the StateFlow until
// the predicate matches, with a 12s backstop in case the service
// failed before flipping the flag (e.g., establish() returned null).
// Side benefit: this also debounces the rapid-tap EGL renderer crash
// the old timer was guarding against.
var awaitingRunning by remember { mutableStateOf<Boolean?>(null) }
val transitioning = awaitingRunning != null
LaunchedEffect(awaitingRunning) {
val target = awaitingRunning ?: return@LaunchedEffect
try {
withTimeoutOrNull(12_000) {
VpnState.isRunning.first { it == target }
}
} finally {
awaitingRunning = null
}
}
// Surface CA install result as a snackbar. We consume the outcome
// after showing so a recomposition doesn't re-trigger it.
LaunchedEffect(caOutcome) {
val o = caOutcome ?: return@LaunchedEffect
val msg = when (o) {
is CaInstallOutcome.Installed ->
"Certificate installed ✓"
is CaInstallOutcome.NotInstalled -> buildString {
append("Certificate not yet installed.")
if (!o.downloadPath.isNullOrBlank()) {
append(" Saved to ${o.downloadPath}. ")
append("In Settings, search for \"CA certificate\" and install from there — NOT \"VPN & app user certificate\" or \"Wi-Fi\".")
} else {
append(" Tap Install again to retry.")
}
}
is CaInstallOutcome.Failed -> o.message
}
snackbar.showSnackbar(msg, withDismissAction = true)
onCaOutcomeConsumed()
}
Scaffold(
topBar = {
TopAppBar(
title = { Text("mhrv-rs") },
actions = {
// Language toggle — cycles AUTO → FA → EN → AUTO.
// Saving writes to config.json and triggers activity
// recreate, which re-applies the AppCompatDelegate
// locale (and flips LTR ↔ RTL accordingly). Kept as
// a small label button instead of an icon because
// "AUTO/FA/EN" communicates the current state at a
// glance; a flag icon alone would be ambiguous.
TextButton(
onClick = {
val next = when (cfg.uiLang) {
UiLang.AUTO -> UiLang.FA
UiLang.FA -> UiLang.EN
UiLang.EN -> UiLang.AUTO
}
persist(cfg.copy(uiLang = next))
onLangChange(next)
},
) {
Text(
text = when (cfg.uiLang) {
UiLang.AUTO -> "AUTO"
UiLang.FA -> "FA"
UiLang.EN -> "EN"
},
style = MaterialTheme.typography.labelSmall,
)
}
// Tap the version label to check for updates.
var checking by remember { mutableStateOf(false) }
TextButton(
onClick = {
if (checking) return@TextButton
checking = true
scope.launch {
val json = withContext(Dispatchers.IO) {
runCatching { Native.checkUpdate() }.getOrNull()
}
val msg = summarizeUpdateCheck(json)
snackbar.showSnackbar(msg, withDismissAction = true)
checking = false
}
},
modifier = Modifier.padding(end = 4.dp),
) {
Text(
text = if (checking) stringResource(R.string.tb_check_update_checking)
else stringResource(R.string.tb_version_prefix) +
runCatching { Native.version() }.getOrDefault("?"),
style = MaterialTheme.typography.labelMedium,
)
}
},
)
},
snackbarHost = { SnackbarHost(snackbar) },
) { inner ->
Column(
modifier = Modifier
.padding(inner)
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
// Config import/export bar — paste from clipboard + export + QR.
ConfigSharingBar(
cfg = cfg,
onImport = { persist(it) },
onSnackbar = { snackbar.showSnackbar(it) },
)
SectionHeader("Mode")
ModeDropdown(
mode = cfg.mode,
onChange = { persist(cfg.copy(mode = it)) },
)
// Connect/Disconnect lives right under Mode so users with a long
// deployment-ID list don't have to scroll past it on every
// session. Disabled state still acts as the "you're not set up
// yet" signal — they'll expand the Apps Script section below to
// resolve it.
val isVpnRunning by VpnState.isRunning.collectAsState()
Button(
onClick = {
if (isVpnRunning) {
awaitingRunning = false
onStop()
} else {
awaitingRunning = true
// Connect flow: auto-resolve google_ip so we don't
// hand the proxy a stale anycast target; repair
// front_domain if it got corrupted into an IP
// (SNI has to be a hostname); then fire onStart.
// All three steps go through the Compose persist()
// so a subsequent field edit can't overwrite the
// fresh values with pre-resolve ones.
scope.launch {
// Only auto-fill google_ip if it's empty.
// Issue #71: some Iranian ISPs return
// poisoned A records for www.google.com that
// resolve but then refuse TLS (or route to a
// Google IP that's not on the GFE and can't
// handle our SNI-rewrite). If the user has
// manually set a working IP
// (e.g. 216.239.38.120), we must NOT
// overwrite it with a poisoned fresh lookup
// just because the two values differ. They
// can still force a re-resolve via the
// explicit "Auto-detect" button above.
var updated = cfg
if (updated.googleIp.isBlank()) {
val fresh = withContext(Dispatchers.IO) {
NetworkDetect.resolveGoogleIp()
}
if (!fresh.isNullOrBlank()) {
updated = updated.copy(googleIp = fresh)
}
}
if (updated.frontDomain.isBlank() ||
updated.frontDomain.parseAsIpOrNull() != null
) {
updated = updated.copy(frontDomain = "www.google.com")
}
if (updated !== cfg) persist(updated)
onStart()
}
}
},
enabled = (isVpnRunning ||
cfg.mode == Mode.GOOGLE_ONLY ||
(cfg.hasDeploymentId && cfg.authKey.isNotBlank())) && !transitioning,
colors = ButtonDefaults.buttonColors(
containerColor = if (isVpnRunning) ErrRed else OkGreen,
contentColor = androidx.compose.ui.graphics.Color.White,
disabledContainerColor = MaterialTheme.colorScheme.surfaceVariant,
),
modifier = Modifier
.fillMaxWidth()
.heightIn(min = 52.dp),
) {
Text(
when {
transitioning -> "…"
isVpnRunning -> stringResource(R.string.btn_disconnect)
else -> stringResource(R.string.btn_connect)
},
style = MaterialTheme.typography.titleMedium,
)
}
Spacer(Modifier.height(4.dp))
val appsScriptEnabled = cfg.mode == Mode.APPS_SCRIPT || cfg.mode == Mode.FULL
// Wrapped in a collapsible so a long ID list (10+ deployments
// is normal in full-tunnel rotations) doesn't dominate the
// screen once it's set up. Starts expanded for first-run users
// (no IDs/key yet) so the form is immediately discoverable.
CollapsibleSection(
title = stringResource(R.string.sec_apps_script_relay),
initiallyExpanded = appsScriptEnabled &&
(cfg.appsScriptUrls.isEmpty() || cfg.authKey.isBlank()),
) {
DeploymentIdsField(
urls = cfg.appsScriptUrls,
onChange = { persist(cfg.copy(appsScriptUrls = it)) },
enabled = appsScriptEnabled,
)
OutlinedTextField(
value = cfg.authKey,
onValueChange = { persist(cfg.copy(authKey = it)) },
label = { Text(stringResource(R.string.field_auth_key)) },
singleLine = true,
enabled = appsScriptEnabled,
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next),
modifier = Modifier.fillMaxWidth(),
supportingText = {
Text(stringResource(R.string.help_auth_key))
},
)
}
Spacer(Modifier.height(4.dp))
SectionHeader(stringResource(R.string.sec_network))
ConnectionModeDropdown(
mode = cfg.connectionMode,
onChange = { persist(cfg.copy(connectionMode = it)) },
httpPort = cfg.listenPort,
socks5Port = cfg.socks5Port ?: (cfg.listenPort + 1),
)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
OutlinedTextField(
value = cfg.googleIp,
onValueChange = { persist(cfg.copy(googleIp = it)) },
label = { Text(stringResource(R.string.field_google_ip)) },
singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri),
modifier = Modifier.weight(1f),
)
OutlinedTextField(
value = cfg.frontDomain,
onValueChange = { persist(cfg.copy(frontDomain = it)) },
label = { Text(stringResource(R.string.field_front_domain)) },
singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri),
modifier = Modifier.weight(1f),
)
}
// "Auto-detect" forces a fresh DNS resolution now. Start also
// auto-resolves transparently, but exposing a button makes the
// "I'm getting connect timeouts, is my google_ip stale?" case
// a one-tap fix without needing to look up nslookup output.
TextButton(
onClick = {
scope.launch {
val fresh = withContext(Dispatchers.IO) {
NetworkDetect.resolveGoogleIp()
}
if (!fresh.isNullOrBlank()) {
var updated = cfg
if (fresh != updated.googleIp) {
updated = updated.copy(googleIp = fresh)
}
// Same repair logic as the Start button —
// if front_domain has been corrupted into an
// IP we can't use it for SNI, so put the
// default hostname back.
if (updated.frontDomain.isBlank() ||
updated.frontDomain.parseAsIpOrNull() != null
) {
updated = updated.copy(frontDomain = "www.google.com")
}
// Captured up-front so the lambda has access
// to the format-string resources via context
// before running on the IO dispatcher.
if (updated !== cfg) {
persist(updated)
snackbar.showSnackbar(
ctx.getString(R.string.snack_google_ip_updated, fresh),
)
} else {
snackbar.showSnackbar(
ctx.getString(R.string.snack_google_ip_current, fresh),
)
}
} else {
snackbar.showSnackbar(ctx.getString(R.string.snack_dns_lookup_failed))
}
}
},
modifier = Modifier.align(Alignment.End),
) { Text(stringResource(R.string.btn_auto_detect_google_ip)) }
// App splitting — only makes sense in VPN_TUN mode.
// PROXY_ONLY has no system-level routing to partition.
if (cfg.connectionMode == ConnectionMode.VPN_TUN) {
CollapsibleSection(title = stringResource(R.string.sec_app_splitting)) {
AppSplittingEditor(cfg = cfg, onChange = ::persist)
}
}
// SNI pool: collapsed by default. Users without a reason to
// touch it should leave Rust's auto-expansion to handle it.
CollapsibleSection(title = stringResource(R.string.sec_sni_pool_tester)) {
SniPoolEditor(
cfg = cfg,
onChange = ::persist,
)
}
// Advanced settings: collapsed by default.
CollapsibleSection(title = stringResource(R.string.sec_advanced)) {
AdvancedSettings(
cfg = cfg,
onChange = ::persist,
)
}
Spacer(Modifier.height(8.dp))
// Secondary action — FilledTonalButton signals "helper" against
// the primary Connect/Disconnect button at the top. Kept down
// here because cert install is a one-time setup step; daily
// users never tap it again.
FilledTonalButton(
onClick = { showInstallDialog = true },
modifier = Modifier.fillMaxWidth(),
) {
Text(stringResource(R.string.btn_install_mitm))
}
// "Usage today (estimated)" — visible only while a proxy is
// actually running (the handle is non-zero). Polls the native
// stats counter once a second; cheap (just reads atomics on
// the Rust side) and gives users a live feel for how close
// they are to the Apps Script daily quota. Also links out to
// Google's dashboard for the authoritative number — the
// client-side estimate only sees what this device relayed,
// not what other devices on the same deployment consumed.
UsageTodayCard()
CollapsibleSection(title = stringResource(R.string.sec_live_logs), initiallyExpanded = false) {
LiveLogPane()
}
Spacer(Modifier.height(16.dp))
// Wrapped in a collapsible so the big prose block doesn't
// dominate the form after the user has learned the flow.
// Starts expanded once for a fresh install so the first-run
// instructions are immediately visible.
CollapsibleSection(
title = stringResource(R.string.sec_how_to_use),
initiallyExpanded = cfg.appsScriptUrls.isEmpty() || cfg.authKey.isBlank(),
) {
HowToUseBody(cfg.listenPort)
}
}
}
// ---- CA install confirmation dialog ---------------------------------
if (showInstallDialog) {
// Export eagerly so we can show the fingerprint in the dialog body
// — builds user confidence ("yes, that's the cert I'm trusting")
// and gives us a usable failure path if the CA doesn't exist yet.
val exported = remember { CaInstall.export(ctx) }
val fp = remember(exported) { if (exported) CaInstall.fingerprint(ctx) else null }
val cn = remember(exported) { if (exported) CaInstall.subjectCn(ctx) else null }
AlertDialog(
onDismissRequest = { showInstallDialog = false },
title = { Text(stringResource(R.string.dialog_install_mitm_title)) },
text = {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text(
"mhrv-rs creates a local certificate authority so it can decrypt " +
"and re-encrypt HTTPS traffic before tunnelling it through the Apps " +
"Script relay. Without this CA installed as trusted, apps will show " +
"certificate errors."
)
Text(
"On Android 11+ the system removed the inline install path, so " +
"tapping Install will: (1) save a PEM copy to Downloads/mhrv-ca.crt, " +
"(2) open the Settings app.\n\n" +
"Inside Settings, tap the search bar and type \"CA certificate\". " +
"Open the result labelled \"CA certificate\" (NOT \"VPN & app user " +
"certificate\" or \"Wi-Fi certificate\"). Pick mhrv-ca.crt from " +
"Downloads when prompted. If you don't have a screen lock, Android " +
"will ask you to add one first — that's an OS requirement for " +
"installing any user CA."
)
if (fp != null) {
Text("Subject: ${cn ?: "(unknown)"}", style = MaterialTheme.typography.labelMedium)
Text(
text = "SHA-256: ${CaInstall.fingerprintHex(fp)}",
style = MaterialTheme.typography.labelSmall,
fontFamily = FontFamily.Monospace,
)
} else {
Text(
"Could not read the CA cert yet. Tap Start once so the " +
"proxy generates it, then come back.",
color = MaterialTheme.colorScheme.error,
)
}
}
},
confirmButton = {
TextButton(
onClick = {
showInstallDialog = false
if (fp != null) onInstallCaConfirmed()
},
enabled = fp != null,
) { Text("Install") }
},
dismissButton = {
TextButton(onClick = { showInstallDialog = false }) { Text("Cancel") }
},
)
}
}
// =========================================================================
// App splitting — ALL / ONLY / EXCEPT, plus a picker for the package list.
// =========================================================================
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun AppSplittingEditor(
cfg: MhrvConfig,
onChange: (MhrvConfig) -> Unit,
) {
val ctx = LocalContext.current
var pickerOpen by remember { mutableStateOf(false) }
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
Text(
stringResource(R.string.help_app_splitting),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
// Radio-style mode selector. Using Column-of-Row-with-RadioButton
// instead of a dropdown because all three options deserve to be
// visible simultaneously — the labels explain the contract.
SplitModeRow(
label = stringResource(R.string.split_all),
selected = cfg.splitMode == SplitMode.ALL,
onClick = { onChange(cfg.copy(splitMode = SplitMode.ALL)) },
)
SplitModeRow(
label = stringResource(R.string.split_only),
selected = cfg.splitMode == SplitMode.ONLY,
onClick = { onChange(cfg.copy(splitMode = SplitMode.ONLY)) },
)
SplitModeRow(
label = stringResource(R.string.split_except),
selected = cfg.splitMode == SplitMode.EXCEPT,
onClick = { onChange(cfg.copy(splitMode = SplitMode.EXCEPT)) },
)
if (cfg.splitMode != SplitMode.ALL) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth(),
) {
Text(
stringResource(R.string.sni_selected_count, cfg.splitApps.size),
style = MaterialTheme.typography.labelMedium,
modifier = Modifier.weight(1f),
)
TextButton(onClick = { pickerOpen = true }) {
Text(stringResource(R.string.split_pick_apps))
}
}
}
}
if (pickerOpen) {
AppPickerDialog(
initial = cfg.splitApps.toSet(),
ownPackage = ctx.packageName,
onSave = { picked ->
onChange(cfg.copy(splitApps = picked))
pickerOpen = false
},
onDismiss = { pickerOpen = false },
)
}
}
@Composable
private fun SplitModeRow(label: String, selected: Boolean, onClick: () -> Unit) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth(),
) {
RadioButton(selected = selected, onClick = onClick)
Text(
text = label,
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.weight(1f),
)
}
}
// =========================================================================
// Connection mode — VPN (TUN) vs Proxy-only.
// =========================================================================
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun ConnectionModeDropdown(
mode: ConnectionMode,
onChange: (ConnectionMode) -> Unit,
httpPort: Int,
socks5Port: Int,
) {
val labelVpn = stringResource(R.string.mode_vpn_tun)
val labelProxy = stringResource(R.string.mode_proxy_only)
val currentLabel = when (mode) {
ConnectionMode.VPN_TUN -> labelVpn
ConnectionMode.PROXY_ONLY -> labelProxy
}
var expanded by remember { mutableStateOf(false) }
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
ExposedDropdownMenuBox(
expanded = expanded,
onExpandedChange = { expanded = !expanded },
) {
OutlinedTextField(
value = currentLabel,
onValueChange = {},
readOnly = true,
label = { Text(stringResource(R.string.field_connection_mode)) },
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) },
modifier = Modifier.fillMaxWidth().menuAnchor(),
)
ExposedDropdownMenu(
expanded = expanded,
onDismissRequest = { expanded = false },
) {
DropdownMenuItem(
text = { Text(labelVpn) },
onClick = {
onChange(ConnectionMode.VPN_TUN)
expanded = false
},
)
DropdownMenuItem(
text = { Text(labelProxy) },
onClick = {
onChange(ConnectionMode.PROXY_ONLY)
expanded = false
},
)
}
}
// Helper text under the dropdown explains what the user is
// signing up for in each mode — especially important for
// PROXY_ONLY, where "tap Connect" alone doesn't route anything
// until they set the Wi-Fi proxy themselves.
val help = when (mode) {
ConnectionMode.VPN_TUN ->
stringResource(R.string.help_mode_vpn_tun)
ConnectionMode.PROXY_ONLY ->
stringResource(R.string.help_mode_proxy_only, httpPort, socks5Port)
}
Text(
help,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
// =========================================================================
// Deployment IDs editor — one row per ID, with add/remove buttons. The
// "+ Add" field accepts a single ID OR a bulk paste of many separated by
// whitespace / newline / comma / semicolon — useful when migrating from
// the desktop config or pasting a freshly-deployed batch (issue: bulk add).
// =========================================================================
/** Split a bulk-pasted blob into individual entries. */
private val ID_SEPARATORS = Regex("[\\s,;]+")
@Composable
private fun DeploymentIdsField(
urls: List<String>,
onChange: (List<String>) -> Unit,
enabled: Boolean = true,
) {
var newEntry by remember { mutableStateOf("") }
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
Text(
stringResource(R.string.field_deployment_urls),
style = MaterialTheme.typography.labelLarge,
)
// Existing entries — each with its own row and a remove button.
// A bulk paste into an existing row also expands into multiple
// entries, so users don't have to find the "+ Add" field to do it.
urls.forEachIndexed { index, url ->
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth(),
) {
OutlinedTextField(
value = url,
onValueChange = { edited ->
val parts = edited.split(ID_SEPARATORS).filter { it.isNotBlank() }
val updated = urls.toMutableList()
if (parts.size > 1) {
// Bulk paste into this row: expand in place.
updated.removeAt(index)
updated.addAll(index, parts)
} else {
// Normal typing — preserve raw input so the
// caret/whitespace doesn't get reformatted on
// every keystroke.
updated[index] = edited
}
onChange(updated)
},
enabled = enabled,
modifier = Modifier.weight(1f),
singleLine = true,
textStyle = MaterialTheme.typography.bodySmall,
label = { Text("#${index + 1}") },
)
IconButton(
onClick = {
onChange(urls.filterIndexed { i, _ -> i != index })
},
enabled = enabled,
) {
Text("✕", color = MaterialTheme.colorScheme.error)
}
}
}
// "Add" row: multi-line text field + button. Multi-line so a user
// can paste a long list at once (newline-separated is the natural
// form when copying out of the desktop UI's textarea).
Row(
verticalAlignment = Alignment.Top,
modifier = Modifier.fillMaxWidth(),
) {
OutlinedTextField(
value = newEntry,
onValueChange = { newEntry = it },
enabled = enabled,
modifier = Modifier.weight(1f),
singleLine = false,
minLines = 1,
maxLines = 6,
placeholder = { Text(stringResource(R.string.placeholder_paste_ids)) },
)
Spacer(Modifier.width(8.dp))
Button(
onClick = {
val parts = newEntry.split(ID_SEPARATORS).filter { it.isNotBlank() }
if (parts.isNotEmpty()) {
onChange(urls + parts)
newEntry = ""
}
},
enabled = enabled && newEntry.isNotBlank(),
contentPadding = PaddingValues(horizontal = 12.dp),
) {
Text("+ Add")
}
}
Text(
stringResource(R.string.help_deployment_urls),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
// =========================================================================
// Mode dropdown: apps_script (default) vs google_only (bootstrap).
// =========================================================================
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun ModeDropdown(
mode: Mode,
onChange: (Mode) -> Unit,
) {
val labelApps = "Apps Script (MITM)"
val labelGoogle = "Google-only (bootstrap)"
val labelFull = "Full tunnel (no cert)"
val currentLabel = when (mode) {
Mode.APPS_SCRIPT -> labelApps
Mode.GOOGLE_ONLY -> labelGoogle
Mode.FULL -> labelFull
}
var expanded by remember { mutableStateOf(false) }
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
ExposedDropdownMenuBox(
expanded = expanded,
onExpandedChange = { expanded = !expanded },
) {
OutlinedTextField(
value = currentLabel,
onValueChange = {},
readOnly = true,
label = { Text("Mode") },
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) },
modifier = Modifier.fillMaxWidth().menuAnchor(),
)
ExposedDropdownMenu(
expanded = expanded,
onDismissRequest = { expanded = false },
) {
DropdownMenuItem(
text = { Text(labelApps) },
onClick = { onChange(Mode.APPS_SCRIPT); expanded = false },
)
DropdownMenuItem(
text = { Text(labelGoogle) },
onClick = { onChange(Mode.GOOGLE_ONLY); expanded = false },
)
DropdownMenuItem(
text = { Text(labelFull) },
onClick = { onChange(Mode.FULL); expanded = false },
)
}
}
val help = when (mode) {
Mode.APPS_SCRIPT ->
"Full DPI bypass through your deployed Apps Script relay."
Mode.GOOGLE_ONLY ->
"Bootstrap: reach *.google.com directly so you can open script.google.com and deploy Code.gs. Non-Google traffic goes direct."
Mode.FULL ->
"All traffic tunneled end-to-end through Apps Script + remote tunnel node. No certificate needed."
}
Text(
help,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
// =========================================================================
// SNI pool editor + per-SNI probe.
// =========================================================================
private sealed class ProbeState {
object Idle : ProbeState()
object InFlight : ProbeState()
data class Ok(val latencyMs: Int) : ProbeState()
data class Err(val message: String) : ProbeState()
}
@Composable
private fun SniPoolEditor(
cfg: MhrvConfig,
onChange: (MhrvConfig) -> Unit,
) {
val scope = rememberCoroutineScope()
// Build the displayed list: union of the default pool + the config's
// sniHosts + the current front_domain. Order: front_domain first,
// defaults, then user customs. Deduped.
val displayed: List<String> = remember(cfg) {
val seen = linkedSetOf<String>()
if (cfg.frontDomain.isNotBlank()) seen.add(cfg.frontDomain.trim())
DEFAULT_SNI_POOL.forEach { seen.add(it) }
cfg.sniHosts.forEach { if (it.isNotBlank()) seen.add(it.trim()) }
seen.toList()
}
// A host is enabled if it appears in cfg.sniHosts. Empty sniHosts
// means "let Rust auto-expand" — we reflect that as "default pool
// enabled, customs not".
val enabledSet: Set<String> = remember(cfg.sniHosts) {
if (cfg.sniHosts.isNotEmpty()) cfg.sniHosts.toSet()
else DEFAULT_SNI_POOL.toSet() + setOfNotNull(cfg.frontDomain.takeIf { it.isNotBlank() })
}
val probeState = remember { mutableStateMapOf<String, ProbeState>() }
fun probe(sni: String) {
probeState[sni] = ProbeState.InFlight
scope.launch {
val json = withContext(Dispatchers.IO) {
runCatching { Native.testSni(cfg.googleIp, sni) }.getOrNull()
}
probeState[sni] = parseProbeResult(json)
}
}
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
Text(
stringResource(R.string.help_sni_pool),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
displayed.forEach { sni ->
val enabled = sni in enabledSet
SniRow(
sni = sni,
enabled = enabled,
state = probeState[sni] ?: ProbeState.Idle,
onToggle = { nowEnabled ->
val next = if (nowEnabled) {
(cfg.sniHosts.takeIf { it.isNotEmpty() } ?: emptyList()) + sni
} else {
val current = if (cfg.sniHosts.isNotEmpty()) cfg.sniHosts else enabledSet.toList()
current.filter { it != sni }
}
onChange(cfg.copy(sniHosts = next.distinct()))
},
onTest = { probe(sni) },
)
}
// Custom-add row.
var custom by remember { mutableStateOf("") }
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp),
modifier = Modifier.fillMaxWidth(),
) {
OutlinedTextField(
value = custom,
onValueChange = { custom = it },
label = { Text(stringResource(R.string.field_add_custom_sni)) },
// Accept a pasted list — users (issue #47) want to dump a
// whole list of subdomains in one go. We split on newlines,
// commas, semicolons, and whitespace so formats like
// www.google.com\nmail.google.com\ndrive.google.com
// www.google.com, mail.google.com
// www.google.com mail.google.com
// all do the right thing on Add.
singleLine = false,