Move the rest of the permissions classes.

This commit is contained in:
Alex Hart
2026-02-10 13:25:48 -04:00
committed by Michelle Tang
parent f90ba45940
commit 58d2c92102
260 changed files with 12375 additions and 350 deletions

View File

@@ -5,18 +5,28 @@
package org.signal.core.ui
import android.app.Application
object CoreUiDependencies {
private lateinit var _application: Application
private lateinit var _provider: Provider
fun init(provider: Provider) {
fun init(application: Application, provider: Provider) {
if (this::_provider.isInitialized) {
return
}
_application = application
_provider = provider
}
val application: Application
get() = _application
val packageId: String
get() = _provider.providePackageId()
val isIncognitoKeyboardEnabled: Boolean
get() = _provider.provideIsIncognitoKeyboardEnabled()
@@ -27,6 +37,7 @@ object CoreUiDependencies {
get() = _provider.provideForceSplitPane()
interface Provider {
fun providePackageId(): String
fun provideIsIncognitoKeyboardEnabled(): Boolean
fun provideIsScreenSecurityEnabled(): Boolean
fun provideForceSplitPane(): Boolean

View File

@@ -0,0 +1,186 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.core.ui.permissions
import android.content.Intent
import android.net.Uri
import android.provider.Settings
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.text.InlineTextContent
import androidx.compose.foundation.text.appendInlineContent
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.text.Placeholder
import androidx.compose.ui.text.PlaceholderVerticalAlign
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.core.os.bundleOf
import org.signal.core.ui.R
import org.signal.core.ui.compose.BottomSheets
import org.signal.core.ui.compose.Buttons
import org.signal.core.ui.compose.ComposeBottomSheetDialogFragment
import org.signal.core.ui.compose.DayNightPreviews
import org.signal.core.ui.compose.Previews
private const val PLACEHOLDER = "__RADIO_BUTTON_PLACEHOLDER__"
/**
* Bottom sheet shown when a permission has been previously denied
*
* Displays rationale for the need of a permission and how to grant it
*/
class PermissionDeniedBottomSheet private constructor() : ComposeBottomSheetDialogFragment() {
override val peekHeightPercentage: Float = 0.66f
companion object {
private const val ARG_TITLE = "argument.title_res"
private const val ARG_SUBTITLE = "argument.subtitle_res"
private const val ARG_USE_EXTENDED = "argument.use.extended"
@JvmStatic
fun showPermissionFragment(titleRes: Int, subtitleRes: Int, useExtended: Boolean = false): ComposeBottomSheetDialogFragment {
return PermissionDeniedBottomSheet().apply {
arguments = bundleOf(
ARG_TITLE to titleRes,
ARG_SUBTITLE to subtitleRes,
ARG_USE_EXTENDED to useExtended
)
}
}
}
@Composable
override fun SheetContent() {
PermissionDeniedSheetContent(
titleRes = remember { requireArguments().getInt(ARG_TITLE) },
subtitleRes = remember { requireArguments().getInt(ARG_SUBTITLE) },
useExtended = remember { requireArguments().getBoolean(ARG_USE_EXTENDED) },
onSettingsClicked = this::goToSettings
)
}
private fun goToSettings() {
val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply {
data = Uri.fromParts("package", requireContext().packageName, null)
}
requireContext().startActivity(intent)
dismissAllowingStateLoss()
}
}
@DayNightPreviews
@Composable
private fun PermissionDeniedSheetContentPreview() {
Previews.BottomSheetContentPreview {
PermissionDeniedSheetContent(
titleRes = R.string.PermissionDeniedBottomSheet__settings,
subtitleRes = R.string.PermissionDeniedBottomSheet__1_tap_settings_below,
onSettingsClicked = {}
)
}
}
@Composable
private fun PermissionDeniedSheetContent(
titleRes: Int,
subtitleRes: Int,
useExtended: Boolean = false,
onSettingsClicked: () -> Unit
) {
Column(
modifier = Modifier.padding(start = 24.dp, end = 24.dp, top = 12.dp, bottom = 32.dp)
) {
BottomSheets.Handle(
modifier = Modifier.align(Alignment.CenterHorizontally)
)
Text(
text = stringResource(titleRes),
style = MaterialTheme.typography.headlineSmall,
color = MaterialTheme.colorScheme.onSurface,
textAlign = TextAlign.Center,
modifier = Modifier
.align(Alignment.CenterHorizontally)
.padding(bottom = 12.dp, top = 20.dp)
)
Text(
text = stringResource(subtitleRes),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface,
modifier = Modifier
.align(Alignment.CenterHorizontally)
.padding(bottom = 32.dp)
)
Text(
text = stringResource(R.string.PermissionDeniedBottomSheet__1_tap_settings_below),
color = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.padding(bottom = 24.dp)
)
if (useExtended) {
Text(
text = stringResource(R.string.PermissionDeniedBottomSheet__2_tap_permissions),
color = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.padding(bottom = 24.dp)
)
}
val stringId = if (useExtended) R.string.PermissionDeniedBottomSheet__3_allow_permission else R.string.PermissionDeniedBottomSheet__2_allow_permission
val stepString = stringResource(id = stringId, PLACEHOLDER)
val (stepText, stepInlineContent) = remember(stepString) {
val parts = stepString.split(PLACEHOLDER)
val annotatedString = buildAnnotatedString {
append(parts[0])
appendInlineContent("radio")
append(parts[1])
}
val inlineContentMap = mapOf(
"radio" to InlineTextContent(Placeholder(22.sp, 22.sp, PlaceholderVerticalAlign.Center)) {
Image(
imageVector = ImageVector.vectorResource(id = R.drawable.ic_radio_button_checked),
contentDescription = null,
modifier = Modifier.fillMaxSize()
)
}
)
annotatedString to inlineContentMap
}
Text(
text = stepText,
inlineContent = stepInlineContent,
color = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.padding(bottom = 32.dp)
)
Buttons.LargeTonal(
onClick = onSettingsClicked,
modifier = Modifier
.align(Alignment.CenterHorizontally)
.fillMaxWidth(1f)
) {
Text(text = stringResource(id = R.string.PermissionDeniedBottomSheet__settings))
}
}
}

View File

@@ -0,0 +1,440 @@
package org.signal.core.ui.permissions;
import android.app.Activity;
import android.content.Context;
import android.content.Intent;
import android.content.pm.PackageManager;
import android.net.Uri;
import android.provider.Settings;
import android.util.DisplayMetrics;
import android.view.Display;
import android.view.ViewGroup;
import android.view.WindowManager;
import androidx.annotation.DrawableRes;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.core.app.ActivityCompat;
import androidx.core.content.ContextCompat;
import androidx.fragment.app.Fragment;
import androidx.fragment.app.FragmentManager;
import com.google.android.material.dialog.MaterialAlertDialogBuilder;
import org.signal.core.util.logging.Log;
import org.signal.core.ui.BottomSheetUtil;
import org.signal.core.ui.R;
import org.signal.core.util.LRUCache;
import java.lang.ref.WeakReference;
import java.security.SecureRandom;
import java.util.Arrays;
import java.util.List;
import java.util.Map;
import java.util.function.Consumer;
public class Permissions {
private static final String TAG = Log.tag(Permissions.class);
private static final Map<Integer, PermissionsRequest> OUTSTANDING = new LRUCache<>(2);
public static PermissionsBuilder with(@NonNull Activity activity) {
return new PermissionsBuilder(new ActivityPermissionObject(activity));
}
public static PermissionsBuilder with(@NonNull Fragment fragment) {
return new PermissionsBuilder(new FragmentPermissionObject(fragment));
}
public static class PermissionsBuilder {
private final PermissionObject permissionObject;
private String[] requestedPermissions;
private Runnable allGrantedListener;
private Runnable anyDeniedListener;
private Runnable anyPermanentlyDeniedListener;
private Runnable anyResultListener;
private Consumer<List<String>> someGrantedListener;
private Consumer<List<String>> someDeniedListener;
private Consumer<List<String>> somePermanentlyDeniedListener;
private @DrawableRes int[] rationalDialogHeader;
private String rationaleDialogMessage;
private String rationaleDialogTitle;
private String rationaleDialogDetails;
private boolean rationaleDialogCancelable;
private boolean ifNecesary;
private boolean condition = true;
PermissionsBuilder(PermissionObject permissionObject) {
this.permissionObject = permissionObject;
}
public PermissionsBuilder request(String... requestedPermissions) {
this.requestedPermissions = requestedPermissions;
return this;
}
public PermissionsBuilder ifNecessary() {
this.ifNecesary = true;
return this;
}
public PermissionsBuilder ifNecessary(boolean condition) {
this.ifNecesary = true;
this.condition = condition;
return this;
}
public PermissionsBuilder withRationaleDialog(@NonNull String message, @NonNull @DrawableRes int... headers) {
return withRationaleDialog(message, true, headers);
}
public PermissionsBuilder withRationaleDialog(@NonNull String message, boolean cancelable, @NonNull @DrawableRes int... headers) {
return withRationaleDialog(message, null, null, cancelable, headers);
}
public PermissionsBuilder withRationaleDialog(@NonNull String title, @NonNull String details, @NonNull @DrawableRes int... headers) {
return withRationaleDialog(null, title, details, true, headers);
}
public PermissionsBuilder withRationaleDialog(@NonNull String title, @NonNull String details, boolean cancelable, @NonNull @DrawableRes int... headers) {
return withRationaleDialog(null, title, details, cancelable, headers);
}
public PermissionsBuilder withRationaleDialog(@Nullable String message, @Nullable String title, @Nullable String details, boolean cancelable, @NonNull @DrawableRes int... headers) {
this.rationalDialogHeader = headers;
this.rationaleDialogMessage = message;
this.rationaleDialogTitle = title;
this.rationaleDialogDetails = details;
this.rationaleDialogCancelable = cancelable;
return this;
}
public PermissionsBuilder withPermanentDenialDialog(@NonNull String message) {
return withPermanentDenialDialog(message, null);
}
public PermissionsBuilder withPermanentDenialDialog(@NonNull String message, @Nullable Runnable onDialogDismissed) {
return withPermanentDenialDialog(message, onDialogDismissed, 0, 0, null);
}
public PermissionsBuilder withPermanentDenialDialog(@NonNull String message, @Nullable Runnable onDialogDismissed, int titleRes, int detailsRes, @Nullable FragmentManager fragmentManager) {
return withPermanentDenialDialog(message, onDialogDismissed, titleRes, detailsRes, false, fragmentManager);
}
public PermissionsBuilder withPermanentDenialDialog(@NonNull String message, @Nullable Runnable onDialogDismissed, int titleRes, int detailsRes, boolean useExtended, @Nullable FragmentManager fragmentManager) {
return onAnyPermanentlyDenied(new SettingsDialogListener(permissionObject.getContext(), message, onDialogDismissed, titleRes, detailsRes, useExtended, fragmentManager));
}
public PermissionsBuilder onAllGranted(Runnable allGrantedListener) {
this.allGrantedListener = allGrantedListener;
return this;
}
public PermissionsBuilder onAnyDenied(Runnable anyDeniedListener) {
this.anyDeniedListener = anyDeniedListener;
return this;
}
@SuppressWarnings("WeakerAccess")
public PermissionsBuilder onAnyPermanentlyDenied(Runnable anyPermanentlyDeniedListener) {
this.anyPermanentlyDeniedListener = anyPermanentlyDeniedListener;
return this;
}
public PermissionsBuilder onAnyResult(Runnable anyResultListener) {
this.anyResultListener = anyResultListener;
return this;
}
public PermissionsBuilder onSomeGranted(Consumer<List<String>> someGrantedListener) {
this.someGrantedListener = someGrantedListener;
return this;
}
public PermissionsBuilder onSomeDenied(Consumer<List<String>> someDeniedListener) {
this.someDeniedListener = someDeniedListener;
return this;
}
public PermissionsBuilder onSomePermanentlyDenied(Consumer<List<String>> somePermanentlyDeniedListener) {
this.somePermanentlyDeniedListener = somePermanentlyDeniedListener;
return this;
}
public void execute() {
PermissionsRequest request = new PermissionsRequest(allGrantedListener, anyDeniedListener, anyPermanentlyDeniedListener, anyResultListener,
someGrantedListener, someDeniedListener, somePermanentlyDeniedListener);
if (ifNecesary && (permissionObject.hasAll(requestedPermissions) || !condition)) {
executePreGrantedPermissionsRequest(request);
} else if ((rationaleDialogMessage != null || (rationaleDialogTitle != null && rationaleDialogDetails != null))
&& rationalDialogHeader != null) {
executePermissionsRequestWithRationale(request);
} else {
executePermissionsRequest(request);
}
}
private void executePreGrantedPermissionsRequest(PermissionsRequest request) {
int[] grantResults = new int[requestedPermissions.length];
for (int i=0;i<grantResults.length;i++) grantResults[i] = PackageManager.PERMISSION_GRANTED;
request.onResult(requestedPermissions, grantResults, new boolean[requestedPermissions.length]);
}
@SuppressWarnings("ConstantConditions")
private void executePermissionsRequestWithRationale(PermissionsRequest request) {
MaterialAlertDialogBuilder builder = (rationaleDialogMessage != null)
? RationaleDialog.createFor(permissionObject.getContext(), rationaleDialogMessage, rationalDialogHeader)
: RationaleDialog.createFor(permissionObject.getContext(), rationaleDialogTitle, rationaleDialogDetails, rationalDialogHeader);
builder.setPositiveButton(R.string.Permissions_continue, (dialog, which) -> executePermissionsRequest(request))
.setNegativeButton(R.string.Permissions_not_now, (dialog, which) -> executeNoPermissionsRequest(request))
.setBackgroundInsetTop(0)
.setBackgroundInsetBottom(0)
.setCancelable(rationaleDialogCancelable);
if (rationaleDialogMessage != null) {
builder.show().getWindow().setLayout((int)(permissionObject.getWindowWidth() * .75), ViewGroup.LayoutParams.WRAP_CONTENT);
} else {
builder.show();
}
}
private void executePermissionsRequest(PermissionsRequest request) {
int requestCode = new SecureRandom().nextInt(65434) + 100;
synchronized (OUTSTANDING) {
OUTSTANDING.put(requestCode, request);
}
for (String permission : requestedPermissions) {
request.addMapping(permission, permissionObject.shouldShouldPermissionRationale(permission));
}
permissionObject.requestPermissions(requestCode, requestedPermissions);
}
private void executeNoPermissionsRequest(PermissionsRequest request) {
for (String permission : requestedPermissions) {
request.addMapping(permission, true);
}
String[] permissions = filterNotGranted(permissionObject.getContext(), requestedPermissions);
int[] grantResults = new int[permissions.length];
Arrays.fill(grantResults, PackageManager.PERMISSION_DENIED);
boolean[] showDialog = new boolean[permissions.length];
Arrays.fill(showDialog, true);
request.onResult(permissions, grantResults, showDialog);
}
}
private static void requestPermissions(@NonNull Activity activity, int requestCode, String... permissions) {
String[] neededPermissions = filterNotGranted(activity, permissions);
if (neededPermissions.length == 0) {
Log.i(TAG, "No permissions needed!");
return;
}
ActivityCompat.requestPermissions(activity, neededPermissions, requestCode);
}
private static void requestPermissions(@NonNull Fragment fragment, int requestCode, String... permissions) {
String[] neededPermissions = filterNotGranted(fragment.requireContext(), permissions);
if (neededPermissions.length == 0) {
Log.i(TAG, "No permissions needed!");
return;
}
fragment.requestPermissions(neededPermissions, requestCode);
}
private static String[] filterNotGranted(@NonNull Context context, String... permissions) {
return Arrays.stream(permissions)
.filter(permission -> ContextCompat.checkSelfPermission(context, permission) != PackageManager.PERMISSION_GRANTED)
.toArray(String[]::new);
}
public static boolean hasAny(@NonNull Context context, String... permissions) {
return Arrays.stream(permissions).anyMatch(permission -> ContextCompat.checkSelfPermission(context, permission) == PackageManager.PERMISSION_GRANTED);
}
public static boolean hasAll(@NonNull Context context, String... permissions) {
return Arrays.stream(permissions).allMatch(permission -> ContextCompat.checkSelfPermission(context, permission) == PackageManager.PERMISSION_GRANTED);
}
public static void onRequestPermissionsResult(Fragment fragment, int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) {
onRequestPermissionsResult(new FragmentPermissionObject(fragment), requestCode, permissions, grantResults);
}
public static void onRequestPermissionsResult(Activity activity, int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) {
onRequestPermissionsResult(new ActivityPermissionObject(activity), requestCode, permissions, grantResults);
}
private static void onRequestPermissionsResult(@NonNull PermissionObject context, int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) {
PermissionsRequest resultListener;
synchronized (OUTSTANDING) {
resultListener = OUTSTANDING.remove(requestCode);
}
if (resultListener == null) return;
boolean[] shouldShowRationaleDialog = new boolean[permissions.length];
for (int i=0;i<permissions.length;i++) {
if (grantResults[i] != PackageManager.PERMISSION_GRANTED) {
shouldShowRationaleDialog[i] = context.shouldShouldPermissionRationale(permissions[i]);
}
}
resultListener.onResult(permissions, grantResults, shouldShowRationaleDialog);
}
public static Intent getApplicationSettingsIntent(@NonNull Context context) {
Intent intent = new Intent();
intent.setAction(Settings.ACTION_APPLICATION_DETAILS_SETTINGS);
Uri uri = Uri.fromParts("package", context.getPackageName(), null);
intent.setData(uri);
return intent;
}
private abstract static class PermissionObject {
abstract Context getContext();
abstract boolean shouldShouldPermissionRationale(String permission);
abstract boolean hasAll(String... permissions);
abstract void requestPermissions(int requestCode, String... permissions);
int getWindowWidth() {
WindowManager windowManager = ContextCompat.getSystemService(getContext(), WindowManager.class);
Display display = windowManager.getDefaultDisplay();
DisplayMetrics metrics = new DisplayMetrics();
display.getMetrics(metrics);
return metrics.widthPixels;
}
}
private static class ActivityPermissionObject extends PermissionObject {
private Activity activity;
ActivityPermissionObject(@NonNull Activity activity) {
this.activity = activity;
}
@Override
public Context getContext() {
return activity;
}
@Override
public boolean shouldShouldPermissionRationale(String permission) {
return ActivityCompat.shouldShowRequestPermissionRationale(activity, permission);
}
@Override
public boolean hasAll(String... permissions) {
return Permissions.hasAll(activity, permissions);
}
@Override
public void requestPermissions(int requestCode, String... permissions) {
Permissions.requestPermissions(activity, requestCode, permissions);
}
}
private static class FragmentPermissionObject extends PermissionObject {
private Fragment fragment;
FragmentPermissionObject(@NonNull Fragment fragment) {
this.fragment = fragment;
}
@Override
public Context getContext() {
return fragment.getContext();
}
@Override
public boolean shouldShouldPermissionRationale(String permission) {
return fragment.shouldShowRequestPermissionRationale(permission);
}
@Override
public boolean hasAll(String... permissions) {
return Permissions.hasAll(fragment.getContext(), permissions);
}
@Override
public void requestPermissions(int requestCode, String... permissions) {
Permissions.requestPermissions(fragment, requestCode, permissions);
}
}
private static class SettingsDialogListener implements Runnable {
private final WeakReference<Context> context;
private final WeakReference<FragmentManager> fragmentManager;
private final Runnable onDialogDismissed;
private final String message;
private final int titleRes;
private final int detailsRes;
private final boolean useBottomSheet;
private final boolean useExtended;
SettingsDialogListener(Context context, String message, @Nullable Runnable onDialogDismissed, int titleRes, int detailsRes, boolean useExtended, @Nullable FragmentManager fragmentManager) {
this.message = message;
this.context = new WeakReference<>(context);
this.onDialogDismissed = onDialogDismissed;
this.fragmentManager = new WeakReference<>(fragmentManager);
this.titleRes = titleRes;
this.detailsRes = detailsRes;
this.useExtended = useExtended;
this.useBottomSheet = fragmentManager != null;
}
@Override
public void run() {
Context context = this.context.get();
FragmentManager fragmentManager = this.fragmentManager.get();
if (context != null) {
if (useBottomSheet && fragmentManager != null) {
PermissionDeniedBottomSheet.showPermissionFragment(titleRes, detailsRes, useExtended).show(fragmentManager, BottomSheetUtil.STANDARD_BOTTOM_SHEET_FRAGMENT_TAG);
} else if (!useBottomSheet){
new MaterialAlertDialogBuilder(context)
.setTitle(R.string.Permissions_permission_required)
.setMessage(message)
.setCancelable(false)
.setPositiveButton(R.string.Permissions_continue, (dialog, which) -> context.startActivity(getApplicationSettingsIntent(context)))
.setNegativeButton(android.R.string.cancel, null)
.setOnDismissListener(d -> {
if (onDialogDismissed != null) {
onDialogDismissed.run();
}
})
.show();
}
}
}
}
}

View File

@@ -0,0 +1,92 @@
package org.signal.core.ui.permissions;
import android.content.pm.PackageManager;
import androidx.annotation.Nullable;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.function.Consumer;
class PermissionsRequest {
private final Map<String, Boolean> PRE_REQUEST_MAPPING = new HashMap<>();
private final @Nullable Runnable allGrantedListener;
private final @Nullable Runnable anyDeniedListener;
private final @Nullable Runnable anyPermanentlyDeniedListener;
private final @Nullable Runnable anyResultListener;
private final @Nullable Consumer<List<String>> someGrantedListener;
private final @Nullable Consumer<List<String>> someDeniedListener;
private final @Nullable Consumer<List<String>> somePermanentlyDeniedListener;
PermissionsRequest(@Nullable Runnable allGrantedListener,
@Nullable Runnable anyDeniedListener,
@Nullable Runnable anyPermanentlyDeniedListener,
@Nullable Runnable anyResultListener,
@Nullable Consumer<List<String>> someGrantedListener,
@Nullable Consumer<List<String>> someDeniedListener,
@Nullable Consumer<List<String>> somePermanentlyDeniedListener)
{
this.allGrantedListener = allGrantedListener;
this.anyDeniedListener = anyDeniedListener;
this.anyPermanentlyDeniedListener = anyPermanentlyDeniedListener;
this.anyResultListener = anyResultListener;
this.someGrantedListener = someGrantedListener;
this.someDeniedListener = someDeniedListener;
this.somePermanentlyDeniedListener = somePermanentlyDeniedListener;
}
void onResult(String[] permissions, int[] grantResults, boolean[] shouldShowRationaleDialog) {
List<String> granted = new ArrayList<>(permissions.length);
List<String> denied = new ArrayList<>(permissions.length);
List<String> permanentlyDenied = new ArrayList<>(permissions.length);
for (int i = 0; i < permissions.length; i++) {
if (grantResults[i] == PackageManager.PERMISSION_GRANTED) {
granted.add(permissions[i]);
} else {
boolean preRequestShouldShowRationaleDialog = PRE_REQUEST_MAPPING.get(permissions[i]);
if ((somePermanentlyDeniedListener != null || anyPermanentlyDeniedListener != null) &&
!preRequestShouldShowRationaleDialog && !shouldShowRationaleDialog[i])
{
permanentlyDenied.add(permissions[i]);
} else {
denied.add(permissions[i]);
}
}
}
if (allGrantedListener != null && granted.size() > 0 && (denied.size() == 0 && permanentlyDenied.size() == 0)) {
allGrantedListener.run();
} else if (someGrantedListener != null && granted.size() > 0) {
someGrantedListener.accept(granted);
}
if (denied.size() > 0) {
if (anyDeniedListener != null) anyDeniedListener.run();
if (someDeniedListener != null) someDeniedListener.accept(denied);
}
if (permanentlyDenied.size() > 0) {
if (anyPermanentlyDeniedListener != null) anyPermanentlyDeniedListener.run();
if (somePermanentlyDeniedListener != null) somePermanentlyDeniedListener.accept(permanentlyDenied);
}
if (anyResultListener != null) {
anyResultListener.run();
}
}
void addMapping(String permission, boolean shouldShowRationaleDialog) {
PRE_REQUEST_MAPPING.put(permission, shouldShowRationaleDialog);
}
}

View File

@@ -0,0 +1,108 @@
package org.signal.core.ui.permissions;
import android.content.Context;
import android.graphics.Color;
import android.graphics.drawable.Drawable;
import android.text.method.ScrollingMovementMethod;
import android.util.TypedValue;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.ImageView;
import android.widget.LinearLayout.LayoutParams;
import android.widget.TextView;
import androidx.annotation.DrawableRes;
import androidx.annotation.NonNull;
import androidx.core.content.ContextCompat;
import androidx.core.graphics.drawable.DrawableCompat;
import com.google.android.material.dialog.MaterialAlertDialogBuilder;
import org.signal.core.util.DimensionUnit;
import org.signal.core.ui.R;
import org.signal.core.ui.util.ThemeUtil;
import java.util.Objects;
public class RationaleDialog {
public static MaterialAlertDialogBuilder createFor(@NonNull Context context, @NonNull String title, @NonNull String details, @DrawableRes int... drawables) {
View view = LayoutInflater.from(context).inflate(R.layout.permission_allow_dialog, null);
ViewGroup header = view.findViewById(R.id.permission_header_container);
TextView titleText = view.findViewById(R.id.permission_title);
TextView detailsText = view.findViewById(R.id.permission_details);
int iconSize = (int) DimensionUnit.DP.toPixels(32);
for (int i = 0; i < drawables.length; i++) {
Drawable drawable = Objects.requireNonNull(ContextCompat.getDrawable(context, drawables[i]));
DrawableCompat.setTint(drawable, ContextCompat.getColor(context, R.color.signal_colorOnPrimaryContainer));
ImageView imageView = new ImageView(context);
imageView.setImageDrawable(drawable);
imageView.setLayoutParams(new LayoutParams(iconSize, iconSize));
imageView.setScaleType(ImageView.ScaleType.FIT_CENTER);
header.addView(imageView);
if (i != drawables.length - 1) {
TextView plus = new TextView(context);
plus.setText("+");
plus.setTextSize(TypedValue.COMPLEX_UNIT_SP, 40);
plus.setTextColor(Color.WHITE);
LayoutParams layoutParams = new LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT);
layoutParams.setMargins((int) DimensionUnit.DP.toPixels(20f), 0, (int) DimensionUnit.DP.toPixels(20f), 0);
plus.setLayoutParams(layoutParams);
header.addView(plus);
}
}
titleText.setText(title);
detailsText.setText(details);
return new MaterialAlertDialogBuilder(context).setView(view);
}
public static MaterialAlertDialogBuilder createFor(@NonNull Context context, @NonNull String message, @DrawableRes int... drawables) {
View view = LayoutInflater.from(context).inflate(R.layout.permissions_rationale_dialog, null);
ViewGroup header = view.findViewById(R.id.header_container);
TextView text = view.findViewById(R.id.message);
int iconSize = (int) DimensionUnit.DP.toPixels(32);
for (int i=0;i<drawables.length;i++) {
Drawable drawable = Objects.requireNonNull(ContextCompat.getDrawable(context, drawables[i]));
DrawableCompat.setTint(drawable, Color.WHITE);
ImageView imageView = new ImageView(context);
imageView.setImageDrawable(drawable);
imageView.setLayoutParams(new LayoutParams(iconSize, iconSize));
imageView.setScaleType(ImageView.ScaleType.FIT_CENTER);
header.addView(imageView);
if (i != drawables.length - 1) {
TextView plus = new TextView(context);
plus.setText("+");
plus.setTextSize(TypedValue.COMPLEX_UNIT_SP, 40);
plus.setTextColor(Color.WHITE);
LayoutParams layoutParams = new LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT);
layoutParams.setMargins((int) DimensionUnit.DP.toPixels(20f), 0, (int) DimensionUnit.DP.toPixels(20f), 0);
plus.setLayoutParams(layoutParams);
header.addView(plus);
}
}
text.setText(message);
text.setMovementMethod(new ScrollingMovementMethod());
int dialogTheme = ThemeUtil.getThemedResourceId(context, R.attr.permissionsRationaleDialogTheme);
return new MaterialAlertDialogBuilder(context, dialogTheme)
.setView(view);
}
}

View File

@@ -0,0 +1,187 @@
package org.signal.core.ui.util;
import android.Manifest;
import android.content.Context;
import android.net.Uri;
import android.os.Build;
import android.os.Environment;
import android.os.storage.StorageManager;
import android.os.storage.StorageVolume;
import android.provider.MediaStore;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.RequiresApi;
import androidx.core.content.ContextCompat;
import org.signal.core.ui.CoreUiDependencies;
import org.signal.core.ui.R;
import org.signal.core.util.NoExternalStorageException;
import org.signal.core.util.permissions.PermissionCompat;
import org.signal.core.ui.permissions.Permissions;
import java.io.File;
import java.util.List;
import java.util.Objects;
public class StorageUtil {
private static final String PRODUCTION_PACKAGE_ID = "org.thoughtcrime.securesms";
public static File getOrCreateBackupDirectory() throws NoExternalStorageException {
File storage = Environment.getExternalStorageDirectory();
if (!storage.canWrite()) {
throw new NoExternalStorageException();
}
File backups = getBackupDirectory();
if (!backups.exists()) {
if (!backups.mkdirs()) {
throw new NoExternalStorageException("Unable to create backup directory...");
}
}
return backups;
}
public static File getOrCreateBackupV2Directory() throws NoExternalStorageException {
File storage = Environment.getExternalStorageDirectory();
if (!storage.canWrite()) {
throw new NoExternalStorageException();
}
File backups = getBackupV2Directory();
if (!backups.exists()) {
if (!backups.mkdirs()) {
throw new NoExternalStorageException("Unable to create backup directory...");
}
}
return backups;
}
public static File getBackupDirectory() throws NoExternalStorageException {
File storage = Environment.getExternalStorageDirectory();
File signal = new File(storage, "Signal");
File backups = new File(signal, "Backups");
String packageId = CoreUiDependencies.INSTANCE.getPackageId();
//noinspection ConstantConditions
if (packageId.startsWith(PRODUCTION_PACKAGE_ID + ".")) {
backups = new File(backups, packageId.substring(PRODUCTION_PACKAGE_ID.length() + 1));
}
return backups;
}
public static File getBackupV2Directory() throws NoExternalStorageException {
File storage = Environment.getExternalStorageDirectory();
File backups = new File(storage, "Signal");
String packageId = CoreUiDependencies.INSTANCE.getPackageId();
//noinspection ConstantConditions
if (packageId.startsWith(PRODUCTION_PACKAGE_ID + ".")) {
backups = new File(storage, packageId.substring(PRODUCTION_PACKAGE_ID.length() + 1));
}
return backups;
}
@RequiresApi(24)
public static @NonNull String getDisplayPath(@NonNull Context context, @NonNull Uri uri) {
String lastPathSegment = Objects.requireNonNull(uri.getLastPathSegment());
String backupVolume = lastPathSegment.replaceFirst(":.*", "");
String backupName = lastPathSegment.replaceFirst(".*:", "");
StorageManager storageManager = ContextCompat.getSystemService(context, StorageManager.class);
List<StorageVolume> storageVolumes = storageManager.getStorageVolumes();
StorageVolume storageVolume = null;
for (StorageVolume volume : storageVolumes) {
if (Objects.equals(volume.getUuid(), backupVolume)) {
storageVolume = volume;
break;
}
}
if (storageVolume == null) {
return backupName;
} else {
return context.getString(R.string.StorageUtil__s_s, storageVolume.getDescription(context), backupName);
}
}
private static File getSignalStorageDir() throws NoExternalStorageException {
final File storage = Environment.getExternalStorageDirectory();
if (!storage.canWrite()) {
throw new NoExternalStorageException();
}
return storage;
}
public static boolean canWriteInSignalStorageDir() {
File storage;
try {
storage = getSignalStorageDir();
} catch (NoExternalStorageException e) {
return false;
}
return storage.canWrite();
}
public static boolean canWriteToMediaStore() {
return Build.VERSION.SDK_INT > 28 ||
Permissions.hasAll(CoreUiDependencies.INSTANCE.getApplication(), Manifest.permission.WRITE_EXTERNAL_STORAGE);
}
public static boolean canReadAnyFromMediaStore() {
return Permissions.hasAny(CoreUiDependencies.INSTANCE.getApplication(), PermissionCompat.forImagesAndVideos());
}
public static boolean canOnlyReadSelectedMediaStore() {
return Build.VERSION.SDK_INT >= 34 &&
Permissions.hasAll(CoreUiDependencies.INSTANCE.getApplication(), Manifest.permission.READ_MEDIA_VISUAL_USER_SELECTED) &&
!Permissions.hasAny(CoreUiDependencies.INSTANCE.getApplication(), Manifest.permission.READ_MEDIA_IMAGES, Manifest.permission.READ_MEDIA_VIDEO);
}
public static @NonNull Uri getVideoUri() {
return MediaStore.Video.Media.EXTERNAL_CONTENT_URI;
}
public static @NonNull Uri getAudioUri() {
return MediaStore.Audio.Media.EXTERNAL_CONTENT_URI;
}
public static @NonNull Uri getImageUri() {
return MediaStore.Images.Media.EXTERNAL_CONTENT_URI;
}
public static @NonNull Uri getDownloadUri() {
if (Build.VERSION.SDK_INT < 29) {
return getLegacyUri(Environment.DIRECTORY_DOWNLOADS);
} else {
return MediaStore.Downloads.EXTERNAL_CONTENT_URI;
}
}
public static @NonNull Uri getLegacyUri(@NonNull String directory) {
return Uri.fromFile(Environment.getExternalStoragePublicDirectory(directory));
}
public static @Nullable String getCleanFileName(@Nullable String fileName) {
if (fileName == null) return null;
fileName = fileName.replace('\u202D', '\uFFFD');
fileName = fileName.replace('\u202E', '\uFFFD');
return fileName;
}
}

View File

@@ -0,0 +1,7 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:height="24dp" android:viewportHeight="25" android:viewportWidth="24" android:width="23.04dp">
<path android:fillColor="#00000000" android:pathData="M12,12.801m-9,0a9,9 0,1 1,18 0a9,9 0,1 1,-18 0" android:strokeColor="#2C58C3" android:strokeWidth="2"/>
<path android:fillColor="#2C58C3" android:pathData="M12,12.801m-4,0a4,4 0,1 1,8 0a4,4 0,1 1,-8 0" android:strokeColor="#2C58C3" android:strokeWidth="2"/>
</vector>

View File

@@ -0,0 +1,38 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:paddingTop="20dp"
android:paddingHorizontal="20dp"
tools:viewBindingIgnore="true">
<LinearLayout
android:id="@+id/permission_header_container"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:gravity="center"
android:layout_gravity="center"
android:orientation="horizontal"
android:layout_marginBottom="10dp">
</LinearLayout>
<TextView
android:id="@+id/permission_title"
style="@style/Signal.Text.Title"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center"
tools:text="@string/Permissions_permission_required" />
<TextView
android:id="@+id/permission_details"
style="@style/Signal.Text.BodySmall"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="15dp"
tools:text="@string/Permissions_permission_required" />
</LinearLayout>

View File

@@ -0,0 +1,31 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:orientation="vertical"
tools:viewBindingIgnore="true">
<LinearLayout
android:id="@+id/header_container"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="@color/signal_colorPrimary"
android:gravity="center"
android:orientation="horizontal"
android:padding="48dp">
</LinearLayout>
<TextView
android:id="@+id/message"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:paddingHorizontal="24dp"
android:paddingTop="16dp"
android:scrollbars="vertical"
android:textAppearance="@style/Signal.Text.BodyMedium"
android:textColor="@color/signal_colorOnSurfaceVariant"
tools:text="Signal needs access to your contacts and media in order to connect with friends, exchange messages, and make secure calls." />
</LinearLayout>

View File

@@ -3,4 +3,5 @@
<attr name="theme_type" format="string"/>
<attr name="fullScreenDialogStyle" format="reference"/>
<attr name="fixedRoundedCornerBottomSheetStyle" format="reference"/>
<attr name="permissionsRationaleDialogTheme" format="reference"/>
</resources>

View File

@@ -5,4 +5,20 @@
<string name="Permissions_continue">Continue</string>
<!-- Button label to dismiss or defer a permission request -->
<string name="Permissions_not_now">Not now</string>
<!-- Sheet describing step 1 on how to give permissions by opening settings -->
<string name="PermissionDeniedBottomSheet__1_tap_settings_below">1. Tap "Settings" below</string>
<!-- Sheet describing step 2 on how to give permissions by checking the permissions button in settings where %s will be replaced with an image of a checked button -->
<string name="PermissionDeniedBottomSheet__2_allow_permission">2. %s Allow the permission</string>
<!-- Sheet describing step 2 on how to give permissions by tapping permissions in their settings -->
<string name="PermissionDeniedBottomSheet__2_tap_permissions">2. Tap "Permissions"</string>
<!-- Sheet describing step 3 on how to give permissions for photos and videos in settings where %s will be replaced with an image of a checked button -->
<string name="PermissionDeniedBottomSheet__3_allow_permission">3. %1$s Allow the "Photos and videos" permission</string>
<!-- Label for button at the bottom of the sheet which opens the system permission settings -->
<string name="PermissionDeniedBottomSheet__settings">Settings</string>
<!-- Title for dialog shown when a required permission has not been granted -->
<string name="Permissions_permission_required">Permission required</string>
<!-- StorageUtil -->
<!-- Format string for displaying a storage path as volume/filename -->
<string name="StorageUtil__s_s">%1$s/%2$s</string>
</resources>