mirror of
https://github.com/ArcaneChat/android.git
synced 2026-07-03 14:05:24 +02:00
Merge branch 'main' into adb/issue-4401
This commit is contained in:
@@ -423,9 +423,18 @@ public abstract class BaseConversationListFragment extends Fragment implements A
|
||||
.setIcon(IconCompat.createWithAdaptiveBitmap(avatar))
|
||||
.setIntent(intent)
|
||||
.build();
|
||||
|
||||
boolean success;
|
||||
try {
|
||||
success = ShortcutManagerCompat.requestPinShortcut(activity, shortcutInfoCompat, null);
|
||||
} catch (Exception e) {
|
||||
Log.e(TAG, "ErrAddToHomescreen: requestPinShortcut() failed", e);
|
||||
success = false;
|
||||
}
|
||||
boolean finalSuccess = success;
|
||||
Util.runOnMain(
|
||||
() -> {
|
||||
if (!ShortcutManagerCompat.requestPinShortcut(activity, shortcutInfoCompat, null)) {
|
||||
if (!finalSuccess) {
|
||||
Toast.makeText(
|
||||
activity,
|
||||
"ErrAddToHomescreen: requestPinShortcut() failed",
|
||||
|
||||
@@ -198,7 +198,6 @@ public class ConversationActivity extends PassphraseRequiredActionBarActivity
|
||||
private final boolean isSecureText = true;
|
||||
private boolean isDefaultSms = true;
|
||||
private boolean isSecurityInitialized = false;
|
||||
private boolean successfulForwardingAttempt = false;
|
||||
private boolean isEditing = false;
|
||||
private boolean switchedProfile = false;
|
||||
|
||||
@@ -844,20 +843,17 @@ public class ConversationActivity extends PassphraseRequiredActionBarActivity
|
||||
if (dcChat.isSelfTalk()) {
|
||||
SendRelayedMessageUtil.immediatelyRelay(this, chatId);
|
||||
} else {
|
||||
String name = dcChat.getName();
|
||||
if (!dcChat.isMultiUser()) {
|
||||
int[] contactIds = dcContext.getChatContacts(chatId);
|
||||
if (contactIds.length == 1 || contactIds.length == 2) {
|
||||
name = dcContext.getContact(contactIds[0]).getDisplayName();
|
||||
}
|
||||
}
|
||||
int messageIds[] = ShareUtil.getForwardedMessageIDs(this);
|
||||
int messageCount = messageIds == null ? 0 : messageIds.length;
|
||||
new AlertDialog.Builder(this)
|
||||
.setMessage(getString(R.string.ask_forward, name))
|
||||
.setMessage(
|
||||
getResources()
|
||||
.getQuantityString(
|
||||
R.plurals.ask_forward_messages, messageCount, messageCount, dcChat.getName()))
|
||||
.setPositiveButton(
|
||||
R.string.ok,
|
||||
R.string.forward,
|
||||
(dialogInterface, i) -> {
|
||||
SendRelayedMessageUtil.immediatelyRelay(this, chatId);
|
||||
successfulForwardingAttempt = true;
|
||||
})
|
||||
.setNegativeButton(R.string.cancel, (dialogInterface, i) -> finish())
|
||||
.setOnCancelListener(dialog -> finish())
|
||||
|
||||
@@ -457,7 +457,7 @@ public class ConversationItem extends BaseConversationItem {
|
||||
int end = spanned.getSpanEnd(span);
|
||||
if (start >= 0 && end > start && end <= spanned.length()) {
|
||||
String linkText = spanned.subSequence(start, end).toString();
|
||||
String label = context.getString(R.string.accessibility_link_action, linkText);
|
||||
String label = context.getString(R.string.open_link, linkText);
|
||||
linkActionIds.add(
|
||||
ViewCompat.addAccessibilityAction(
|
||||
this,
|
||||
@@ -1091,7 +1091,18 @@ public class ConversationItem extends BaseConversationItem {
|
||||
if (!messageRecord.isOutgoing() && callInfo.state instanceof CallState.Alerting) {
|
||||
int callId = messageRecord.getId();
|
||||
CallCoordinator coordinator = CallCoordinator.getInstance(context);
|
||||
coordinator.showIncomingCallScreen(callId);
|
||||
|
||||
if (coordinator.hasActiveCall()) {
|
||||
coordinator.showIncomingCallScreen(callId);
|
||||
} else {
|
||||
if (callInfo.sdpOffer == null) {
|
||||
Toast.makeText(context, R.string.error, Toast.LENGTH_SHORT).show();
|
||||
return;
|
||||
}
|
||||
int accId = dcContext.getAccountId();
|
||||
coordinator.handleIncomingCallFromConversation(
|
||||
accId, callId, callInfo.sdpOffer, callInfo.hasVideo);
|
||||
}
|
||||
} else {
|
||||
if (callInfo.hasVideo) {
|
||||
CallUtil.startVideoCall(getContext(), chatId);
|
||||
|
||||
@@ -545,11 +545,25 @@ public class WebxdcActivity extends WebViewActivity implements DcEventCenter.DcE
|
||||
.build();
|
||||
|
||||
Toast.makeText(context, R.string.one_moment, Toast.LENGTH_SHORT).show();
|
||||
if (!ShortcutManagerCompat.requestPinShortcut(context, shortcutInfoCompat, null)) {
|
||||
Toast.makeText(
|
||||
context, "ErrAddToHomescreen: requestPinShortcut() failed", Toast.LENGTH_LONG)
|
||||
.show();
|
||||
}
|
||||
Util.runOnAnyBackgroundThread(
|
||||
() -> {
|
||||
boolean success;
|
||||
try {
|
||||
success = ShortcutManagerCompat.requestPinShortcut(context, shortcutInfoCompat, null);
|
||||
} catch (Exception e) {
|
||||
Log.e(TAG, "ErrAddToHomescreen: requestPinShortcut() failed", e);
|
||||
success = false;
|
||||
}
|
||||
if (!success) {
|
||||
Util.runOnMain(
|
||||
() ->
|
||||
Toast.makeText(
|
||||
context,
|
||||
"ErrAddToHomescreen: requestPinShortcut() failed",
|
||||
Toast.LENGTH_LONG)
|
||||
.show());
|
||||
}
|
||||
});
|
||||
} catch (Exception e) {
|
||||
Toast.makeText(context, "ErrAddToHomescreen: " + e, Toast.LENGTH_LONG).show();
|
||||
}
|
||||
|
||||
@@ -157,6 +157,12 @@ public class AccountSelectionListFragment extends DialogFragment
|
||||
onSetTag(accountId);
|
||||
} else if (itemId == R.id.menu_move_to_top) {
|
||||
onMoveToTop(accountId);
|
||||
} else if (itemId == R.id.menu_mark_all_as_read) {
|
||||
try {
|
||||
DcHelper.getRpc(requireActivity()).marknoticedAllChats(accountId);
|
||||
} catch (RpcException e) {
|
||||
Log.e(TAG, "Error calling rpc.marknoticedAllChats()", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -6,11 +6,13 @@ import android.app.PictureInPictureParams;
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.content.pm.PackageManager;
|
||||
import android.net.Uri;
|
||||
import android.os.Build;
|
||||
import android.os.Bundle;
|
||||
import android.os.Handler;
|
||||
import android.os.Looper;
|
||||
import android.os.PowerManager;
|
||||
import android.provider.Settings;
|
||||
import android.util.Log;
|
||||
import android.util.Rational;
|
||||
import android.view.View;
|
||||
@@ -22,7 +24,9 @@ import android.widget.TextView;
|
||||
import android.widget.Toast;
|
||||
import androidx.activity.OnBackPressedCallback;
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.annotation.RequiresApi;
|
||||
import androidx.appcompat.app.AlertDialog;
|
||||
import androidx.appcompat.app.AppCompatActivity;
|
||||
import androidx.cardview.widget.CardView;
|
||||
import androidx.constraintlayout.widget.ConstraintLayout;
|
||||
@@ -54,6 +58,7 @@ public class CallActivity extends AppCompatActivity {
|
||||
private static final String TAG = "CallActivity";
|
||||
private static final int MIC_PERMISSION_REQUEST_CODE = 1001;
|
||||
private static final int CAMERA_PERMISSION_REQUEST_CODE = 1002;
|
||||
private static final int CAMERA_MID_CALL_PERMISSION_REQUEST_CODE = 1003;
|
||||
|
||||
public static final String ACTION_ANSWER_CALL = BuildConfig.APPLICATION_ID + ".ANSWER_CALL";
|
||||
public static final String ACTION_DECLINE_CALL = BuildConfig.APPLICATION_ID + ".DECLINE_CALL";
|
||||
@@ -99,6 +104,7 @@ public class CallActivity extends AppCompatActivity {
|
||||
private boolean awaitingPermissionResult = false;
|
||||
private boolean pausedWhileAwaitingPermission = false;
|
||||
private boolean intentHandled = false;
|
||||
private boolean doNotAutoFinish = false;
|
||||
|
||||
@Override
|
||||
protected void onCreate(Bundle savedInstanceState) {
|
||||
@@ -227,6 +233,7 @@ public class CallActivity extends AppCompatActivity {
|
||||
Log.d(TAG, "Resuming existing call");
|
||||
} else if (!coordinator.isIncomingCall()) {
|
||||
Log.d(TAG, "Starting outgoing call");
|
||||
coordinator.ensureServiceStarted();
|
||||
viewModel.startOutgoingCallWhenReady();
|
||||
}
|
||||
}
|
||||
@@ -374,6 +381,18 @@ public class CallActivity extends AppCompatActivity {
|
||||
videoButton.setOnClickListener(
|
||||
v -> {
|
||||
if (viewModel != null) {
|
||||
Boolean currentEnabled = viewModel.getVideoEnabled().getValue();
|
||||
boolean needToEnable = currentEnabled == null || !currentEnabled;
|
||||
|
||||
if (needToEnable && !hasCameraPermission()) {
|
||||
awaitingPermissionResult = true;
|
||||
ActivityCompat.requestPermissions(
|
||||
this,
|
||||
new String[] {Manifest.permission.CAMERA},
|
||||
CAMERA_MID_CALL_PERMISSION_REQUEST_CODE);
|
||||
return;
|
||||
}
|
||||
|
||||
viewModel.toggleVideo();
|
||||
}
|
||||
});
|
||||
@@ -634,7 +653,7 @@ public class CallActivity extends AppCompatActivity {
|
||||
new Handler(Looper.getMainLooper())
|
||||
.postDelayed(
|
||||
() -> {
|
||||
if (!isFinishing()) {
|
||||
if (!isFinishing() && !doNotAutoFinish) {
|
||||
finish();
|
||||
}
|
||||
},
|
||||
@@ -643,7 +662,9 @@ public class CallActivity extends AppCompatActivity {
|
||||
|
||||
case ENDED:
|
||||
statusText.setText(R.string.call_ended);
|
||||
finish();
|
||||
if (!doNotAutoFinish) {
|
||||
finish();
|
||||
}
|
||||
break;
|
||||
|
||||
case ERROR:
|
||||
@@ -656,7 +677,7 @@ public class CallActivity extends AppCompatActivity {
|
||||
new Handler(Looper.getMainLooper())
|
||||
.postDelayed(
|
||||
() -> {
|
||||
if (!isFinishing()) {
|
||||
if (!isFinishing() && !doNotAutoFinish) {
|
||||
finish();
|
||||
}
|
||||
},
|
||||
@@ -842,16 +863,28 @@ public class CallActivity extends AppCompatActivity {
|
||||
}
|
||||
|
||||
private void handleMicPermissionDenied() {
|
||||
Toast.makeText(this, R.string.call_requires_mic_permission, Toast.LENGTH_LONG).show();
|
||||
|
||||
CallCoordinator coordinator = CallCoordinator.getInstance(getApplication());
|
||||
if (coordinator.hasActiveCall()
|
||||
&& coordinator.isIncomingCall()
|
||||
&& !coordinator.hasOngoingCall()) {
|
||||
coordinator.declineCall();
|
||||
|
||||
if (coordinator.hasActiveCall() && !coordinator.hasOngoingCall()) {
|
||||
if (coordinator.isIncomingCall()) {
|
||||
coordinator.declineCall();
|
||||
} else {
|
||||
coordinator.hangUp();
|
||||
}
|
||||
}
|
||||
|
||||
finish();
|
||||
if (!shouldShowRequestPermissionRationale(Manifest.permission.RECORD_AUDIO)) {
|
||||
doNotAutoFinish = true;
|
||||
showPermissionSettingsDialog(
|
||||
getString(R.string.call_requires_mic_permission),
|
||||
() -> {
|
||||
doNotAutoFinish = false;
|
||||
if (!isFinishing()) finish();
|
||||
});
|
||||
} else {
|
||||
Toast.makeText(this, R.string.call_requires_mic_permission, Toast.LENGTH_LONG).show();
|
||||
finish();
|
||||
}
|
||||
}
|
||||
|
||||
private void proceedAfterPermissions() {
|
||||
@@ -878,6 +911,22 @@ public class CallActivity extends AppCompatActivity {
|
||||
|
||||
CallCoordinator coordinator = CallCoordinator.getInstance(getApplication());
|
||||
|
||||
if (requestCode == CAMERA_MID_CALL_PERMISSION_REQUEST_CODE) {
|
||||
boolean cameraGranted =
|
||||
grantResults.length > 0 && grantResults[0] == PackageManager.PERMISSION_GRANTED;
|
||||
|
||||
if (cameraGranted && viewModel != null) {
|
||||
viewModel.toggleVideo();
|
||||
} else {
|
||||
if (!shouldShowRequestPermissionRationale(Manifest.permission.CAMERA)) {
|
||||
showPermissionSettingsDialog(getString(R.string.call_requires_camera_permission), null);
|
||||
} else {
|
||||
Toast.makeText(this, R.string.call_requires_camera_permission, Toast.LENGTH_SHORT).show();
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (requestCode == MIC_PERMISSION_REQUEST_CODE) {
|
||||
boolean micGranted =
|
||||
grantResults.length > 0 && grantResults[0] == PackageManager.PERMISSION_GRANTED;
|
||||
@@ -893,9 +942,17 @@ public class CallActivity extends AppCompatActivity {
|
||||
|
||||
if (!cameraGranted) {
|
||||
Log.w(TAG, "Camera permission denied, switching to audio-only");
|
||||
Toast.makeText(
|
||||
this, "Starting audio-only call (camera permission denied)", Toast.LENGTH_SHORT)
|
||||
.show();
|
||||
if (!shouldShowRequestPermissionRationale(Manifest.permission.CAMERA)) {
|
||||
Toast.makeText(
|
||||
this,
|
||||
"Camera permission permanently denied. Enable in app settings for video calls.",
|
||||
Toast.LENGTH_LONG)
|
||||
.show();
|
||||
} else {
|
||||
Toast.makeText(
|
||||
this, "Starting audio-only call (camera permission denied)", Toast.LENGTH_SHORT)
|
||||
.show();
|
||||
}
|
||||
coordinator.setStartsWithVideo(false);
|
||||
}
|
||||
}
|
||||
@@ -903,6 +960,34 @@ public class CallActivity extends AppCompatActivity {
|
||||
proceedAfterPermissions();
|
||||
}
|
||||
|
||||
private void showPermissionSettingsDialog(String message, @Nullable Runnable onDismissAction) {
|
||||
if (isFinishing() || isDestroyed()) {
|
||||
if (onDismissAction != null) onDismissAction.run();
|
||||
return;
|
||||
}
|
||||
|
||||
new AlertDialog.Builder(this)
|
||||
.setTitle("Permission Required")
|
||||
.setMessage(message)
|
||||
.setPositiveButton(
|
||||
"Open Settings",
|
||||
(dialog, which) -> {
|
||||
Intent intent =
|
||||
new Intent(
|
||||
Settings.ACTION_APPLICATION_DETAILS_SETTINGS,
|
||||
Uri.fromParts("package", getPackageName(), null));
|
||||
startActivity(intent);
|
||||
})
|
||||
.setNegativeButton(android.R.string.cancel, null)
|
||||
.setOnDismissListener(
|
||||
dialog -> {
|
||||
if (onDismissAction != null) {
|
||||
onDismissAction.run();
|
||||
}
|
||||
})
|
||||
.show();
|
||||
}
|
||||
|
||||
// Picture-in-Picture
|
||||
|
||||
@Override
|
||||
|
||||
@@ -23,6 +23,7 @@ import android.os.Looper;
|
||||
import android.telecom.DisconnectCause;
|
||||
import android.util.Log;
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.annotation.RequiresApi;
|
||||
import androidx.core.app.NotificationManagerCompat;
|
||||
import androidx.core.content.ContextCompat;
|
||||
@@ -32,6 +33,7 @@ import androidx.core.telecom.CallControlScope;
|
||||
import androidx.core.telecom.CallEndpointCompat;
|
||||
import androidx.core.telecom.CallException;
|
||||
import androidx.core.telecom.CallsManager;
|
||||
import androidx.core.util.Pair;
|
||||
import androidx.lifecycle.FlowLiveDataConversions;
|
||||
import androidx.lifecycle.LiveData;
|
||||
import androidx.lifecycle.MediatorLiveData;
|
||||
@@ -97,6 +99,7 @@ public class CallCoordinator implements DcEventCenter.DcEventDelegate {
|
||||
private final MutableLiveData<Boolean> outgoingCallPlaced = new MutableLiveData<>(false);
|
||||
private final MutableLiveData<Boolean> answeredElsewhere = new MutableLiveData<>(false);
|
||||
private final MutableLiveData<Boolean> isFrontCamera = new MutableLiveData<>(true);
|
||||
private final MutableLiveData<Boolean> mediaCaptureReady = new MutableLiveData<>(false);
|
||||
|
||||
// Audio Routing Support
|
||||
private final MediatorLiveData<CallEndpointCompat> currentAudioEndpoint =
|
||||
@@ -119,6 +122,7 @@ public class CallCoordinator implements DcEventCenter.DcEventDelegate {
|
||||
private String pendingOfferSdp;
|
||||
private boolean hasNotifiedBackend = false;
|
||||
private boolean hasAutoSelectedEarpiece = false;
|
||||
private boolean pendingMediaCapture = false;
|
||||
|
||||
private CallControlScope activeCallControlScope;
|
||||
private CallViewModel activeCallViewModel;
|
||||
@@ -228,6 +232,11 @@ public class CallCoordinator implements DcEventCenter.DcEventDelegate {
|
||||
mainHandler.removeCallbacks(outgoingRingtoneRunnable);
|
||||
mainHandler.postDelayed(outgoingRingtoneRunnable, 1500);
|
||||
}
|
||||
|
||||
if (pendingMediaCapture) {
|
||||
pendingMediaCapture = false;
|
||||
callService.startMediaCapture();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -336,6 +345,10 @@ public class CallCoordinator implements DcEventCenter.DcEventDelegate {
|
||||
return isFrontCamera;
|
||||
}
|
||||
|
||||
public LiveData<Boolean> getMediaCaptureReady() {
|
||||
return mediaCaptureReady;
|
||||
}
|
||||
|
||||
// State Update Methods (CallService)
|
||||
|
||||
public void updateConnectionState(PeerConnection.PeerConnectionState state) {
|
||||
@@ -375,6 +388,11 @@ public class CallCoordinator implements DcEventCenter.DcEventDelegate {
|
||||
isFrontCamera.postValue(front);
|
||||
}
|
||||
|
||||
public void updateMediaCaptureReady(boolean ready) {
|
||||
Log.d(TAG, "updateMediaCaptureReady: " + ready);
|
||||
mediaCaptureReady.postValue(ready);
|
||||
}
|
||||
|
||||
public void reportError(String error) {
|
||||
Log.e(TAG, "reportError: " + error);
|
||||
errorMessage.postValue(error);
|
||||
@@ -387,7 +405,8 @@ public class CallCoordinator implements DcEventCenter.DcEventDelegate {
|
||||
if (callService != null) {
|
||||
callService.startMediaCapture();
|
||||
} else {
|
||||
Log.w(TAG, "Cannot start media capture, service not ready");
|
||||
Log.d(TAG, "Service not ready, deferring media capture");
|
||||
pendingMediaCapture = true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -651,11 +670,17 @@ public class CallCoordinator implements DcEventCenter.DcEventDelegate {
|
||||
public synchronized void setVideoEnabled(boolean enabled) {
|
||||
Log.d(TAG, "setVideoEnabled: " + enabled);
|
||||
|
||||
if (callService != null) {
|
||||
boolean success = callService.setVideoEnabled(enabled);
|
||||
if (!success && enabled) {
|
||||
enabled = false;
|
||||
reportError("Camera unavailable");
|
||||
}
|
||||
}
|
||||
|
||||
localVideoEnabled.postValue(enabled);
|
||||
|
||||
if (callService != null) {
|
||||
callService.setVideoEnabled(enabled);
|
||||
|
||||
callService.sendMutedState(Boolean.TRUE.equals(localAudioEnabled.getValue()), enabled);
|
||||
}
|
||||
}
|
||||
@@ -875,32 +900,15 @@ public class CallCoordinator implements DcEventCenter.DcEventDelegate {
|
||||
int accId, int callId, String offerSdp, boolean startsWithVideo) {
|
||||
Log.d(TAG, "onIncomingCall: accId=" + accId + ", callId=" + callId);
|
||||
|
||||
if (hasActiveCall()) {
|
||||
Log.w(TAG, "Already have an active call, ignoring incoming call");
|
||||
return;
|
||||
}
|
||||
Pair<DcChat, String> result = setupIncomingCallState(accId, callId, offerSdp, startsWithVideo);
|
||||
if (result == null) return;
|
||||
|
||||
resetLiveDataForNewCall();
|
||||
|
||||
this.activeAccId = accId;
|
||||
this.activeCallId = callId;
|
||||
this.isIncomingCall = true;
|
||||
this.startsWithVideo = startsWithVideo;
|
||||
this.pendingOfferSdp = offerSdp;
|
||||
|
||||
// Get caller info
|
||||
DcContext dcContext = ApplicationContext.getDcAccounts().getAccount(accId);
|
||||
int chatId = dcContext.getMsg(callId).getChatId();
|
||||
this.activeChatId = chatId;
|
||||
DcChat dcChat = dcContext.getChat(chatId);
|
||||
String callerName = getNameFromChat(dcChat);
|
||||
DcChat dcChat = result.first;
|
||||
String callerName = result.second;
|
||||
Icon callerIcon = getIconFromChat(this.appContext, dcChat);
|
||||
|
||||
displayName.postValue(callerName);
|
||||
displayIcon.postValue(callerIcon);
|
||||
|
||||
this.preferredStartingEndpoint = getPreferredStartingEndpoint(startsWithVideo);
|
||||
|
||||
// Add to CallsManager
|
||||
CallAttributesCompat callAttributes = createCallAttributes(callerName, callId, true);
|
||||
addCallToTelecom(callAttributes, callerName, callerIcon);
|
||||
@@ -911,6 +919,37 @@ public class CallCoordinator implements DcEventCenter.DcEventDelegate {
|
||||
startAndBindService();
|
||||
}
|
||||
|
||||
public synchronized void handleIncomingCallFromConversation(
|
||||
int accId, int callId, String offerSdp, boolean hasVideo) {
|
||||
Log.d(TAG, "handleIncomingCallFromConversation: accId=" + accId + ", callId=" + callId);
|
||||
|
||||
if (offerSdp == null || offerSdp.isEmpty()) {
|
||||
Log.e(TAG, "Cannot start incoming call: no SDP offer");
|
||||
return;
|
||||
}
|
||||
|
||||
Pair<DcChat, String> result = setupIncomingCallState(accId, callId, offerSdp, hasVideo);
|
||||
if (result == null) return;
|
||||
|
||||
DcChat dcChat = result.first;
|
||||
String callerName = result.second;
|
||||
|
||||
new Thread(
|
||||
() -> {
|
||||
Icon callerIcon = getIconFromChat(this.appContext, dcChat);
|
||||
displayIcon.postValue(callerIcon);
|
||||
})
|
||||
.start();
|
||||
|
||||
// Add to CallsManager
|
||||
CallAttributesCompat callAttributes = createCallAttributes(callerName, callId, true);
|
||||
addCallToTelecom(callAttributes, callerName, null);
|
||||
|
||||
startAndBindService();
|
||||
|
||||
launchCallActivity();
|
||||
}
|
||||
|
||||
private synchronized void onIncomingCallAccepted(int callId, boolean fromThisDevice) {
|
||||
Log.d(TAG, "onIncomingCallAccepted: callId=" + callId + ", fromThisDevice=" + fromThisDevice);
|
||||
|
||||
@@ -1129,6 +1168,7 @@ public class CallCoordinator implements DcEventCenter.DcEventDelegate {
|
||||
this.pendingOfferSdp = null;
|
||||
this.hasNotifiedBackend = false;
|
||||
this.hasAutoSelectedEarpiece = false;
|
||||
this.pendingMediaCapture = false;
|
||||
|
||||
mainHandler.removeCallbacks(outgoingRingtoneRunnable);
|
||||
|
||||
@@ -1194,6 +1234,7 @@ public class CallCoordinator implements DcEventCenter.DcEventDelegate {
|
||||
private void resetLiveDataForNewCall() {
|
||||
connectionState.postValue(PeerConnection.PeerConnectionState.NEW);
|
||||
answeredElsewhere.postValue(false); // clearLiveData() must not reset answeredElsewhere
|
||||
mediaCaptureReady.postValue(false);
|
||||
clearLiveData();
|
||||
}
|
||||
|
||||
@@ -1209,6 +1250,7 @@ public class CallCoordinator implements DcEventCenter.DcEventDelegate {
|
||||
outgoingCallPlaced.postValue(false);
|
||||
currentAudioEndpoint.postValue(null);
|
||||
availableAudioEndpoints.postValue(null);
|
||||
mediaCaptureReady.postValue(false);
|
||||
}
|
||||
|
||||
public synchronized void initiateOutgoingCall(int accId, int chatId, boolean startsWithVideo) {
|
||||
@@ -1219,16 +1261,6 @@ public class CallCoordinator implements DcEventCenter.DcEventDelegate {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check microphone permission
|
||||
if (!hasMicrophonePermission()) {
|
||||
Log.e(TAG, "Microphone permission not granted");
|
||||
|
||||
Intent intent = new Intent(appContext, CallActivity.class);
|
||||
intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_SINGLE_TOP);
|
||||
appContext.startActivity(intent);
|
||||
return;
|
||||
}
|
||||
|
||||
resetLiveDataForNewCall();
|
||||
|
||||
this.activeCallId = -1; // Placeholder call ID for Intent
|
||||
@@ -1254,7 +1286,9 @@ public class CallCoordinator implements DcEventCenter.DcEventDelegate {
|
||||
|
||||
this.preferredStartingEndpoint = getPreferredStartingEndpoint(startsWithVideo);
|
||||
|
||||
startAndBindService();
|
||||
if (hasMicrophonePermission()) {
|
||||
startAndBindService();
|
||||
}
|
||||
|
||||
launchCallActivity();
|
||||
}
|
||||
@@ -1286,6 +1320,43 @@ public class CallCoordinator implements DcEventCenter.DcEventDelegate {
|
||||
addCallToTelecom(callAttributes, calleeName, calleeIcon);
|
||||
}
|
||||
|
||||
@Nullable
|
||||
private Pair<DcChat, String> setupIncomingCallState(
|
||||
int accId, int callId, String offerSdp, boolean startsWithVideo) {
|
||||
if (hasActiveCall()) {
|
||||
Log.w(TAG, "Already have an active call, ignoring incoming call");
|
||||
return null;
|
||||
}
|
||||
|
||||
resetLiveDataForNewCall();
|
||||
|
||||
this.activeAccId = accId;
|
||||
this.activeCallId = callId;
|
||||
this.isIncomingCall = true;
|
||||
this.startsWithVideo = startsWithVideo;
|
||||
this.pendingOfferSdp = offerSdp;
|
||||
|
||||
DcContext dcContext = ApplicationContext.getDcAccounts().getAccount(accId);
|
||||
int chatId = dcContext.getMsg(callId).getChatId();
|
||||
this.activeChatId = chatId;
|
||||
DcChat dcChat = dcContext.getChat(chatId);
|
||||
String callerName = getNameFromChat(dcChat);
|
||||
|
||||
displayName.postValue(callerName);
|
||||
|
||||
this.preferredStartingEndpoint = getPreferredStartingEndpoint(startsWithVideo);
|
||||
|
||||
return new Pair<>(dcChat, callerName);
|
||||
}
|
||||
|
||||
public synchronized void ensureServiceStarted() {
|
||||
if (isServiceBound || !hasActiveCall()) {
|
||||
return;
|
||||
}
|
||||
Log.d(TAG, "Starting service after permission grant");
|
||||
startAndBindService();
|
||||
}
|
||||
|
||||
private void addCallToTelecom(
|
||||
CallAttributesCompat callAttributes, String displayName, Icon icon) {
|
||||
try {
|
||||
|
||||
@@ -126,13 +126,13 @@ public class CallService extends Service implements WebRTCClient.Callbacks {
|
||||
}
|
||||
|
||||
/**
|
||||
* Start camera/microphone capture
|
||||
* Start media capture
|
||||
*
|
||||
* <p>Must be called when app is in foreground. Called by coordinator when ViewModel/Activity is
|
||||
* ready.
|
||||
*/
|
||||
public void startMediaCapture() {
|
||||
Log.d(TAG, "startMediaCapture (Camera/Microphone)");
|
||||
Log.d(TAG, "startMediaCapture");
|
||||
|
||||
if (webRTCClient != null && webRTCClient.hasLocalMediaStream()) {
|
||||
Log.w(TAG, "Media already initialized, skipping");
|
||||
@@ -153,7 +153,7 @@ public class CallService extends Service implements WebRTCClient.Callbacks {
|
||||
|
||||
boolean startsWithVideo = callCoordinator.isStartsWithVideo();
|
||||
|
||||
Log.d(TAG, "Creating media stream with video: " + startsWithVideo);
|
||||
Log.d(TAG, "Creating media stream");
|
||||
|
||||
mediaStreamManager.createMediaStream(
|
||||
new MediaStreamManager.Callback() {
|
||||
@@ -163,31 +163,22 @@ public class CallService extends Service implements WebRTCClient.Callbacks {
|
||||
|
||||
webRTCClient.setLocalMediaStream(stream);
|
||||
|
||||
callCoordinator.updateFrontCamera(mediaStreamManager.isFrontCamera());
|
||||
|
||||
callCoordinator.setVideoEnabled(startsWithVideo);
|
||||
|
||||
if (!stream.videoTracks.isEmpty()) {
|
||||
VideoTrack localTrack = stream.videoTracks.get(0);
|
||||
callCoordinator.updateLocalVideoTrack(localTrack);
|
||||
} else {
|
||||
Log.w(TAG, "No video track in stream, call will be audio-only");
|
||||
if (startsWithVideo) {
|
||||
callCoordinator.reportError("Camera unavailable, using audio only");
|
||||
}
|
||||
callCoordinator.setVideoEnabled(false);
|
||||
}
|
||||
|
||||
callCoordinator.setVideoEnabled(startsWithVideo);
|
||||
|
||||
callCoordinator.updateMediaCaptureReady(true);
|
||||
|
||||
Log.d(TAG, "Media capture complete, ready for call");
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onError(String error) {
|
||||
Log.e(TAG, "Failed to setup media: " + error);
|
||||
if (startsWithVideo) {
|
||||
callCoordinator.reportError("Camera/microphone error: " + error);
|
||||
}
|
||||
callCoordinator.setVideoEnabled(false);
|
||||
callCoordinator.reportError("Camera/microphone error: " + error);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -395,12 +386,30 @@ public class CallService extends Service implements WebRTCClient.Callbacks {
|
||||
}
|
||||
}
|
||||
|
||||
public void setVideoEnabled(boolean enabled) {
|
||||
public boolean setVideoEnabled(boolean enabled) {
|
||||
Log.d(TAG, "setVideoEnabled: " + enabled);
|
||||
|
||||
if (webRTCClient != null) {
|
||||
webRTCClient.setVideoEnabled(enabled);
|
||||
if (enabled) {
|
||||
if (mediaStreamManager != null) {
|
||||
boolean captureReady = mediaStreamManager.startVideoCapture();
|
||||
if (!captureReady) {
|
||||
Log.w(TAG, "Failed to start video capture");
|
||||
return false;
|
||||
}
|
||||
callCoordinator.updateFrontCamera(mediaStreamManager.isFrontCamera());
|
||||
}
|
||||
if (webRTCClient != null) {
|
||||
webRTCClient.setVideoEnabled(true);
|
||||
}
|
||||
} else {
|
||||
if (webRTCClient != null) {
|
||||
webRTCClient.setVideoEnabled(false);
|
||||
}
|
||||
if (mediaStreamManager != null) {
|
||||
mediaStreamManager.stopVideoCapture();
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
public void sendMutedState(boolean audioEnabled, boolean videoEnabled) {
|
||||
|
||||
@@ -46,8 +46,8 @@ public class CallViewModel extends AndroidViewModel {
|
||||
private final MediatorLiveData<CallState> callState;
|
||||
|
||||
// Observer References for one-time observe
|
||||
private Observer<VideoTrack> answerCallObserver;
|
||||
private Observer<VideoTrack> startOutgoingCallObserver;
|
||||
private Observer<Boolean> answerCallObserver;
|
||||
private Observer<Boolean> startOutgoingCallObserver;
|
||||
|
||||
private final AtomicBoolean hasCallEnded = new AtomicBoolean(false);
|
||||
|
||||
@@ -211,25 +211,24 @@ public class CallViewModel extends AndroidViewModel {
|
||||
callCoordinator.startMediaCapture();
|
||||
|
||||
// Create one-time observer
|
||||
LiveData<VideoTrack> localTrack = callCoordinator.getLocalVideoTrack();
|
||||
LiveData<Boolean> mediaReady = callCoordinator.getMediaCaptureReady();
|
||||
|
||||
answerCallObserver =
|
||||
new Observer<VideoTrack>() {
|
||||
new Observer<Boolean>() {
|
||||
@Override
|
||||
public void onChanged(VideoTrack videoTrack) {
|
||||
if (videoTrack != null) {
|
||||
// Media is ready, remove observer
|
||||
localTrack.removeObserver(this);
|
||||
public void onChanged(Boolean ready) {
|
||||
if (Boolean.TRUE.equals(ready)) {
|
||||
mediaReady.removeObserver(this);
|
||||
answerCallObserver = null;
|
||||
|
||||
Log.d(TAG, "Local video ready, answering call (WebRTC)");
|
||||
Log.d(TAG, "Media capture ready, answering call (WebRTC)");
|
||||
|
||||
callCoordinator.answerWebRTC();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
localTrack.observeForever(answerCallObserver);
|
||||
mediaReady.observeForever(answerCallObserver);
|
||||
}
|
||||
|
||||
/** Start outgoing call with media capture Called by Activity for outgoing calls */
|
||||
@@ -244,30 +243,28 @@ public class CallViewModel extends AndroidViewModel {
|
||||
callCoordinator.startMediaCapture();
|
||||
|
||||
// Create one-time observer
|
||||
LiveData<VideoTrack> localTrack = callCoordinator.getLocalVideoTrack();
|
||||
VideoTrack currentValue = localTrack.getValue();
|
||||
LiveData<Boolean> mediaReady = callCoordinator.getMediaCaptureReady();
|
||||
|
||||
if (currentValue != null) {
|
||||
if (Boolean.TRUE.equals(mediaReady.getValue())) {
|
||||
Log.d(TAG, "Media already ready, starting call immediately");
|
||||
callCoordinator.startOutgoingCall();
|
||||
} else {
|
||||
startOutgoingCallObserver =
|
||||
new Observer<VideoTrack>() {
|
||||
new Observer<Boolean>() {
|
||||
@Override
|
||||
public void onChanged(VideoTrack videoTrack) {
|
||||
if (videoTrack != null) {
|
||||
// Media is ready, remove observer
|
||||
localTrack.removeObserver(this);
|
||||
public void onChanged(Boolean ready) {
|
||||
if (Boolean.TRUE.equals(ready)) {
|
||||
mediaReady.removeObserver(this);
|
||||
startOutgoingCallObserver = null;
|
||||
|
||||
Log.d(TAG, "Local video ready, starting outgoing call");
|
||||
Log.d(TAG, "Media capture ready, starting outgoing call");
|
||||
|
||||
callCoordinator.startOutgoingCall();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
localTrack.observeForever(startOutgoingCallObserver);
|
||||
mediaReady.observeForever(startOutgoingCallObserver);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -460,12 +457,12 @@ public class CallViewModel extends AndroidViewModel {
|
||||
Log.d(TAG, "CallViewModel cleared");
|
||||
|
||||
if (answerCallObserver != null) {
|
||||
callCoordinator.getLocalVideoTrack().removeObserver(answerCallObserver);
|
||||
callCoordinator.getMediaCaptureReady().removeObserver(answerCallObserver);
|
||||
answerCallObserver = null;
|
||||
}
|
||||
|
||||
if (startOutgoingCallObserver != null) {
|
||||
callCoordinator.getLocalVideoTrack().removeObserver(startOutgoingCallObserver);
|
||||
callCoordinator.getMediaCaptureReady().removeObserver(startOutgoingCallObserver);
|
||||
startOutgoingCallObserver = null;
|
||||
}
|
||||
|
||||
|
||||
@@ -29,6 +29,10 @@ public class MediaStreamManager {
|
||||
private static final String AUDIO_TRACK_ID = "audio_track";
|
||||
private static final String VIDEO_TRACK_ID = "video_track";
|
||||
|
||||
private static final int VIDEO_WIDTH = 1280;
|
||||
private static final int VIDEO_HEIGHT = 720;
|
||||
private static final int VIDEO_FPS = 30;
|
||||
|
||||
private final Context context;
|
||||
private final PeerConnectionFactory peerConnectionFactory;
|
||||
|
||||
@@ -37,6 +41,7 @@ public class MediaStreamManager {
|
||||
private AudioSource audioSource;
|
||||
private SurfaceTextureHelper surfaceTextureHelper;
|
||||
private volatile boolean isFrontCamera = true;
|
||||
private volatile boolean isCapturing = false;
|
||||
|
||||
public interface Callback {
|
||||
void onMediaStreamReady(MediaStream stream);
|
||||
@@ -56,9 +61,8 @@ public class MediaStreamManager {
|
||||
this.peerConnectionFactory = peerConnectionFactory;
|
||||
}
|
||||
|
||||
/** Create media stream with audio and optionally video */
|
||||
@RequiresApi(api = Build.VERSION_CODES.M)
|
||||
public void createMediaStream(Callback callback) {
|
||||
/** Create a media stream with an audio track and a video track. */
|
||||
public synchronized void createMediaStream(Callback callback) {
|
||||
try {
|
||||
MediaStream mediaStream = peerConnectionFactory.createLocalMediaStream(STREAM_ID);
|
||||
|
||||
@@ -68,24 +72,11 @@ public class MediaStreamManager {
|
||||
AudioTrack audioTrack = peerConnectionFactory.createAudioTrack(AUDIO_TRACK_ID, audioSource);
|
||||
mediaStream.addTrack(audioTrack);
|
||||
|
||||
// Create video track
|
||||
videoCapturer = createVideoCapturer();
|
||||
if (videoCapturer == null) {
|
||||
callback.onError("No camera available");
|
||||
callback.onMediaStreamReady(mediaStream);
|
||||
return;
|
||||
}
|
||||
|
||||
videoSource = peerConnectionFactory.createVideoSource(videoCapturer.isScreencast());
|
||||
// Create video source and track
|
||||
videoSource = peerConnectionFactory.createVideoSource(false);
|
||||
VideoTrack videoTrack = peerConnectionFactory.createVideoTrack(VIDEO_TRACK_ID, videoSource);
|
||||
mediaStream.addTrack(videoTrack);
|
||||
|
||||
// Start capturing
|
||||
surfaceTextureHelper =
|
||||
SurfaceTextureHelper.create("CaptureThread", EglUtils.getEglBase().getEglBaseContext());
|
||||
videoCapturer.initialize(surfaceTextureHelper, context, videoSource.getCapturerObserver());
|
||||
videoCapturer.startCapture(1280, 720, 30);
|
||||
|
||||
callback.onMediaStreamReady(mediaStream);
|
||||
|
||||
} catch (Exception e) {
|
||||
@@ -94,6 +85,62 @@ public class MediaStreamManager {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Open the camera and start sending frames to VideoSource.
|
||||
*
|
||||
* @return true if the camera is capturing, false if it could not be started
|
||||
*/
|
||||
@RequiresApi(api = Build.VERSION_CODES.M)
|
||||
public synchronized boolean startVideoCapture() {
|
||||
if (isCapturing) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (videoSource == null) {
|
||||
Log.e(TAG, "VideoSource not initialized");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (videoCapturer == null) {
|
||||
videoCapturer = createVideoCapturer();
|
||||
if (videoCapturer == null) {
|
||||
Log.w(TAG, "Cannot start video capture: no camera available");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (surfaceTextureHelper == null) {
|
||||
surfaceTextureHelper =
|
||||
SurfaceTextureHelper.create("CaptureThread", EglUtils.getEglBase().getEglBaseContext());
|
||||
}
|
||||
|
||||
videoCapturer.initialize(surfaceTextureHelper, context, videoSource.getCapturerObserver());
|
||||
}
|
||||
|
||||
videoCapturer.startCapture(VIDEO_WIDTH, VIDEO_HEIGHT, VIDEO_FPS);
|
||||
isCapturing = true;
|
||||
Log.d(TAG, "Video capture started");
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Stop the camera. The capturer is kept alive. */
|
||||
public synchronized void stopVideoCapture() {
|
||||
if (!isCapturing) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (videoCapturer != null) {
|
||||
try {
|
||||
videoCapturer.stopCapture();
|
||||
Log.d(TAG, "Video capture stopped");
|
||||
} catch (InterruptedException e) {
|
||||
Log.e(TAG, "Interrupted while stopping capture", e);
|
||||
Thread.currentThread().interrupt();
|
||||
}
|
||||
}
|
||||
|
||||
isCapturing = false;
|
||||
}
|
||||
|
||||
@Nullable
|
||||
private VideoCapturer createVideoCapturer() {
|
||||
if (ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA)
|
||||
@@ -129,6 +176,14 @@ public class MediaStreamManager {
|
||||
}
|
||||
|
||||
public void switchCamera(@Nullable CameraSwitchCallback callback) {
|
||||
if (!isCapturing) {
|
||||
Log.w(TAG, "Cannot switch camera while not capturing");
|
||||
if (callback != null) {
|
||||
callback.onError("Camera not active");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!(videoCapturer instanceof CameraVideoCapturer)) {
|
||||
Log.e(TAG, "switchCamera called but videoCapturer is not a CameraVideoCapturer");
|
||||
return;
|
||||
@@ -186,10 +241,12 @@ public class MediaStreamManager {
|
||||
}
|
||||
|
||||
/** Cleanup resources */
|
||||
public void dispose() {
|
||||
public synchronized void dispose() {
|
||||
if (videoCapturer != null) {
|
||||
try {
|
||||
videoCapturer.stopCapture();
|
||||
if (isCapturing) {
|
||||
videoCapturer.stopCapture();
|
||||
}
|
||||
} catch (InterruptedException e) {
|
||||
Log.e(TAG, "Error stopping capture", e);
|
||||
}
|
||||
@@ -197,6 +254,8 @@ public class MediaStreamManager {
|
||||
videoCapturer = null;
|
||||
}
|
||||
|
||||
isCapturing = false;
|
||||
|
||||
if (surfaceTextureHelper != null) {
|
||||
surfaceTextureHelper.dispose();
|
||||
surfaceTextureHelper = null;
|
||||
|
||||
@@ -188,9 +188,8 @@ public class LocationStreamingService extends Service {
|
||||
NotificationChannel channel =
|
||||
new NotificationChannel(
|
||||
CHANNEL_ID,
|
||||
getString(R.string.location_streaming_notification_title),
|
||||
getString(R.string.pref_on_demand_location_streaming),
|
||||
NotificationManager.IMPORTANCE_LOW);
|
||||
channel.setDescription(getString(R.string.location_streaming_channel_desc));
|
||||
channel.setShowBadge(false);
|
||||
NotificationManager nm = getSystemService(NotificationManager.class);
|
||||
if (nm != null) nm.createNotificationChannel(channel);
|
||||
@@ -209,7 +208,7 @@ public class LocationStreamingService extends Service {
|
||||
PendingIntent.getService(this, 1, stopIntent, PendingIntent.FLAG_IMMUTABLE);
|
||||
|
||||
return new NotificationCompat.Builder(this, CHANNEL_ID)
|
||||
.setContentTitle(getString(R.string.location_streaming_notification_title))
|
||||
.setContentTitle(getString(R.string.pref_on_demand_location_streaming))
|
||||
.setContentText(getString(R.string.location_streaming_notification_text))
|
||||
.setSmallIcon(R.drawable.ic_location_on_white_24dp)
|
||||
.setOngoing(true)
|
||||
|
||||
+12
@@ -39,6 +39,7 @@ public class NotificationsPreferenceFragment extends ListSummaryPreferenceFragme
|
||||
private CheckBoxPreference ignoreBattery;
|
||||
private CheckBoxPreference notificationsEnabled;
|
||||
private CheckBoxPreference mentionNotifEnabled;
|
||||
private CheckBoxPreference notifyCalls;
|
||||
private CheckBoxPreference reliableService;
|
||||
private ActivityResultLauncher<Intent> ringtonePickerLauncher;
|
||||
|
||||
@@ -137,6 +138,16 @@ public class NotificationsPreferenceFragment extends ListSummaryPreferenceFragme
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
notifyCalls = this.findPreference("pref_notify_calls");
|
||||
if (notifyCalls != null) {
|
||||
notifyCalls.setOnPreferenceChangeListener(
|
||||
(preference, newValue) -> {
|
||||
boolean enabled = (Boolean) newValue;
|
||||
dcContext.setConfig("who_can_call_me", enabled ? "1" : "2");
|
||||
return true;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -156,6 +167,7 @@ public class NotificationsPreferenceFragment extends ListSummaryPreferenceFragme
|
||||
notificationsEnabled.setChecked(!dcContext.isMuted());
|
||||
notificationsEnabled.setSummary(getSummary(getContext(), false));
|
||||
mentionNotifEnabled.setChecked(dcContext.isMentionsEnabled());
|
||||
notifyCalls.setChecked(dcContext.getConfig("who_can_call_me") != "2");
|
||||
|
||||
// set without altering "unset" state of the preference
|
||||
reliableService.setOnPreferenceChangeListener(null);
|
||||
|
||||
@@ -66,7 +66,7 @@ public class ShareUtil {
|
||||
}
|
||||
}
|
||||
|
||||
static int[] getForwardedMessageIDs(Activity activity) {
|
||||
public static int[] getForwardedMessageIDs(Activity activity) {
|
||||
try {
|
||||
return activity.getIntent().getIntArrayExtra(FORWARDED_MESSAGE_IDS);
|
||||
} catch (NullPointerException npe) {
|
||||
@@ -174,8 +174,4 @@ public class ShareUtil {
|
||||
public static void setSharedTitle(Intent composeIntent, String text) {
|
||||
composeIntent.putExtra(SHARED_TITLE, text);
|
||||
}
|
||||
|
||||
public static void setDirectSharing(Intent composeIntent, int chatId) {
|
||||
composeIntent.putExtra(DIRECT_SHARING_CHAT_ID, chatId);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user