Implement Android 17+ local network permission check

This commit is contained in:
M M Arif
2026-06-23 19:34:28 +05:00
parent c7da7b038e
commit 190337338c
11 changed files with 418 additions and 4 deletions

View File

@@ -2,6 +2,7 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET"/>
<uses-permission android:name="android.permission.ACCESS_LOCAL_NETWORK"/>
<uses-permission android:name="android.permission.VIBRATE"/>
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/>
<uses-permission android:name="android.permission.CHANGE_NETWORK_STATE"/>

View File

@@ -34,6 +34,7 @@ import org.gitnex.tea4j.v2.models.GeneralAPISettings;
import org.gitnex.tea4j.v2.models.ServerVersion;
import org.gitnex.tea4j.v2.models.User;
import org.mian.gitnex.R;
import org.mian.gitnex.bottomsheets.AndroidPermissionsBottomSheet;
import org.mian.gitnex.clients.RetrofitClient;
import org.mian.gitnex.database.api.BaseApi;
import org.mian.gitnex.database.api.UserAccountsApi;
@@ -45,6 +46,7 @@ import org.mian.gitnex.databinding.BottomsheetTokenHelpBinding;
import org.mian.gitnex.helpers.AppUtil;
import org.mian.gitnex.helpers.NetworkStatusObserver;
import org.mian.gitnex.helpers.PathsHelper;
import org.mian.gitnex.helpers.PermissionHelper;
import org.mian.gitnex.helpers.Toasty;
import org.mian.gitnex.helpers.UIHelper;
import org.mian.gitnex.helpers.UrlHelper;
@@ -220,6 +222,30 @@ public class LoginActivity extends BaseActivity {
activityLoginBinding.tokenHelper.setOnClickListener(token -> showTokenHelpSheet());
activityLoginBinding.grantLocalNetworkPermission.setVisibility(
PermissionHelper.isBelowApiLevel(37)
|| PermissionHelper.isGranted(
ctx, "android.permission.ACCESS_LOCAL_NETWORK", 37)
? View.GONE
: View.VISIBLE);
activityLoginBinding.grantLocalNetworkPermission.setOnClickListener(
v -> {
AndroidPermissionsBottomSheet sheet =
AndroidPermissionsBottomSheet.newInstance(
"local_network_permission",
"android.permission.ACCESS_LOCAL_NETWORK",
37);
sheet.setOnPermissionResultListener(
granted -> {
if (granted) {
activityLoginBinding.grantLocalNetworkPermission.setVisibility(
View.GONE);
}
});
sheet.show(getSupportFragmentManager(), "AndroidPermissionsBottomSheet");
});
networkStatusObserver.registerNetworkStatusListener(
hasNetworkConnection ->
runOnUiThread(

View File

@@ -0,0 +1,149 @@
package org.mian.gitnex.bottomsheets;
import android.app.Dialog;
import android.content.Intent;
import android.net.Uri;
import android.os.Bundle;
import android.provider.Settings;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import androidx.activity.result.ActivityResultLauncher;
import androidx.activity.result.contract.ActivityResultContracts;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import com.google.android.material.bottomsheet.BottomSheetDialog;
import com.google.android.material.bottomsheet.BottomSheetDialogFragment;
import org.mian.gitnex.R;
import org.mian.gitnex.databinding.BottomsheetAndroidPermissionsBinding;
import org.mian.gitnex.helpers.AppUtil;
import org.mian.gitnex.helpers.PermissionHelper;
/**
* @author mmarif
*/
public class AndroidPermissionsBottomSheet extends BottomSheetDialogFragment {
private BottomsheetAndroidPermissionsBinding binding;
private String permission;
private int requiredApiLevel;
private String title;
private String description;
private ActivityResultLauncher<String> permissionLauncher;
private OnPermissionResultListener listener;
public interface OnPermissionResultListener {
void onPermissionResult(boolean granted);
}
public static AndroidPermissionsBottomSheet newInstance(
String permissionType, String permission, int requiredApiLevel) {
AndroidPermissionsBottomSheet sheet = new AndroidPermissionsBottomSheet();
Bundle args = new Bundle();
args.putString("permissionType", permissionType);
args.putString("permission", permission);
args.putInt("requiredApiLevel", requiredApiLevel);
sheet.setArguments(args);
return sheet;
}
public void setOnPermissionResultListener(OnPermissionResultListener listener) {
this.listener = listener;
}
@Override
public void onCreate(@Nullable Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
if (getArguments() != null) {
String permissionType = getArguments().getString("permissionType");
permission = getArguments().getString("permission");
requiredApiLevel = getArguments().getInt("requiredApiLevel");
if ((permissionType != null ? permissionType : "").equals("local_network_permission")) {
title = getString(R.string.localNetworkPermissionTitle);
description = getString(R.string.localNetworkPermissionDescription);
} else {
title = getString(R.string.permissionRequired);
description = getString(R.string.permissionRequiredDescription);
}
} else {
permission = null;
requiredApiLevel = 0;
}
permissionLauncher =
registerForActivityResult(
new ActivityResultContracts.RequestPermission(),
isGranted -> {
updateButtonState();
if (listener != null) {
listener.onPermissionResult(isGranted);
}
});
}
@Nullable @Override
public View onCreateView(
@NonNull LayoutInflater inflater,
@Nullable ViewGroup container,
@Nullable Bundle savedInstanceState) {
binding = BottomsheetAndroidPermissionsBinding.inflate(inflater, container, false);
binding.permissionTitle.setText(title);
binding.permissionDescription.setText(description);
updateButtonState();
binding.btnGrantPermission.setOnClickListener(
v -> {
if (permission == null) return;
if (PermissionHelper.isGranted(
requireContext(), permission, requiredApiLevel)) {
return;
}
if (PermissionHelper.isPermanentlyDenied(
requireActivity(), permission, requiredApiLevel)) {
Intent intent = new Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS);
intent.setData(Uri.parse("package:" + requireContext().getPackageName()));
startActivity(intent);
} else {
permissionLauncher.launch(permission);
}
});
return binding.getRoot();
}
private void updateButtonState() {
if (permission == null) return;
if (PermissionHelper.isGranted(requireContext(), permission, requiredApiLevel)) {
binding.btnGrantPermission.setText(R.string.permissionGranted);
binding.btnGrantPermission.setEnabled(false);
} else if (PermissionHelper.isPermanentlyDenied(
requireActivity(), permission, requiredApiLevel)) {
binding.btnGrantPermission.setText(R.string.openSettings);
binding.btnGrantPermission.setEnabled(true);
} else {
binding.btnGrantPermission.setText(R.string.grantPermission);
binding.btnGrantPermission.setEnabled(true);
}
}
@Override
public void onStart() {
super.onStart();
Dialog dialog = getDialog();
if (dialog instanceof BottomSheetDialog) {
AppUtil.applySheetStyle((BottomSheetDialog) dialog, true);
}
}
@Override
public void onDestroyView() {
super.onDestroyView();
binding = null;
}
}

View File

@@ -2,7 +2,10 @@ package org.mian.gitnex.bottomsheets;
import android.app.Dialog;
import android.content.Context;
import android.content.Intent;
import android.net.Uri;
import android.os.Bundle;
import android.provider.Settings;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
@@ -22,6 +25,7 @@ import org.mian.gitnex.databinding.BottomsheetSettingsSecurityBinding;
import org.mian.gitnex.helpers.AppDatabaseSettings;
import org.mian.gitnex.helpers.AppUIStateManager;
import org.mian.gitnex.helpers.AppUtil;
import org.mian.gitnex.helpers.PermissionHelper;
import org.mian.gitnex.helpers.Toasty;
import org.mian.gitnex.helpers.ssl.MemorizingTrustManager;
@@ -189,6 +193,16 @@ public class SettingsSecurityBottomSheet extends BottomSheetDialogFragment {
}
});
if (!PermissionHelper.isBelowApiLevel(37)) {
binding.localNetworkPermissionFrame.setVisibility(View.VISIBLE);
binding.openLocalNetworkPermissionSettings.setOnClickListener(
v -> {
Intent intent = new Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS);
intent.setData(Uri.parse("package:" + requireContext().getPackageName()));
startActivity(intent);
});
}
binding.clearCacheButton.setOnClickListener(
v -> {
MaterialAlertDialogBuilder dialog =

View File

@@ -146,13 +146,13 @@ public abstract class GitnexDatabase extends RoomDatabase {
"CREATE TABLE IF NOT EXISTS 'bookmarks' ("
+ "'bookmarkId' INTEGER NOT NULL, "
+ "'accountId' INTEGER NOT NULL, "
+ "'type' TEXT NOT NULL, "
+ "'type' TEXT, "
+ "'bookmarkAction' TEXT, "
+ "'owner' TEXT, "
+ "'repo' TEXT, "
+ "'identifier' TEXT, "
+ "'branch' TEXT, "
+ "'title' TEXT NOT NULL, "
+ "'title' TEXT, "
+ "'url' TEXT, "
+ "'createdAt' INTEGER NOT NULL, "
+ "PRIMARY KEY('bookmarkId'))");

View File

@@ -0,0 +1,48 @@
package org.mian.gitnex.helpers;
import android.content.Context;
import android.content.pm.PackageManager;
import android.os.Build;
import androidx.core.content.ContextCompat;
/**
* @author mmarif
*/
public class PermissionHelper {
private PermissionHelper() {}
public static boolean isBelowApiLevel(int apiLevel) {
return Build.VERSION.SDK_INT < apiLevel;
}
public static boolean isGranted(Context context, String permission, int requiredApiLevel) {
if (isBelowApiLevel(requiredApiLevel)) {
return true;
}
return ContextCompat.checkSelfPermission(context, permission)
== PackageManager.PERMISSION_GRANTED;
}
public static boolean shouldShowRationale(
android.app.Activity activity, String permission, int requiredApiLevel) {
if (isBelowApiLevel(requiredApiLevel)) {
return false;
}
if (isGranted(activity, permission, requiredApiLevel)) {
return false;
}
return activity.shouldShowRequestPermissionRationale(permission);
}
public static boolean isPermanentlyDenied(
android.app.Activity activity, String permission, int requiredApiLevel) {
if (isBelowApiLevel(requiredApiLevel)) {
return false;
}
if (isGranted(activity, permission, requiredApiLevel)) {
return false;
}
return !activity.shouldShowRequestPermissionRationale(permission);
}
}

View File

@@ -0,0 +1,62 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:pathData="M6,9a6,6 0,1 0,12 0a6,6 0,0 0,-12 0"
android:strokeLineJoin="round"
android:strokeWidth="2"
android:fillColor="#00000000"
android:strokeColor="?attr/iconsColor"
android:strokeLineCap="round"/>
<path
android:pathData="M12,3c1.333,0.333 2,2.333 2,6s-0.667,5.667 -2,6"
android:strokeLineJoin="round"
android:strokeWidth="2"
android:fillColor="#00000000"
android:strokeColor="?attr/iconsColor"
android:strokeLineCap="round"/>
<path
android:pathData="M12,3c-1.333,0.333 -2,2.333 -2,6s0.667,5.667 2,6"
android:strokeLineJoin="round"
android:strokeWidth="2"
android:fillColor="#00000000"
android:strokeColor="?attr/iconsColor"
android:strokeLineCap="round"/>
<path
android:pathData="M6,9h12"
android:strokeLineJoin="round"
android:strokeWidth="2"
android:fillColor="#00000000"
android:strokeColor="?attr/iconsColor"
android:strokeLineCap="round"/>
<path
android:pathData="M3,20h7"
android:strokeLineJoin="round"
android:strokeWidth="2"
android:fillColor="#00000000"
android:strokeColor="?attr/iconsColor"
android:strokeLineCap="round"/>
<path
android:pathData="M14,20h7"
android:strokeLineJoin="round"
android:strokeWidth="2"
android:fillColor="#00000000"
android:strokeColor="?attr/iconsColor"
android:strokeLineCap="round"/>
<path
android:pathData="M10,20a2,2 0,1 0,4 0a2,2 0,0 0,-4 0"
android:strokeLineJoin="round"
android:strokeWidth="2"
android:fillColor="#00000000"
android:strokeColor="?attr/iconsColor"
android:strokeLineCap="round"/>
<path
android:pathData="M12,15v3"
android:strokeLineJoin="round"
android:strokeWidth="2"
android:fillColor="#00000000"
android:strokeColor="?attr/iconsColor"
android:strokeLineCap="round"/>
</vector>

View File

@@ -259,6 +259,19 @@
</androidx.constraintlayout.widget.ConstraintLayout>
<com.google.android.material.button.MaterialButton
android:id="@+id/grantLocalNetworkPermission"
style="@style/Widget.Material3.Button.TonalButton"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="@dimen/dimen8dp"
android:text="@string/localNetworkPermissionTitle"
android:textColor="?attr/inputTextColor"
android:visibility="gone"
app:icon="@drawable/ic_network"
app:iconGravity="textStart"
app:iconTint="?attr/iconsColor" />
<TextView
android:id="@+id/appVersion"
android:layout_width="match_parent"

View File

@@ -0,0 +1,55 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="wrap_content">
<com.google.android.material.bottomsheet.BottomSheetDragHandleView
android:id="@+id/drag_handle"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:layout_constraintTop_toTopOf="parent" />
<androidx.core.widget.NestedScrollView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:clipToPadding="false"
android:paddingHorizontal="@dimen/dimen16dp"
android:paddingBottom="@dimen/dimen32dp"
app:layout_constrainedHeight="true"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintTop_toBottomOf="@id/drag_handle">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">
<TextView
android:id="@+id/permissionTitle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textAppearance="@style/TextAppearance.Material3.HeadlineSmall"
android:textStyle="bold" />
<TextView
android:id="@+id/permissionDescription"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="@dimen/dimen12dp"
android:alpha="0.8"
android:textAppearance="@style/TextAppearance.Material3.BodyMedium"
android:textColor="?attr/primaryTextColor" />
<com.google.android.material.button.MaterialButton
android:id="@+id/btnGrantPermission"
style="@style/Widget.Material3.Button"
android:layout_width="match_parent"
android:layout_height="@dimen/dimen56dp"
android:layout_marginTop="@dimen/dimen28dp" />
</LinearLayout>
</androidx.core.widget.NestedScrollView>
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@@ -230,11 +230,50 @@
</LinearLayout>
<LinearLayout
android:id="@+id/localNetworkPermissionFrame"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="@dimen/dimen24dp"
android:orientation="vertical"
android:visibility="gone">
<TextView
android:id="@+id/localNetworkPermissionHeader"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/localNetworkPermissionTitle"
android:textColor="?attr/primaryTextColor"
android:textSize="@dimen/dimen16sp" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="@dimen/dimen4dp"
android:text="@string/localNetworkPermissionDescription"
android:textColor="?attr/hintColor"
android:textSize="@dimen/dimen12sp" />
<com.google.android.material.button.MaterialButton
android:id="@+id/openLocalNetworkPermissionSettings"
style="@style/Widget.Material3.Button"
android:layout_width="match_parent"
android:layout_height="@dimen/dimen56dp"
android:layout_marginTop="@dimen/dimen12dp"
android:text="@string/openSettings"
android:backgroundTint="?attr/iconsColor"
android:textColor="?attr/materialCardBackgroundColor"
app:icon="@drawable/ic_settings"
app:iconGravity="textStart"
app:iconTint="?attr/materialCardBackgroundColor" />
</LinearLayout>
<com.google.android.material.button.MaterialButton
android:id="@+id/clearCacheButton"
style="@style/Widget.Material3.Button"
android:layout_width="match_parent"
android:layout_height="@dimen/dimen54dp"
android:layout_height="@dimen/dimen56dp"
android:layout_marginTop="@dimen/dimen24dp"
android:backgroundTint="?attr/iconsColor"
android:text="@string/clearCacheSelectionHeaderText"
@@ -244,7 +283,7 @@
android:id="@+id/deleteCertsButton"
style="@style/Widget.Material3.Button"
android:layout_width="match_parent"
android:layout_height="@dimen/dimen54dp"
android:layout_height="@dimen/dimen56dp"
android:layout_marginTop="@dimen/dimen16dp"
android:backgroundTint="?attr/iconsColor"
android:text="@string/settingsCertsSelectorHeader"

View File

@@ -768,6 +768,13 @@
<string name="bookmark_tab">Bookmark tab</string>
<string name="repository_section">Repository section</string>
<string name="wiki_page">Wiki page</string>
<string name="localNetworkPermissionTitle">Local Network Permission</string>
<string name="localNetworkPermissionDescription">Android 17+ requires this permission to connect to local network. Without it, connections to servers on your local network may fail.</string>
<string name="grantPermission">Grant permission</string>
<string name="openSettings">Open settings</string>
<string name="permissionGranted">Permission granted</string>
<string name="permissionRequired">Permission required</string>
<string name="permissionRequiredDescription">This permission is needed for certain features to work properly.</string>
<!-- generic copy -->
<string name="all">All</string>