feat(flutter,android): permission state banner + AndroidPermissionsService + voice-join gate
Dart consumer for the Android permission state pipeline. New AndroidPermissionsService listens on the app.chanora/android_permissions MethodChannel and exposes a ValueListenable for the UI. The voice-join flow in main.dart calls ensureRecordAudio() before rust.voiceJoin and clamps to listen-only via setHardMute on denial. A non-modal banner above the VoiceBar surfaces the Grant / Open Settings action depending on whether the state is Denied or PermanentlyDenied. On non-Android hosts the service short-circuits to granted; the banner is never built. Also adds the BackIntentService Dart consumer (back_intent_policy + back_intent_service) which the Kotlin BackIntentBridge invokes via MethodChannel for deterministic route-pop ordering. Trace: SDD-028, SDD-106, SRS-163, SRS-209.
This commit is contained in:
@@ -0,0 +1,297 @@
|
||||
/// SDD-106 `AndroidPermissionRequester` — Dart integration layer.
|
||||
///
|
||||
/// Trace:
|
||||
/// - SDD-106 (Android runtime acquisition of `RECORD_AUDIO` with
|
||||
/// listen-only fallback; bridge event surface §5).
|
||||
/// - SRS-209 (runtime microphone permission acquisition; fail-safe
|
||||
/// to listen-only on denial; user-visible path to grant).
|
||||
///
|
||||
/// Responsibilities:
|
||||
/// * Subscribe to the Kotlin-side `MethodChannel`
|
||||
/// `app.chanora/android_permissions` for `permissionStateChanged`
|
||||
/// invocations emitted by `AndroidPermissionRequester.kt`
|
||||
/// (Kotlin → Dart).
|
||||
/// * Provide imperative Dart → Kotlin entry points
|
||||
/// `requestRecordAudio` and `openAppSettings` so the UI can drive
|
||||
/// the runtime request flow per SDD-106 §1–§3.
|
||||
/// * Expose the latest resolved state as a [ValueListenable] so UI
|
||||
/// surfaces (the listen-only banner per SRS-209; the pre-`voice_join`
|
||||
/// gate per SDD-106 §1) can react without polling.
|
||||
///
|
||||
/// ## Non-Android short-circuit
|
||||
///
|
||||
/// On Linux / macOS / Windows / iOS / web, RECORD_AUDIO is not gated
|
||||
/// by this channel (desktop has no Android runtime-permission concept;
|
||||
/// iOS uses AVAudioSession authorisation which is owned by the audio
|
||||
/// engine itself per SDD-101). The MethodChannel is therefore never
|
||||
/// constructed off-Android. The state listenable stays at
|
||||
/// [AndroidRecordAudioPermissionState.granted] so the voice-join gate
|
||||
/// (in `main.dart`) becomes a no-op on those platforms.
|
||||
///
|
||||
/// ## No global statics
|
||||
///
|
||||
/// Following the pattern established by `BackIntentService`
|
||||
/// (SDD-028), this class is constructor-injected. The host app
|
||||
/// instantiates one instance at startup and passes it through the
|
||||
/// widget tree.
|
||||
///
|
||||
/// ## Bridge event surface (SDD-106 §5)
|
||||
///
|
||||
/// SDD-106 §5 calls for a `BridgeEvent::PermissionState` variant on
|
||||
/// the existing Rust → Dart event stream so the audio engine can
|
||||
/// observe the resolved state for the transmit-clamp described in
|
||||
/// SDD-106 §6. That Rust-side variant is intentionally deferred to a
|
||||
/// follow-up Wave touching `chanora_bridge`; the current Wave 2B
|
||||
/// Dart-only path is sufficient for the UI obligations of SRS-209
|
||||
/// (listen-only banner; path to grant). See the structured handoff
|
||||
/// note attached to this task for the rationale.
|
||||
library;
|
||||
|
||||
import 'dart:async';
|
||||
import 'dart:developer' as developer;
|
||||
import 'dart:io' show Platform;
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
/// MethodChannel name shared with Kotlin `MethodChannels.ANDROID_PERMISSIONS`.
|
||||
///
|
||||
/// Trace: SDD-106 §5.
|
||||
@visibleForTesting
|
||||
const String androidPermissionsChannelName = 'app.chanora/android_permissions';
|
||||
|
||||
/// Inbound (Kotlin → Dart) method invoked whenever the resolved
|
||||
/// `RECORD_AUDIO` permission state transitions.
|
||||
///
|
||||
/// Payload schema: `{permission: String, state: String}` where
|
||||
/// `state` is one of `"Granted"`, `"Denied"`, `"PermanentlyDenied"`.
|
||||
///
|
||||
/// Trace: SDD-106 §5; mirrors Kotlin
|
||||
/// `MethodChannels.METHOD_PERMISSION_STATE_CHANGED`.
|
||||
@visibleForTesting
|
||||
const String methodPermissionStateChanged = 'permissionStateChanged';
|
||||
|
||||
/// Outbound (Dart → Kotlin) method that asks the Android requester to
|
||||
/// (re-)prompt for `RECORD_AUDIO`. The result is delivered
|
||||
/// asynchronously via [methodPermissionStateChanged].
|
||||
///
|
||||
/// Trace: SDD-106 §1.
|
||||
@visibleForTesting
|
||||
const String methodRequestRecordAudio = 'requestRecordAudio';
|
||||
|
||||
/// Outbound (Dart → Kotlin) method that deep-links to the application's
|
||||
/// Android Settings page so the user can re-grant a permanently-denied
|
||||
/// permission.
|
||||
///
|
||||
/// Trace: SDD-106 §3.
|
||||
@visibleForTesting
|
||||
const String methodOpenAppSettings = 'openAppSettings';
|
||||
|
||||
/// Discrete states surfaced to the Dart UI.
|
||||
///
|
||||
/// Trace: SDD-106 §5 (state machine).
|
||||
enum AndroidRecordAudioPermissionState {
|
||||
/// Permission granted; microphone capture is allowed.
|
||||
granted,
|
||||
|
||||
/// Permission denied but the user may still be re-prompted by
|
||||
/// invoking [AndroidPermissionsService.ensureRecordAudio] again.
|
||||
denied,
|
||||
|
||||
/// Permission denied with "Don't ask again" or revoked from system
|
||||
/// Settings. The UI must deep-link to app settings via
|
||||
/// [AndroidPermissionsService.openAppSettings] (SDD-106 §3).
|
||||
permanentlyDenied,
|
||||
|
||||
/// No resolved state yet (cold launch before the first emission, or
|
||||
/// non-Android host before short-circuit). The voice-join gate
|
||||
/// treats this as "request before joining".
|
||||
unknown,
|
||||
}
|
||||
|
||||
/// Maps the Kotlin-side `PermissionState` string into the Dart enum.
|
||||
AndroidRecordAudioPermissionState _parseState(String? raw) {
|
||||
switch (raw) {
|
||||
case 'Granted':
|
||||
return AndroidRecordAudioPermissionState.granted;
|
||||
case 'Denied':
|
||||
return AndroidRecordAudioPermissionState.denied;
|
||||
case 'PermanentlyDenied':
|
||||
return AndroidRecordAudioPermissionState.permanentlyDenied;
|
||||
default:
|
||||
return AndroidRecordAudioPermissionState.unknown;
|
||||
}
|
||||
}
|
||||
|
||||
/// Dart-side integration for `AndroidPermissionRequester` (SDD-106).
|
||||
///
|
||||
/// Trace: SDD-106, SRS-209.
|
||||
class AndroidPermissionsService {
|
||||
/// Construct a service bound to [channel]. Injected for testability;
|
||||
/// production code uses the default channel keyed on
|
||||
/// [androidPermissionsChannelName].
|
||||
AndroidPermissionsService({MethodChannel? channel})
|
||||
: _channel = channel ??
|
||||
(_isAndroid
|
||||
? const MethodChannel(androidPermissionsChannelName)
|
||||
: null);
|
||||
|
||||
/// Platform-detection seam. Web counts as non-Android.
|
||||
static bool get _isAndroid {
|
||||
if (kIsWeb) return false;
|
||||
return Platform.isAndroid;
|
||||
}
|
||||
|
||||
/// The underlying channel, or `null` on non-Android (and unset in
|
||||
/// tests that omit the optional argument).
|
||||
final MethodChannel? _channel;
|
||||
|
||||
final ValueNotifier<AndroidRecordAudioPermissionState> _state =
|
||||
ValueNotifier<AndroidRecordAudioPermissionState>(
|
||||
// On non-Android, present as granted so the voice-join gate is a
|
||||
// no-op (desktop / iOS have separate audio-permission paths owned
|
||||
// elsewhere; see SDD-101 for iOS).
|
||||
_isAndroid
|
||||
? AndroidRecordAudioPermissionState.unknown
|
||||
: AndroidRecordAudioPermissionState.granted,
|
||||
);
|
||||
|
||||
bool _started = false;
|
||||
|
||||
/// Latest known [RECORD_AUDIO] state. Defaults to
|
||||
/// [AndroidRecordAudioPermissionState.unknown] on Android and
|
||||
/// [AndroidRecordAudioPermissionState.granted] elsewhere.
|
||||
ValueListenable<AndroidRecordAudioPermissionState> get recordAudioState =>
|
||||
_state;
|
||||
|
||||
/// Start listening for state updates from Kotlin. Idempotent.
|
||||
///
|
||||
/// On non-Android this is a no-op.
|
||||
///
|
||||
/// Trace: SDD-106 §5.
|
||||
void start() {
|
||||
if (_started) return;
|
||||
_started = true;
|
||||
final ch = _channel;
|
||||
if (ch == null) return;
|
||||
ch.setMethodCallHandler(_handle);
|
||||
}
|
||||
|
||||
/// Stop listening. Idempotent.
|
||||
void stop() {
|
||||
if (!_started) return;
|
||||
_started = false;
|
||||
final ch = _channel;
|
||||
if (ch == null) return;
|
||||
ch.setMethodCallHandler(null);
|
||||
}
|
||||
|
||||
Future<dynamic> _handle(MethodCall call) async {
|
||||
if (call.method != methodPermissionStateChanged) return null;
|
||||
final args = call.arguments;
|
||||
if (args is! Map) return null;
|
||||
// We currently track RECORD_AUDIO only. POST_NOTIFICATIONS (SDD-107
|
||||
// §6) is on the same channel by design and will route through a
|
||||
// sibling listenable when that work lands.
|
||||
final permission = args['permission'];
|
||||
if (permission != 'android.permission.RECORD_AUDIO') return null;
|
||||
final state = _parseState(args['state'] as String?);
|
||||
_state.value = state;
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Request the system permission. Invokes the Kotlin requester and
|
||||
/// then, if the platform synchronously resolves the request, returns
|
||||
/// the resolved state without parking for the listener; otherwise
|
||||
/// awaits the next `permissionStateChanged` emission.
|
||||
///
|
||||
/// On non-Android, resolves immediately with
|
||||
/// [AndroidRecordAudioPermissionState.granted].
|
||||
///
|
||||
/// Trace: SDD-106 §1, §3.
|
||||
Future<AndroidRecordAudioPermissionState> ensureRecordAudio() async {
|
||||
final ch = _channel;
|
||||
if (ch == null) {
|
||||
return AndroidRecordAudioPermissionState.granted;
|
||||
}
|
||||
// Snapshot the pre-invocation state. If the platform synchronously
|
||||
// resolves the request to a NEW state during invokeMethod (the
|
||||
// platform may emit permissionStateChanged before invokeMethod
|
||||
// returns), we can return immediately. If the state is unchanged
|
||||
// (typical: user is being re-prompted after a previous denial; the
|
||||
// platform always re-prompts and the resolution arrives
|
||||
// asynchronously), we must wait for the next listener fire.
|
||||
final preInvokeState = _state.value;
|
||||
try {
|
||||
await ch.invokeMethod<void>(methodRequestRecordAudio);
|
||||
} catch (_) {
|
||||
// Channel-side failure (e.g. missing handler in a debug build).
|
||||
// Fall back to whatever state we currently hold; if still
|
||||
// unknown, surface unknown so the caller can choose its own
|
||||
// policy. Per SRS-209 the voice-join gate treats unknown as
|
||||
// "proceed in listen-only".
|
||||
return _state.value;
|
||||
}
|
||||
// M1 fix (corrected): only short-circuit if the platform changed
|
||||
// the state synchronously to a resolved value. A still-unknown
|
||||
// state means we must wait. A still-equal-to-pre-invoke state
|
||||
// means we also must wait (the platform is re-prompting; the
|
||||
// resolution arrives asynchronously after the user interacts with
|
||||
// the dialog).
|
||||
if (_state.value != preInvokeState &&
|
||||
_state.value != AndroidRecordAudioPermissionState.unknown) {
|
||||
return _state.value;
|
||||
}
|
||||
// Otherwise we wait for the listener to observe a state
|
||||
// transition. Bounded wait to avoid hanging the voice-join flow
|
||||
// if the platform dialog is dismissed without a result. The
|
||||
// Kotlin requester treats dismissal as Denied (see
|
||||
// AndroidPermissionRequester.handleRequestPermissionsResult) so
|
||||
// this is defence-in-depth.
|
||||
final completer = Completer<AndroidRecordAudioPermissionState>();
|
||||
void listener() {
|
||||
if (!completer.isCompleted &&
|
||||
_state.value != preInvokeState &&
|
||||
_state.value != AndroidRecordAudioPermissionState.unknown) {
|
||||
completer.complete(_state.value);
|
||||
}
|
||||
}
|
||||
|
||||
_state.addListener(listener);
|
||||
try {
|
||||
return await completer.future.timeout(
|
||||
const Duration(seconds: 30),
|
||||
onTimeout: () => _state.value,
|
||||
);
|
||||
} finally {
|
||||
_state.removeListener(listener);
|
||||
}
|
||||
}
|
||||
|
||||
/// Deep-link to the system app settings page for the permanently
|
||||
/// denied case (SDD-106 §3). On non-Android, a no-op.
|
||||
Future<void> openAppSettings() async {
|
||||
final ch = _channel;
|
||||
if (ch == null) return;
|
||||
try {
|
||||
await ch.invokeMethod<void>(methodOpenAppSettings);
|
||||
} catch (e, st) {
|
||||
// Best-effort; failure to launch settings is non-fatal. The
|
||||
// Kotlin side already logs ActivityNotFoundException.
|
||||
developer.log(
|
||||
'openAppSettings failed',
|
||||
name: 'AndroidPermissionsService',
|
||||
error: e,
|
||||
stackTrace: st,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Release the state notifier. Test helper; production keeps the
|
||||
/// service alive for the lifetime of the app.
|
||||
@visibleForTesting
|
||||
void dispose() {
|
||||
stop();
|
||||
_state.dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
/// Pure-Dart route-pop policy for SDD-028 (`BackIntentService`).
|
||||
///
|
||||
/// Trace: SDD-028 §2 (deterministic route-pop ordering) / SAD-018.
|
||||
///
|
||||
/// This file intentionally has **no Flutter dependency** so it can be
|
||||
/// unit-tested without a `MaterialApp` or `Navigator` context. The
|
||||
/// integration wrapper lives in `back_intent_service.dart`.
|
||||
library;
|
||||
|
||||
/// Decision produced by [decideBackIntent].
|
||||
///
|
||||
/// Trace: SDD-028 §2 — exactly one decision per platform back event.
|
||||
sealed class BackIntentDecision {
|
||||
const BackIntentDecision();
|
||||
}
|
||||
|
||||
/// PTT is currently transmitting; the back event must be swallowed
|
||||
/// without any UI side effect (SDD-028 §2.a).
|
||||
class IgnoreDueToPtt extends BackIntentDecision {
|
||||
const IgnoreDueToPtt();
|
||||
}
|
||||
|
||||
/// A modal / dialog route is on top; close it only (SDD-028 §2.b).
|
||||
class CloseModal extends BackIntentDecision {
|
||||
const CloseModal();
|
||||
}
|
||||
|
||||
/// Navigator can pop a non-root route (SDD-028 §2.c).
|
||||
class PopRoute extends BackIntentDecision {
|
||||
const PopRoute();
|
||||
}
|
||||
|
||||
/// At the root route with no modal and no PTT; the platform shall be
|
||||
/// asked to finish the activity (SDD-028 §2.d — `exitCandidate`).
|
||||
class ExitApp extends BackIntentDecision {
|
||||
const ExitApp();
|
||||
}
|
||||
|
||||
/// Resolve the back-intent decision deterministically.
|
||||
///
|
||||
/// Trace: SDD-028 §2. The branch order is intentionally fixed:
|
||||
///
|
||||
/// 1. `pttActive == true` → [IgnoreDueToPtt]
|
||||
/// 2. `modalOpen == true` → [CloseModal]
|
||||
/// 3. `atRoot == false` → [PopRoute]
|
||||
/// 4. otherwise → [ExitApp]
|
||||
///
|
||||
/// This function is pure: identical inputs always produce identical
|
||||
/// outputs and there are no side effects, which is the verification
|
||||
/// surface targeted by SWE4-UV-042.
|
||||
BackIntentDecision decideBackIntent({
|
||||
required bool pttActive,
|
||||
required bool modalOpen,
|
||||
required bool atRoot,
|
||||
}) {
|
||||
if (pttActive) {
|
||||
return const IgnoreDueToPtt();
|
||||
}
|
||||
if (modalOpen) {
|
||||
return const CloseModal();
|
||||
}
|
||||
if (!atRoot) {
|
||||
return const PopRoute();
|
||||
}
|
||||
return const ExitApp();
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
/// SDD-028 `BackIntentService` — Dart integration layer.
|
||||
///
|
||||
/// Trace: SDD-028 (Android back-intent normalization) / SAD-018.
|
||||
///
|
||||
/// Responsibilities:
|
||||
/// * Listen on `MethodChannel("app.chanora/back_intent")` for
|
||||
/// `backIntent` calls forwarded by `BackIntentBridge` (Kotlin).
|
||||
/// * Read the current shell state via **injected** getter callbacks
|
||||
/// (`pttActiveProbe`, `modalOpenProbe`, `atRootProbe`) so the class
|
||||
/// is testable without a `MaterialApp` / global Navigator. No
|
||||
/// global statics are introduced.
|
||||
/// * Apply `decideBackIntent(...)` from `back_intent_policy.dart` to
|
||||
/// produce a deterministic `BackIntentDecision`.
|
||||
/// * Dispatch the decision to injected effect callbacks
|
||||
/// (`closeTopModal`, `popRoute`) and, for `ExitApp`, ask the
|
||||
/// platform to finish via `channel.invokeMethod('popToSystem')`.
|
||||
///
|
||||
/// Non-Android platforms: use [BackIntentService.noOp] which never
|
||||
/// registers a channel handler. The rest of the app can construct a
|
||||
/// `BackIntentService` unconditionally.
|
||||
library;
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
import 'back_intent_policy.dart';
|
||||
|
||||
/// Probe returning the current value of a shell-state flag.
|
||||
typedef BackIntentBoolProbe = bool Function();
|
||||
|
||||
/// Effect callback invoked by [BackIntentService] when the policy
|
||||
/// resolves a non-platform decision.
|
||||
typedef BackIntentEffect = void Function();
|
||||
|
||||
/// MethodChannel contract shared with `BackIntentBridge.kt`.
|
||||
@visibleForTesting
|
||||
const String backIntentChannelName = 'app.chanora/back_intent';
|
||||
|
||||
@visibleForTesting
|
||||
const String backIntentMethodFromPlatform = 'backIntent';
|
||||
|
||||
@visibleForTesting
|
||||
const String backIntentMethodPopToSystem = 'popToSystem';
|
||||
|
||||
@visibleForTesting
|
||||
const String backIntentPayloadKindKey = 'kind';
|
||||
|
||||
@visibleForTesting
|
||||
const String backIntentPayloadKindSystemBack = 'system_back';
|
||||
|
||||
/// SDD-028 service. Construct once per app shell.
|
||||
class BackIntentService {
|
||||
BackIntentService({
|
||||
required BackIntentBoolProbe pttActiveProbe,
|
||||
required BackIntentBoolProbe modalOpenProbe,
|
||||
required BackIntentBoolProbe atRootProbe,
|
||||
required BackIntentEffect closeTopModal,
|
||||
required BackIntentEffect popRoute,
|
||||
MethodChannel? channel,
|
||||
}) : _pttActiveProbe = pttActiveProbe,
|
||||
_modalOpenProbe = modalOpenProbe,
|
||||
_atRootProbe = atRootProbe,
|
||||
_closeTopModal = closeTopModal,
|
||||
_popRoute = popRoute,
|
||||
_channel = channel ?? const MethodChannel(backIntentChannelName),
|
||||
_enabled = true;
|
||||
|
||||
/// No-op factory for non-Android targets (SDD-028 §6).
|
||||
///
|
||||
/// Constructs a service whose probes always return false and whose
|
||||
/// effect callbacks do nothing. It never installs a channel handler.
|
||||
factory BackIntentService.noOp() {
|
||||
return BackIntentService._noOp();
|
||||
}
|
||||
|
||||
BackIntentService._noOp()
|
||||
: _pttActiveProbe = _alwaysFalse,
|
||||
_modalOpenProbe = _alwaysFalse,
|
||||
_atRootProbe = _alwaysFalse,
|
||||
_closeTopModal = _noEffect,
|
||||
_popRoute = _noEffect,
|
||||
_channel = const MethodChannel(backIntentChannelName),
|
||||
_enabled = false;
|
||||
|
||||
final BackIntentBoolProbe _pttActiveProbe;
|
||||
final BackIntentBoolProbe _modalOpenProbe;
|
||||
final BackIntentBoolProbe _atRootProbe;
|
||||
final BackIntentEffect _closeTopModal;
|
||||
final BackIntentEffect _popRoute;
|
||||
final MethodChannel _channel;
|
||||
final bool _enabled;
|
||||
|
||||
bool _started = false;
|
||||
|
||||
/// Install the MethodChannel handler. Safe to call once per instance.
|
||||
/// On the no-op variant this returns without registering anything.
|
||||
void start() {
|
||||
if (!_enabled || _started) {
|
||||
return;
|
||||
}
|
||||
_channel.setMethodCallHandler(_handleMethodCall);
|
||||
_started = true;
|
||||
}
|
||||
|
||||
/// Remove the MethodChannel handler. Safe to call repeatedly.
|
||||
void stop() {
|
||||
if (!_started) {
|
||||
return;
|
||||
}
|
||||
_channel.setMethodCallHandler(null);
|
||||
_started = false;
|
||||
}
|
||||
|
||||
/// Resolve and dispatch a back intent. Visible for tests so the
|
||||
/// decision pipeline can be driven without a real platform channel.
|
||||
@visibleForTesting
|
||||
BackIntentDecision dispatch() {
|
||||
final decision = decideBackIntent(
|
||||
pttActive: _pttActiveProbe(),
|
||||
modalOpen: _modalOpenProbe(),
|
||||
atRoot: _atRootProbe(),
|
||||
);
|
||||
_apply(decision);
|
||||
return decision;
|
||||
}
|
||||
|
||||
Future<dynamic> _handleMethodCall(MethodCall call) async {
|
||||
if (call.method != backIntentMethodFromPlatform) {
|
||||
return null;
|
||||
}
|
||||
final args = call.arguments;
|
||||
if (args is Map &&
|
||||
args[backIntentPayloadKindKey] != backIntentPayloadKindSystemBack) {
|
||||
// Unknown kind: ignore defensively per SDD-028 §5 sanitiser stance.
|
||||
return null;
|
||||
}
|
||||
dispatch();
|
||||
return null;
|
||||
}
|
||||
|
||||
void _apply(BackIntentDecision decision) {
|
||||
switch (decision) {
|
||||
case IgnoreDueToPtt():
|
||||
// SDD-028 §2.a — consume without UI effect.
|
||||
return;
|
||||
case CloseModal():
|
||||
_closeTopModal();
|
||||
return;
|
||||
case PopRoute():
|
||||
_popRoute();
|
||||
return;
|
||||
case ExitApp():
|
||||
// SDD-028 §2.d — defer to platform fallback.
|
||||
// ignore: discarded_futures
|
||||
_channel.invokeMethod<void>(backIntentMethodPopToSystem);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
static bool _alwaysFalse() => false;
|
||||
static void _noEffect() {}
|
||||
}
|
||||
Reference in New Issue
Block a user