Skip to content

Commit 9d89ef6

Browse files
committed
[feature] apply speed chart toggle on home screen
1 parent f53ec8a commit 9d89ef6

1 file changed

Lines changed: 191 additions & 74 deletions

File tree

app/src/main/java/org/fptn/vpn/views/home/HomeActivity.java

Lines changed: 191 additions & 74 deletions
Original file line numberDiff line numberDiff line change
@@ -30,12 +30,15 @@
3030
import android.graphics.drawable.Icon;
3131
import android.net.Uri;
3232
import android.net.VpnService;
33+
import android.Manifest;
3334
import android.os.Build;
3435
import android.os.Bundle;
3536
import android.os.IBinder;
3637
import android.provider.Settings;
3738
import android.view.View;
3839
import android.widget.TextView;
40+
import android.widget.Button;
41+
import android.widget.ImageView;
3942
import android.widget.Toast;
4043
import android.widget.ToggleButton;
4144

@@ -70,7 +73,6 @@
7073

7174
import java.util.List;
7275
import java.util.Optional;
73-
import java.util.concurrent.atomic.AtomicInteger;
7476

7577
import 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

Comments
 (0)