3030import android .graphics .drawable .Icon ;
3131import android .net .Uri ;
3232import android .net .VpnService ;
33+ import android .Manifest ;
3334import android .os .Build ;
3435import android .os .Bundle ;
3536import android .os .IBinder ;
3637import android .provider .Settings ;
3738import android .view .View ;
3839import android .widget .TextView ;
40+ import android .widget .Button ;
41+ import android .widget .ImageView ;
3942import android .widget .Toast ;
4043import android .widget .ToggleButton ;
4144
7073
7174import java .util .List ;
7275import java .util .Optional ;
73- import java .util .concurrent .atomic .AtomicInteger ;
7476
7577import lombok .Getter ;
7678
@@ -94,11 +96,25 @@ public class HomeActivity extends AppCompatActivity {
9496 private View homeTrafficFrame ;
9597 private View permissionWarningFrame ;
9698 private TrafficSpeedChart trafficSpeedChart ;
99+ private View trafficChartDivider ;
100+ private int trafficFrameMinHeight ;
97101
98102 private CustomSpinner spinnerServers ;
99103
100104 private ToggleButton startStopButton ;
101105
106+ // Background-setup checklist dialog (notifications / battery / pin), refreshed on resume
107+ private AlertDialog backgroundSetupDialog ;
108+ private ImageView notificationsStateIcon ;
109+ private ImageView batteryStateIcon ;
110+ private Button backgroundSetupContinueButton ;
111+ // Notifications/battery are gated on the real grant state; only the Xiaomi "lock in Security"
112+ // step (which can't be read back) is gated on the user having opened it.
113+ private boolean visitedPin ;
114+ private boolean connectAfterBackgroundSetup ;
115+ // POST_NOTIFICATIONS permanently denied — the system dialog won't show again, use settings.
116+ private boolean notificationPermanentlyDenied ;
117+
102118 //for service binding
103119 private ServiceConnection connection ;
104120 private BottomNavigationView bottomNavigationView ;
@@ -163,6 +179,9 @@ private void initializeVariable() {
163179 /*View containers to hide*/
164180 homeTrafficFrame = findViewById (R .id .home_traffic_frame );
165181 trafficSpeedChart = findViewById (R .id .home_traffic_chart );
182+ trafficChartDivider = findViewById (R .id .home_traffic_chart_divider );
183+ trafficFrameMinHeight = ((ConstraintLayout .LayoutParams ) homeTrafficFrame .getLayoutParams ()).matchConstraintMinHeight ;
184+ applyTrafficChartVisibility ();
166185 connectionTimeFrame = findViewById (R .id .home_connection_timer_frame );
167186 serverInfoFrame = findViewById (R .id .home_server_info_frame );
168187
@@ -254,6 +273,8 @@ private void initializeVariable() {
254273 bottomNavigationView .setOnItemSelectedListener (new CustomBottomNavigationListener (this , R .id .menuHome ));
255274
256275 permissionWarningFrame = findViewById (R .id .home_permission_warning_frame );
276+ // Re-entry point: tapping the warning re-opens the checklist (without forcing a connect).
277+ permissionWarningFrame .setOnClickListener (v -> showBackgroundSetupDialog (false ));
257278
258279 // hide
259280 disconnectedStateUiItems ();
@@ -315,6 +336,14 @@ protected void onResume() {
315336 bottomNavigationView .setSelectedItemId (R .id .menuHome );
316337 }
317338
339+ // Returned from a system settings screen — refresh the checklist marks.
340+ if (backgroundSetupDialog != null && backgroundSetupDialog .isShowing ()) {
341+ refreshBackgroundSetupStates ();
342+ }
343+
344+ // The setting may have changed while this activity was paused (advanced settings screen).
345+ applyTrafficChartVisibility ();
346+
318347 Optional .ofNullable (viewModel .getServiceStateMutableLiveData ())
319348 .map (LiveData ::getValue )
320349 .map (FptnServiceState ::getConnectionState )
@@ -325,6 +354,21 @@ protected void onResume() {
325354 });
326355 }
327356
357+ // Toggles only the speed chart (advanced setting); speed and traffic rows stay visible,
358+ // and the traffic card shrinks to its remaining content instead of keeping the chart's space.
359+ private void applyTrafficChartVisibility () {
360+ boolean showChart = SharedPrefUtils .getShowTrafficChart (this );
361+ trafficSpeedChart .setVisibility (showChart ? View .VISIBLE : View .GONE );
362+ trafficChartDivider .setVisibility (showChart ? View .VISIBLE : View .GONE );
363+
364+ ConstraintLayout .LayoutParams params = (ConstraintLayout .LayoutParams ) homeTrafficFrame .getLayoutParams ();
365+ params .height = showChart ? ConstraintLayout .LayoutParams .MATCH_CONSTRAINT : ConstraintLayout .LayoutParams .WRAP_CONTENT ;
366+ params .verticalBias = showChart ? 0.5f : 0f ;
367+ // The XML min height (140dp) keeps padding the card even in wrap mode — drop it with the chart.
368+ params .matchConstraintMinHeight = showChart ? trafficFrameMinHeight : 0 ;
369+ homeTrafficFrame .setLayoutParams (params );
370+ }
371+
328372 private void disconnectedStateUiItems () {
329373 ViewUtils .hideView (connectionTimeFrame );
330374 ViewUtils .hideView (serverInfoFrame );
@@ -346,40 +390,34 @@ private void connectedStateUiItems() {
346390 ViewUtils .showView (serverInfoFrame );
347391 ViewUtils .showView (homeTrafficFrame );
348392
349- // check is need to show permissions warning
350- if (!PermissionsUtils .isAllOptionalPermissionsGranted (this )) {
393+ // Show the warning banner on exactly the same condition the checklist opens on, so the two
394+ // never disagree (and tapping the banner can actually clear it).
395+ if (needsBackgroundSetup ()) {
351396 ViewUtils .showView (permissionWarningFrame );
352397 }
353398
354399 ViewUtils .hideView (spinnerServers );
355400 }
356401
402+ // Single source of truth for "background setup incomplete": notifications + battery on every
403+ // device, plus the Xiaomi "lock in Security" step (persisted once opened, since it can't be
404+ // read back). Used by both the connect gate and the home warning banner.
405+ private boolean needsBackgroundSetup () {
406+ return !PermissionsUtils .checkNotificationEnabled (this )
407+ || !PermissionsUtils .checkBatteryOptimizations (this )
408+ || (PermissionsUtils .isXiaomi () && !SharedPrefUtils .isXiaomiPinDone (this ));
409+ }
410+
357411 public void onClickToStartStop (View v ) {
358412 ConnectionState currentConnectionState = Optional .ofNullable (viewModel .getServiceStateMutableLiveData ().getValue ())
359413 .map (FptnServiceState ::getConnectionState )
360414 .orElse (ConnectionState .DISCONNECTED );
361415 if (currentConnectionState == ConnectionState .DISCONNECTED ) {
362416
363- // Check notification enabled
364- if (!PermissionsUtils .checkNotificationEnabled (this )) {
365- Toast .makeText (this , R .string .notifications_request_title , Toast .LENGTH_SHORT )
366- .show ();
367-
368- Intent intent = new Intent ();
369- intent .setAction (Settings .ACTION_APP_NOTIFICATION_SETTINGS );
370- intent .putExtra (Settings .EXTRA_APP_PACKAGE , getPackageName ());
371- intent .setFlags (Intent .FLAG_ACTIVITY_NEW_TASK );
372- startActivity (intent );
373-
417+ if (needsBackgroundSetup ()) {
374418 startStopButton .setChecked (false );
375- return ;
376- }
377-
378- // Ask for optional permissions until user grants them; once granted, never ask again
379- if (!SharedPrefUtils .isPermissionsRequested (this )) {
380- startStopButton .setChecked (false );
381- requestRequiredPermissions ();
382- // proceedToVpnConnect() is called from the dialog callbacks — not here
419+ showBackgroundSetupDialog (true );
420+ // proceedToVpnConnect() is called from the dialog's Continue button — not here
383421 return ;
384422 }
385423
@@ -400,6 +438,129 @@ private void proceedToVpnConnect() {
400438 connectVpn ();
401439 }
402440
441+ private void showBackgroundSetupDialog (boolean connectOnDone ) {
442+ connectAfterBackgroundSetup = connectOnDone ;
443+ visitedPin = false ;
444+
445+ View dialogView = getLayoutInflater ().inflate (R .layout .dialog_background_setup , null );
446+ // Buttons live in the dialog's fixed footer, so on small screens they stay visible while the
447+ // checklist itself scrolls. "Continue" (positive) is gated in refresh(); "Later" (negative)
448+ // is always available so the user is never trapped. Both dismiss and, in the connect flow,
449+ // proceed to connect. The checklist reappears next connect while something is outstanding.
450+ backgroundSetupDialog = new AlertDialog .Builder (this )
451+ .setView (dialogView )
452+ .setCancelable (true )
453+ .setPositiveButton (R .string .background_setup_done , (d , w ) -> {
454+ if (connectAfterBackgroundSetup ) {
455+ proceedToVpnConnect ();
456+ }
457+ })
458+ .setNegativeButton (R .string .background_setup_later , (d , w ) -> {
459+ if (connectAfterBackgroundSetup ) {
460+ proceedToVpnConnect ();
461+ }
462+ })
463+ .create ();
464+
465+ View rowNotifications = dialogView .findViewById (R .id .row_notifications );
466+ View rowBattery = dialogView .findViewById (R .id .row_battery );
467+ View rowPin = dialogView .findViewById (R .id .row_pin );
468+ notificationsStateIcon = dialogView .findViewById (R .id .row_notifications_state );
469+ batteryStateIcon = dialogView .findViewById (R .id .row_battery_state );
470+
471+ // Notifications and battery apply to every device; the "lock in Security" step is Xiaomi-only.
472+ rowPin .setVisibility (PermissionsUtils .isXiaomi () ? View .VISIBLE : View .GONE );
473+
474+ // Rows open system screens; keep the dialog up so the user does every step and then taps
475+ // Continue. State refreshes on resume, so the check marks reflect what was actually granted.
476+ rowNotifications .setOnClickListener (v -> {
477+ startStopButton .setChecked (false );
478+ requestNotifications ();
479+ });
480+ rowBattery .setOnClickListener (v -> {
481+ startStopButton .setChecked (false );
482+ SharedPrefUtils .saveBatteryOptimizationRequested (this , true );
483+ openBatteryOptimizationSettings ();
484+ });
485+ rowPin .setOnClickListener (v -> {
486+ startStopButton .setChecked (false );
487+ visitedPin = true ;
488+ SharedPrefUtils .saveXiaomiPinDone (this , true );
489+ refreshBackgroundSetupStates ();
490+ PermissionsUtils .openMiuiSecurityApp (this );
491+ });
492+
493+ // getButton() is only valid after show() — grab "Continue" then and gate it.
494+ backgroundSetupDialog .setOnShowListener (d -> {
495+ backgroundSetupContinueButton = backgroundSetupDialog .getButton (AlertDialog .BUTTON_POSITIVE );
496+ refreshBackgroundSetupStates ();
497+ });
498+ backgroundSetupDialog .setOnDismissListener (d -> {
499+ backgroundSetupDialog = null ;
500+ notificationsStateIcon = null ;
501+ batteryStateIcon = null ;
502+ backgroundSetupContinueButton = null ;
503+ });
504+ backgroundSetupDialog .show ();
505+ }
506+
507+ // Notifications and battery are gated on the REAL grant state (honest check marks); the Xiaomi
508+ // pin step is gated on the user having opened it, since MIUI exposes no way to read it back.
509+ // Called again on resume so state updates after returning from a system screen.
510+ private void refreshBackgroundSetupStates () {
511+ boolean notificationsDone = PermissionsUtils .checkNotificationEnabled (this );
512+ boolean batteryDone = PermissionsUtils .checkBatteryOptimizations (this );
513+ boolean pinDone = !PermissionsUtils .isXiaomi () || visitedPin || SharedPrefUtils .isXiaomiPinDone (this );
514+
515+ if (notificationsStateIcon != null ) {
516+ notificationsStateIcon .setImageResource (notificationsDone
517+ ? R .drawable .ic_check_16 : R .drawable .ic_outline_arrow_forward_ios_16 );
518+ }
519+ if (batteryStateIcon != null ) {
520+ batteryStateIcon .setImageResource (batteryDone
521+ ? R .drawable .ic_check_16 : R .drawable .ic_outline_arrow_forward_ios_16 );
522+ }
523+ if (backgroundSetupContinueButton != null ) {
524+ backgroundSetupContinueButton .setEnabled (notificationsDone && batteryDone && pinDone );
525+ }
526+ }
527+
528+ private void requestNotifications () {
529+ if (PermissionsUtils .checkNotificationEnabled (this )) {
530+ refreshBackgroundSetupStates ();
531+ return ;
532+ }
533+ // Android 13+: one-tap runtime prompt while it's still available; otherwise settings.
534+ if (Build .VERSION .SDK_INT >= Build .VERSION_CODES .TIRAMISU && !notificationPermanentlyDenied ) {
535+ notificationPermissionLauncher .launch (Manifest .permission .POST_NOTIFICATIONS );
536+ } else {
537+ openNotificationSettings ();
538+ }
539+ }
540+
541+ private void openNotificationSettings () {
542+ try {
543+ Intent intent = new Intent (Settings .ACTION_APP_NOTIFICATION_SETTINGS );
544+ intent .putExtra (Settings .EXTRA_APP_PACKAGE , getPackageName ());
545+ intent .setFlags (Intent .FLAG_ACTIVITY_NEW_TASK );
546+ startActivity (intent );
547+ } catch (Exception e ) {
548+ XLog .tag (TAG ).w ("Failed to open notification settings: %s" , e .getMessage ());
549+ PermissionsUtils .openMiuiBackgroundSettings (this );
550+ }
551+ }
552+
553+ @ SuppressLint ("BatteryLife" )
554+ private void openBatteryOptimizationSettings () {
555+ try {
556+ Intent intent = new Intent (Settings .ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS );
557+ intent .setData (Uri .parse ("package:" + getPackageName ()));
558+ startActivity (intent );
559+ } catch (Exception e ) {
560+ PermissionsUtils .openMiuiBackgroundSettings (this );
561+ }
562+ }
563+
403564 private void connectVpn () {
404565 Intent intent = VpnService .prepare (this );
405566 if (intent != null ) {
@@ -476,60 +637,16 @@ private void requestAddTileService() {
476637 }
477638 );
478639
479- private final AtomicInteger requestedPermissions = new AtomicInteger (0 );
480-
481- private final ActivityResultLauncher <Intent > settingsPermissionActivityResultLauncher = registerForActivityResult (
482- new ActivityResultContracts .StartActivityForResult (),
483- activityResult -> {
484- if (activityResult != null && activityResult .getResultCode () == RESULT_OK ) {
485- XLog .tag (TAG ).i ("System permission granted via Settings" );
486- } else {
487- XLog .tag (TAG ).w ("System permission denied via Settings" );
488- }
489- if (requestedPermissions .decrementAndGet () == 0 ) {
490- if (PermissionsUtils .isAllOptionalPermissionsGranted (this )) {
491- SharedPrefUtils .savePermissionsRequested (this , true );
492- }
493- proceedToVpnConnect ();
640+ private final ActivityResultLauncher <String > notificationPermissionLauncher = registerForActivityResult (
641+ new ActivityResultContracts .RequestPermission (),
642+ granted -> {
643+ // If it wasn't granted and the system won't show the dialog again, the only path
644+ // left is the notification settings screen.
645+ if (!granted && !shouldShowRequestPermissionRationale (Manifest .permission .POST_NOTIFICATIONS )) {
646+ notificationPermanentlyDenied = true ;
494647 }
648+ refreshBackgroundSetupStates ();
495649 }
496650 );
497651
498- /* PERMISSIONS PART */
499- @ SuppressLint ("BatteryLife" )
500- private void requestRequiredPermissions () {
501- new AlertDialog .Builder (this )
502- .setTitle (getString (R .string .permission_request_title ))
503- .setMessage (getString (R .string .permission_request_text ))
504- .setPositiveButton (getString (R .string .grant ), (d , w ) -> {
505- // Battery optimization permission
506- if (!PermissionsUtils .checkBatteryOptimizations (this )) {
507- //Manifest.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS
508- requestedPermissions .incrementAndGet ();
509- startActivityWithSettings (Settings .ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS );
510- }
511- // Background data transfer restriction permission
512- if (!PermissionsUtils .checkBackgroundDataTransferRestrictions (this )) {
513- requestedPermissions .incrementAndGet ();
514- startActivityWithSettings (Settings .ACTION_IGNORE_BACKGROUND_DATA_RESTRICTIONS_SETTINGS );
515- }
516- // Nothing to open — permissions already granted, save and proceed
517- if (requestedPermissions .get () == 0 ) {
518- SharedPrefUtils .savePermissionsRequested (this , true );
519- proceedToVpnConnect ();
520- }
521- })
522- .setNegativeButton (getString (R .string .deny ), (dialog , which ) -> {
523- XLog .tag (TAG ).w ("Optional permissions denied by user — continuing without them" );
524- proceedToVpnConnect ();
525- })
526- .show ();
527- }
528-
529- private void startActivityWithSettings (String settingsAction ) {
530- Intent intent = new Intent (settingsAction );
531- intent .setData (Uri .parse ("package:" + getPackageName ()));
532- settingsPermissionActivityResultLauncher .launch (intent );
533- }
534-
535652}
0 commit comments