Dialpad integration + contact number sync + improved tags (Cheogram)

This commit is contained in:
Arne 2023-05-18 17:31:41 +02:00
parent 2500a6fe11
commit 85794d85f8
25 changed files with 1269 additions and 43 deletions

View file

@ -107,6 +107,7 @@ dependencies {
coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:1.1.5'
implementation 'androidx.recyclerview:recyclerview:1.1.0'
implementation 'io.michaelrocks:libphonenumber-android:8.12.49'
implementation 'io.github.nishkarsh:android-permissions:2.0.54'
}
ext {

View file

@ -1,6 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.BIND_TELECOM_CONNECTION_SERVICE" />
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" />
@ -52,6 +53,10 @@
android:name="android.hardware.microphone"
android:required="false" />
<queries>
<package android:name="org.sufficientlysecure.keychain"/>
</queries>
<queries>
<!-- Browser -->
<intent>
@ -109,6 +114,16 @@
tools:replace="android:label, android:allowBackup"
tools:targetApi="r">
<service android:name="de.monocles.chat.ConnectionService"
android:label="@string/app_name"
android:permission="android.permission.BIND_TELECOM_CONNECTION_SERVICE"
android:exported="true">
<intent-filter>
<action android:name="android.telecom.ConnectionService" />
</intent-filter>
</service>
<meta-data
android:name="com.google.android.gms.car.application"
android:resource="@xml/automotive_app_desc" />

View file

@ -0,0 +1,266 @@
package de.monocles.chat;
import java.lang.ref.WeakReference;
import java.util.Collections;
import java.util.HashSet;
import java.util.Set;
import java.util.Stack;
import com.google.common.collect.ImmutableSet;
import android.telecom.CallAudioState;
import android.telecom.Connection;
import android.telecom.ConnectionRequest;
import android.telecom.DisconnectCause;
import android.telecom.PhoneAccount;
import android.telecom.PhoneAccountHandle;
import android.telecom.StatusHints;
import android.telecom.TelecomManager;
import android.telephony.PhoneNumberUtils;
import android.Manifest;
import androidx.core.content.ContextCompat;
import android.content.ComponentName;
import android.content.Context;
import android.content.Intent;
import android.content.pm.PackageManager;
import android.content.ServiceConnection;
import android.net.Uri;
import android.os.Bundle;
import android.os.IBinder;
import android.os.Parcel;
import android.util.Log;
import com.intentfilter.androidpermissions.PermissionManager;
import com.intentfilter.androidpermissions.models.DeniedPermissions;
import eu.siacs.conversations.entities.Account;
import eu.siacs.conversations.services.AppRTCAudioManager;
import eu.siacs.conversations.services.XmppConnectionService.XmppConnectionBinder;
import eu.siacs.conversations.services.XmppConnectionService;
import eu.siacs.conversations.ui.RtpSessionActivity;
import eu.siacs.conversations.xmpp.Jid;
import eu.siacs.conversations.xmpp.jingle.JingleRtpConnection;
import eu.siacs.conversations.xmpp.jingle.Media;
import eu.siacs.conversations.xmpp.jingle.RtpEndUserState;
public class ConnectionService extends android.telecom.ConnectionService {
public XmppConnectionService xmppConnectionService = null;
protected ServiceConnection mConnection = new ServiceConnection() {
@Override
public void onServiceConnected(ComponentName className, IBinder service) {
XmppConnectionBinder binder = (XmppConnectionBinder) service;
xmppConnectionService = binder.getService();
}
@Override
public void onServiceDisconnected(ComponentName arg0) {
xmppConnectionService = null;
}
};
@Override
public void onCreate() {
// From XmppActivity.connectToBackend
Intent intent = new Intent(this, XmppConnectionService.class);
intent.setAction("ui");
try {
startService(intent);
} catch (IllegalStateException e) {
Log.w(".ConnectionService", "unable to start service from " + getClass().getSimpleName());
}
bindService(intent, mConnection, Context.BIND_AUTO_CREATE);
}
@Override
public void onDestroy() {
unbindService(mConnection);
}
@Override
public Connection onCreateOutgoingConnection(
PhoneAccountHandle phoneAccountHandle,
ConnectionRequest request
) {
String[] gateway = phoneAccountHandle.getId().split("/", 2);
String rawTel = request.getAddress().getSchemeSpecificPart();
String postDial = PhoneNumberUtils.extractPostDialPortion(rawTel);
// TODO: jabber:iq:gateway
String tel = PhoneNumberUtils.extractNetworkPortion(rawTel);
if (tel.startsWith("1")) {
tel = "+" + tel;
} else if (!tel.startsWith("+")) {
tel = "+1" + tel;
}
if (xmppConnectionService.getJingleConnectionManager().isBusy()) {
return Connection.createFailedConnection(
new DisconnectCause(DisconnectCause.BUSY)
);
}
Account account = xmppConnectionService.findAccountByJid(Jid.of(gateway[0]));
Jid with = Jid.ofLocalAndDomain(tel, gateway[1]);
CheogramConnection connection = new CheogramConnection(account, with, postDial);
PermissionManager permissionManager = PermissionManager.getInstance(this);
Set<String> permissions = new HashSet();
permissions.add(Manifest.permission.RECORD_AUDIO);
permissionManager.checkPermissions(permissions, new PermissionManager.PermissionRequestListener() {
@Override
public void onPermissionGranted() {
connection.setSessionId(xmppConnectionService.getJingleConnectionManager().proposeJingleRtpSession(
account,
with,
ImmutableSet.of(Media.AUDIO)
));
}
@Override
public void onPermissionDenied(DeniedPermissions deniedPermissions) {
connection.setDisconnected(new DisconnectCause(DisconnectCause.ERROR));
}
});
connection.setAddress(
Uri.fromParts("tel", tel, null), // Normalized tel as tel: URI
TelecomManager.PRESENTATION_ALLOWED
);
connection.setCallerDisplayName(
account.getDisplayName(),
TelecomManager.PRESENTATION_ALLOWED
);
connection.setAudioModeIsVoip(true);
connection.setRingbackRequested(true);
connection.setDialing();
connection.setConnectionCapabilities(
Connection.CAPABILITY_CAN_SEND_RESPONSE_VIA_CONNECTION
);
xmppConnectionService.setOnRtpConnectionUpdateListener(
(XmppConnectionService.OnJingleRtpConnectionUpdate) connection
);
return connection;
}
public class CheogramConnection extends Connection implements XmppConnectionService.OnJingleRtpConnectionUpdate {
protected Account account;
protected Jid with;
protected String sessionId = null;
protected Stack<String> postDial = new Stack();
protected WeakReference<JingleRtpConnection> rtpConnection = null;
CheogramConnection(Account account, Jid with, String postDialString) {
super();
this.account = account;
this.with = with;
if (postDialString != null) {
for (int i = postDialString.length() - 1; i >= 0; i--) {
postDial.push("" + postDialString.charAt(i));
}
}
}
public void setSessionId(final String sessionId) {
this.sessionId = sessionId;
}
@Override
public void onJingleRtpConnectionUpdate(final Account account, final Jid with, final String sessionId, final RtpEndUserState state) {
if (sessionId == null || !sessionId.equals(this.sessionId)) return;
if (rtpConnection == null) {
this.with = with; // Store full JID of connection
rtpConnection = xmppConnectionService.getJingleConnectionManager().findJingleRtpConnection(account, with, sessionId);
}
if (state == RtpEndUserState.CONNECTED) {
xmppConnectionService.setDiallerIntegrationActive(true);
setActive();
postDial();
} else if (state == RtpEndUserState.DECLINED_OR_BUSY) {
setDisconnected(new DisconnectCause(DisconnectCause.BUSY));
} else if (state == RtpEndUserState.ENDED) {
setDisconnected(new DisconnectCause(DisconnectCause.LOCAL));
} else if (state == RtpEndUserState.RETRACTED) {
setDisconnected(new DisconnectCause(DisconnectCause.CANCELED));
} else if (RtpSessionActivity.END_CARD.contains(state)) {
setDisconnected(new DisconnectCause(DisconnectCause.ERROR));
}
}
@Override
public void onAudioDeviceChanged(AppRTCAudioManager.AudioDevice selectedAudioDevice, Set<AppRTCAudioManager.AudioDevice> availableAudioDevices) {
switch(selectedAudioDevice) {
case SPEAKER_PHONE:
setAudioRoute(CallAudioState.ROUTE_SPEAKER);
case WIRED_HEADSET:
setAudioRoute(CallAudioState.ROUTE_WIRED_HEADSET);
case EARPIECE:
setAudioRoute(CallAudioState.ROUTE_EARPIECE);
case BLUETOOTH:
setAudioRoute(CallAudioState.ROUTE_BLUETOOTH);
default:
setAudioRoute(CallAudioState.ROUTE_WIRED_OR_EARPIECE);
}
}
@Override
public void onDisconnect() {
if (rtpConnection == null || rtpConnection.get() == null) {
xmppConnectionService.getJingleConnectionManager().retractSessionProposal(account, with.asBareJid());
} else {
rtpConnection.get().endCall();
}
destroy();
xmppConnectionService.setDiallerIntegrationActive(false);
xmppConnectionService.removeRtpConnectionUpdateListener(
(XmppConnectionService.OnJingleRtpConnectionUpdate) this
);
}
@Override
public void onAbort() {
onDisconnect();
}
@Override
public void onPlayDtmfTone(char c) {
rtpConnection.get().applyDtmfTone("" + c);
}
@Override
public void onPostDialContinue(boolean c) {
if (c) postDial();
}
protected void sleep(int ms) {
try {
Thread.sleep(ms);
} catch (InterruptedException ex) {
Thread.currentThread().interrupt();
}
}
protected void postDial() {
while (!postDial.empty()) {
String next = postDial.pop();
if (next.equals(";")) {
Stack v = (Stack) postDial.clone();
Collections.reverse(v);
setPostDialWait(String.join("", v));
return;
} else if (next.equals(",")) {
sleep(2000);
} else {
rtpConnection.get().applyDtmfTone(next);
sleep(100);
}
}
}
}
}

View file

@ -18,6 +18,7 @@ import eu.siacs.conversations.xmpp.chatstate.ChatState;
public final class Config {
public static final long MILLISECONDS_IN_DAY = 24 * 60 * 60 * 1000;
public static final long CONTACT_SYNC_RETRY_INTERVAL = 1000L * 60 * 5;
private static final int UNENCRYPTED = 1;
private static final int OPENPGP = 2;

View file

@ -0,0 +1,102 @@
package eu.siacs.conversations.android;
import android.Manifest;
import android.content.Context;
import android.content.pm.PackageManager;
import android.database.Cursor;
import android.net.Uri;
import android.os.Build;
import android.provider.ContactsContract;
import android.util.Log;
import com.google.common.collect.ImmutableMap;
import java.util.Collection;
import java.util.Collections;
import java.util.HashMap;
import java.util.Map;
import eu.siacs.conversations.Config;
import eu.siacs.conversations.utils.PhoneNumberUtilWrapper;
import io.michaelrocks.libphonenumber.android.NumberParseException;
public class PhoneNumberContact extends AbstractPhoneContact {
private final String phoneNumber;
private final String typeLabel;
public String getPhoneNumber() {
return phoneNumber;
}
public String getTypeLabel() {
return typeLabel;
}
private PhoneNumberContact(Context context, Cursor cursor) throws IllegalArgumentException {
super(cursor);
try {
this.phoneNumber = PhoneNumberUtilWrapper.normalize(context, cursor.getString(cursor.getColumnIndex(ContactsContract.CommonDataKinds.Phone.NUMBER)));
this.typeLabel = ContactsContract.CommonDataKinds.Phone.getTypeLabel(
context.getResources(),
cursor.getInt(cursor.getColumnIndex(ContactsContract.CommonDataKinds.Phone.TYPE)),
cursor.getString(cursor.getColumnIndex(ContactsContract.CommonDataKinds.Phone.LABEL))
).toString();
} catch (NumberParseException | NullPointerException e) {
throw new IllegalArgumentException(e);
}
}
public static ImmutableMap<String, PhoneNumberContact> load(Context context) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M && context.checkSelfPermission(Manifest.permission.READ_CONTACTS) != PackageManager.PERMISSION_GRANTED) {
return ImmutableMap.of();
}
final String[] PROJECTION = new String[]{ContactsContract.Data._ID,
ContactsContract.Data.DISPLAY_NAME,
ContactsContract.Data.PHOTO_URI,
ContactsContract.Data.LOOKUP_KEY,
ContactsContract.CommonDataKinds.Phone.TYPE,
ContactsContract.CommonDataKinds.Phone.LABEL,
ContactsContract.CommonDataKinds.Phone.NUMBER};
final HashMap<String, PhoneNumberContact> contacts = new HashMap<>();
try (final Cursor cursor = context.getContentResolver().query(ContactsContract.CommonDataKinds.Phone.CONTENT_URI, PROJECTION, null, null, null)){
while (cursor != null && cursor.moveToNext()) {
try {
final PhoneNumberContact contact = new PhoneNumberContact(context, cursor);
final PhoneNumberContact preexisting = contacts.get(contact.getPhoneNumber());
if (preexisting == null || preexisting.rating() < contact.rating()) {
contacts.put(contact.getPhoneNumber(), contact);
}
} catch (final IllegalArgumentException ignored) {
}
}
} catch (final Exception e) {
return ImmutableMap.of();
}
return ImmutableMap.copyOf(contacts);
}
public static PhoneNumberContact findByUriOrNumber(Collection<PhoneNumberContact> haystack, Uri uri, String number) {
final PhoneNumberContact byUri = findByUri(haystack, uri);
return byUri != null || number == null ? byUri : findByNumber(haystack, number);
}
public static PhoneNumberContact findByUri(Collection<PhoneNumberContact> haystack, Uri needle) {
for (PhoneNumberContact contact : haystack) {
if (needle.equals(contact.getLookupUri())) {
return contact;
}
}
return null;
}
private static PhoneNumberContact findByNumber(Collection<PhoneNumberContact> haystack, String needle) {
for (PhoneNumberContact contact : haystack) {
if (needle.equals(contact.getPhoneNumber())) {
return contact;
}
}
return null;
}
}

View file

@ -1,11 +1,26 @@
package eu.siacs.conversations.entities;
import android.content.ComponentName;
import android.annotation.SuppressLint;
import android.content.ContentValues;
import android.content.Context;
import android.database.Cursor;
import android.net.Uri;
import android.text.TextUtils;
import android.os.Bundle;
import android.telecom.PhoneAccount;
import android.telecom.PhoneAccountHandle;
import android.telecom.TelecomManager;
import android.graphics.drawable.Icon;
import eu.siacs.conversations.services.AvatarService;
import eu.siacs.conversations.services.XmppConnectionService;
import android.os.Bundle;
import android.telecom.PhoneAccount;
import android.telecom.PhoneAccountHandle;
import android.telecom.TelecomManager;
import android.content.ComponentName;
import androidx.annotation.NonNull;
@ -195,11 +210,17 @@ public class Contact implements ListItem, Blockable {
for (final String group : getGroups(true)) {
tags.add(new Tag(group, UIHelper.getColorForName(group), 0, account, isActive()));
}
for (final String tag : getSystemTags(true)) {
tags.add(new Tag(tag, UIHelper.getColorForName(tag), 0, account, isActive()));
}
Presence.Status status = getShownStatus();
tags.add(UIHelper.getTagForStatus(context, status, account, isActive()));
if (isBlocked()) {
tags.add(new Tag(context.getString(R.string.blocked), 0xff2e2f3b, 0, account, isActive()));
}
if (!showInRoster() && getSystemAccount() != null) {
tags.add(new Tag("Android", UIHelper.getColorForName("Android"), 0, account, isActive()));
}
return new ArrayList<>(tags);
}
@ -322,6 +343,15 @@ public class Contact implements ListItem, Blockable {
return !old.equals(getDisplayName());
}
public boolean setSystemTags(Collection<String> systemTags) {
final JSONArray old = this.systemTags;
this.systemTags = new JSONArray();
for(String tag : systemTags) {
this.systemTags.put(tag);
}
return !old.equals(this.systemTags);
}
public boolean setPresenceName(String presenceName) {
final String old = getDisplayName();
this.presenceName = presenceName;
@ -361,6 +391,7 @@ public class Contact implements ListItem, Blockable {
}
return tags;
}
public ArrayList<String> getOtrFingerprints() {
synchronized (this.keys) {
final ArrayList<String> fingerprints = new ArrayList<String>();
@ -665,6 +696,48 @@ public class Contact implements ListItem, Blockable {
return changed;
}
protected String phoneAccountLabel() {
return account.getJid().asBareJid().toString() +
"/" + getJid().asBareJid().toString();
}
protected PhoneAccountHandle phoneAccountHandle() {
ComponentName componentName = new ComponentName(
"de.monocles.chat",
"de.monocles.chat.ConnectionService"
);
return new PhoneAccountHandle(componentName, phoneAccountLabel());
}
// This Contact is a gateway to use for voice calls, register it with OS
public void registerAsPhoneAccount(XmppConnectionService ctx) {
TelecomManager telecomManager = ctx.getSystemService(TelecomManager.class);
PhoneAccount phoneAccount = PhoneAccount.builder(
phoneAccountHandle(),
account.getJid().asBareJid().toString()
).setAddress(
Uri.fromParts("xmpp", account.getJid().asBareJid().toString(), null)
).setIcon(
Icon.createWithBitmap(ctx.getAvatarService().get(this, AvatarService.getSystemUiAvatarSize(ctx) / 2, false))
).setHighlightColor(
0x7401CF
).setShortDescription(
getJid().asBareJid().toString()
).setCapabilities(
PhoneAccount.CAPABILITY_CALL_PROVIDER
).build();
telecomManager.registerPhoneAccount(phoneAccount);
}
// Unregister any associated PSTN gateway integration
public void unregisterAsPhoneAccount(Context ctx) {
TelecomManager telecomManager = ctx.getSystemService(TelecomManager.class);
telecomManager.unregisterPhoneAccount(phoneAccountHandle());
}
public static int getOption(Class<? extends AbstractPhoneContact> clazz) {
if (clazz == JabberIdContact.class) {
return Options.SYNCED_VIA_ADDRESSBOOK;

View file

@ -181,6 +181,23 @@ public class Presences {
return null;
}
public boolean anyIdentity(final String category, final String type) {
synchronized (this.presences) {
if (this.presences.size() == 0) {
// https://github.com/iNPUTmice/Conversations/issues/4230
return false;
}
for (Presence presence : this.presences.values()) {
ServiceDiscoveryResult disco = presence.getServiceDiscoveryResult();
if (disco != null && disco.hasIdentity(category, type)) {
return true;
}
}
}
return false;
}
public Pair<Map<String, String>, Map<String, String>> toTypeAndNameMap() {
Map<String, String> typeMap = new HashMap<>();
Map<String, String> nameMap = new HashMap<>();
@ -205,19 +222,4 @@ public class Presences {
return new Pair<>(typeMap, nameMap);
}
public boolean anyIdentity(final String category, final String type) {
synchronized (this.presences) {
if (this.presences.size() == 0) {
// https://github.com/iNPUTmice/Conversations/issues/4230
return false;
}
for (Presence presence : this.presences.values()) {
ServiceDiscoveryResult disco = presence.getServiceDiscoveryResult();
if (disco != null && disco.hasIdentity(category, type)) {
return true;
}
}
}
return false;
}
}

View file

@ -1,7 +1,14 @@
package eu.siacs.conversations.services;
import android.content.Intent;
import eu.siacs.conversations.BuildConfig;
public abstract class AbstractQuickConversationsService {
public static final String SMS_RETRIEVED_ACTION = "com.google.android.gms.auth.api.phone.SMS_RETRIEVED";
protected final XmppConnectionService service;
public AbstractQuickConversationsService(XmppConnectionService service) {
@ -23,4 +30,6 @@ public abstract class AbstractQuickConversationsService {
public abstract boolean isSynchronizing();
public abstract void considerSyncBackground(boolean force);
}
public abstract void handleSmsReceived(Intent intent);
}

View file

@ -1,14 +1,42 @@
package eu.siacs.conversations.services;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.Collection;
import java.util.HashMap;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Collections;
import com.google.common.collect.ImmutableMap;
import android.content.Intent;
import android.os.SystemClock;
import android.net.Uri;
import android.util.Log;
import eu.siacs.conversations.Config;
import eu.siacs.conversations.android.PhoneNumberContact;
import eu.siacs.conversations.entities.Account;
import eu.siacs.conversations.entities.Contact;
import eu.siacs.conversations.utils.SerialSingleThreadExecutor;
import eu.siacs.conversations.xmpp.Jid;
public class QuickConversationsService extends AbstractQuickConversationsService {
protected final AtomicInteger mRunningSyncJobs = new AtomicInteger(0);
protected final SerialSingleThreadExecutor mSerialSingleThreadExecutor = new SerialSingleThreadExecutor(QuickConversationsService.class.getSimpleName());
protected Attempt mLastSyncAttempt = Attempt.NULL;
QuickConversationsService(XmppConnectionService xmppConnectionService) {
super(xmppConnectionService);
}
@Override
public void considerSync() {
considerSync(false);
}
@Override
@ -18,11 +46,132 @@ public class QuickConversationsService extends AbstractQuickConversationsService
@Override
public boolean isSynchronizing() {
return false;
return mRunningSyncJobs.get() > 0;
}
@Override
public void considerSyncBackground(boolean force) {
mRunningSyncJobs.incrementAndGet();
mSerialSingleThreadExecutor.execute(() -> {
considerSync(force);
if (mRunningSyncJobs.decrementAndGet() == 0) {
service.updateRosterUi();
}
});
}
}
@Override
public void handleSmsReceived(Intent intent) {
Log.d(Config.LOGTAG,"ignoring received SMS");
}
protected static String getNumber(final List<String> gateways, final Contact contact) {
final Jid jid = contact.getJid();
if (jid.getLocal() != null && ("quicksy.im".equals(jid.getDomain()) || gateways.contains(jid.getDomain()))) {
return jid.getLocal();
}
return null;
}
protected void refresh(Account account, final List<String> gateways, Collection<PhoneNumberContact> phoneNumberContacts) {
for (Contact contact : account.getRoster().getWithSystemAccounts(PhoneNumberContact.class)) {
final Uri uri = contact.getSystemAccount();
if (uri == null) {
continue;
}
final String number = getNumber(gateways, contact);
final PhoneNumberContact phoneNumberContact = PhoneNumberContact.findByUriOrNumber(phoneNumberContacts, uri, number);
final boolean needsCacheClean;
if (phoneNumberContact != null) {
if (!uri.equals(phoneNumberContact.getLookupUri())) {
Log.d(Config.LOGTAG, "lookupUri has changed from " + uri + " to " + phoneNumberContact.getLookupUri());
}
needsCacheClean = contact.setPhoneContact(phoneNumberContact);
} else {
needsCacheClean = contact.unsetPhoneContact(PhoneNumberContact.class);
Log.d(Config.LOGTAG, uri.toString() + " vanished from address book");
}
if (needsCacheClean) {
service.getAvatarService().clear(contact);
}
}
}
protected void considerSync(boolean forced) {
final ImmutableMap<String, PhoneNumberContact> allContacts = PhoneNumberContact.load(service);
for (final Account account : service.getAccounts()) {
List<String> gateways = gateways(account);
refresh(account, gateways, allContacts.values());
if (!considerSync(account, gateways, allContacts, forced)) {
service.syncRoster(account);
}
}
}
protected List<String> gateways(final Account account) {
List<String> gateways = new ArrayList();
for (final Contact contact : account.getRoster().getContacts()) {
if (contact.showInRoster() && (contact.getPresences().anyIdentity("gateway", "pstn") || contact.getPresences().anyIdentity("gateway", "sms"))) {
gateways.add(contact.getJid().asBareJid().toString());
}
}
return gateways;
}
protected boolean considerSync(final Account account, final List<String> gateways, final Map<String, PhoneNumberContact> contacts, final boolean forced) {
final int hash = Objects.hash(contacts.keySet(), gateways);
Log.d(Config.LOGTAG, account.getJid().asBareJid() + ": consider sync of " + hash);
if (!mLastSyncAttempt.retry(hash) && !forced) {
Log.d(Config.LOGTAG, account.getJid().asBareJid() + ": do not attempt sync");
return false;
}
mRunningSyncJobs.incrementAndGet();
mLastSyncAttempt = Attempt.create(hash);
final List<Contact> withSystemAccounts = account.getRoster().getWithSystemAccounts(PhoneNumberContact.class);
for (Map.Entry<String, PhoneNumberContact> item : contacts.entrySet()) {
PhoneNumberContact phoneContact = item.getValue();
for(String gateway : gateways) {
final Jid jid = Jid.ofLocalAndDomain(phoneContact.getPhoneNumber(), gateway);
final Contact contact = account.getRoster().getContact(jid);
boolean needsCacheClean = contact.setPhoneContact(phoneContact);
needsCacheClean |= contact.setSystemTags(Collections.singleton(phoneContact.getTypeLabel()));
if (needsCacheClean) {
service.getAvatarService().clear(contact);
}
withSystemAccounts.remove(contact);
}
}
for (final Contact contact : withSystemAccounts) {
final boolean needsCacheClean = contact.unsetPhoneContact(PhoneNumberContact.class);
if (needsCacheClean) {
service.getAvatarService().clear(contact);
}
}
mRunningSyncJobs.decrementAndGet();
service.syncRoster(account);
service.updateRosterUi();
return true;
}
protected static class Attempt {
private final long timestamp;
private final int hash;
private static final Attempt NULL = new Attempt(0, 0);
private Attempt(long timestamp, int hash) {
this.timestamp = timestamp;
this.hash = hash;
}
public static Attempt create(int hash) {
return new Attempt(SystemClock.elapsedRealtime(), hash);
}
public boolean retry(int hash) {
return hash != this.hash || SystemClock.elapsedRealtime() - timestamp >= Config.CONTACT_SYNC_RETRY_INTERVAL;
}
}
}

View file

@ -293,6 +293,9 @@ public class XmppConnectionService extends Service {
}
}
}
if (contact.getPresences().anyIdentity("gateway", "pstn")) {
contact.registerAsPhoneAccount(this);
}
};
private final PresenceGenerator mPresenceGenerator = new PresenceGenerator(this);
private List<Account> accounts;
@ -342,15 +345,22 @@ public class XmppConnectionService extends Service {
}
};
private final AtomicBoolean isPhoneInCall = new AtomicBoolean(false);
private final AtomicBoolean diallerIntegrationActive = new AtomicBoolean(false);
private final PhoneStateListener phoneStateListener = new PhoneStateListener() {
@Override
public void onCallStateChanged(final int state, final String phoneNumber) {
if (diallerIntegrationActive.get()) return;
isPhoneInCall.set(state != TelephonyManager.CALL_STATE_IDLE);
if (state == TelephonyManager.CALL_STATE_OFFHOOK) {
mJingleConnectionManager.notifyPhoneCallStarted();
}
}
};
public void setDiallerIntegrationActive(boolean active) {
diallerIntegrationActive.set(active);
}
//Ui callback listeners
private final Set<OnConversationUpdate> mOnConversationUpdates = Collections.newSetFromMap(new WeakHashMap<OnConversationUpdate, Boolean>());
private final Set<OnShowErrorToast> mOnShowErrorToasts = Collections.newSetFromMap(new WeakHashMap<OnShowErrorToast, Boolean>());
@ -2419,6 +2429,7 @@ public class XmppConnectionService extends Service {
}
public void syncRoster(final Account account) {
unregisterPhoneAccounts(account);
mRosterSyncTaskManager.execute(account, () -> databaseBackend.writeRoster(account.getRoster()));
}
@ -4048,6 +4059,15 @@ public class XmppConnectionService extends Service {
}
}
protected void unregisterPhoneAccounts(final Account account) {
for (final Contact contact : account.getRoster().getContacts()) {
if (!contact.showInRoster()) {
contact.unregisterAsPhoneAccount(this);
}
}
}
public void createContact(final Contact contact, final boolean autoGrant) {
createContact(contact, autoGrant, null);
}

View file

@ -4,6 +4,7 @@ import static java.util.Arrays.asList;
import static eu.siacs.conversations.utils.PermissionUtils.getFirstDenied;
import android.Manifest;
import org.jetbrains.annotations.NotNull;
import android.annotation.SuppressLint;
import android.app.Activity;
import android.app.PictureInPictureParams;
@ -40,6 +41,7 @@ import com.google.common.collect.ImmutableSet;
import com.google.common.util.concurrent.FutureCallback;
import com.google.common.util.concurrent.Futures;
import org.jetbrains.annotations.NotNull;
import org.webrtc.RendererCommon;
import org.webrtc.SurfaceViewRenderer;
import org.webrtc.VideoTrack;
@ -72,6 +74,10 @@ import eu.siacs.conversations.xmpp.jingle.JingleRtpConnection;
import eu.siacs.conversations.xmpp.jingle.Media;
import eu.siacs.conversations.xmpp.jingle.RtpEndUserState;
import static eu.siacs.conversations.utils.PermissionUtils.getFirstDenied;
import static java.util.Arrays.asList;
public class RtpSessionActivity extends XmppActivity
implements XmppConnectionService.OnJingleRtpConnectionUpdate,
eu.siacs.conversations.ui.widget.SurfaceViewRenderer.OnAspectRatioChanged {
@ -86,8 +92,7 @@ public class RtpSessionActivity extends XmppActivity
private static final int CALL_DURATION_UPDATE_INTERVAL = 333;
private static final List<RtpEndUserState> END_CARD =
Arrays.asList(
public static final List<RtpEndUserState> END_CARD = Arrays.asList(
RtpEndUserState.APPLICATION_ERROR,
RtpEndUserState.SECURITY_ERROR,
RtpEndUserState.DECLINED_OR_BUSY,
@ -156,25 +161,35 @@ public class RtpSessionActivity extends XmppActivity
@Override
public void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
getWindow()
.addFlags(
WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON
| WindowManager.LayoutParams.FLAG_DISMISS_KEYGUARD
| WindowManager.LayoutParams.FLAG_SHOW_WHEN_LOCKED
| WindowManager.LayoutParams.FLAG_TURN_SCREEN_ON);
getWindow().addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON
| WindowManager.LayoutParams.FLAG_DISMISS_KEYGUARD
| WindowManager.LayoutParams.FLAG_SHOW_WHEN_LOCKED
| WindowManager.LayoutParams.FLAG_TURN_SCREEN_ON);
this.binding = DataBindingUtil.setContentView(this, R.layout.activity_rtp_session);
setSupportActionBar(binding.toolbar);
binding.dialpad.setClickConsumer(tag -> {
requireRtpConnection().applyDtmfTone(tag);
});
if (savedInstanceState != null) {
boolean dialpadVisible = savedInstanceState.getBoolean("dialpad_visible");
binding.dialpad.setVisibility(dialpadVisible ? View.VISIBLE : View.GONE);
}
}
@Override
public boolean onCreateOptionsMenu(final Menu menu) {
getMenuInflater().inflate(R.menu.activity_rtp_session, menu);
final MenuItem help = menu.findItem(R.id.action_help);
final MenuItem gotoChat = menu.findItem(R.id.action_goto_chat);
final MenuItem switchToVideo = menu.findItem(R.id.action_switch_to_video);
final MenuItem dialpad = menu.findItem(R.id.action_dialpad);
help.setVisible(Config.HELP != null && isHelpButtonVisible());
gotoChat.setVisible(isSwitchToConversationVisible());
switchToVideo.setVisible(isSwitchToVideoVisible());
dialpad.setVisible(isAudioOnlyConversation());
return super.onCreateOptionsMenu(menu);
}
@ -205,6 +220,14 @@ public class RtpSessionActivity extends XmppActivity
}
}
private boolean isAudioOnlyConversation() {
final JingleRtpConnection connection =
this.rtpConnectionReference != null ? this.rtpConnectionReference.get() : null;
return connection != null && !connection.getMedia().contains(Media.VIDEO);
}
private boolean isSwitchToConversationVisible() {
final JingleRtpConnection connection =
this.rtpConnectionReference != null ? this.rtpConnectionReference.get() : null;
@ -229,6 +252,16 @@ public class RtpSessionActivity extends XmppActivity
switchToConversation(conversation);
}
private void toggleDialpadVisibility() {
if (binding.dialpad.getVisibility() == View.VISIBLE) {
binding.dialpad.setVisibility(View.GONE);
}
else {
binding.dialpad.setVisibility(View.VISIBLE);
}
}
public boolean onOptionsItemSelected(final MenuItem item) {
switch (item.getItemId()) {
case R.id.action_help:
@ -240,6 +273,9 @@ public class RtpSessionActivity extends XmppActivity
case R.id.action_switch_to_video:
requestPermissionAndSwitchToVideo();
return true;
case R.id.action_dialpad:
toggleDialpadVisibility();
break;
}
return super.onOptionsItemSelected(item);
}
@ -617,6 +653,13 @@ public class RtpSessionActivity extends XmppActivity
.show();
}
}
@Override
protected void onSaveInstanceState(@NonNull @NotNull Bundle outState) {
super.onSaveInstanceState(outState);
int visibility = findViewById(R.id.dialpad).getVisibility();
outState.putInt("dialpad_visibility", visibility);
}
@Override
public void onStart() {

View file

@ -19,6 +19,8 @@ import static eu.siacs.conversations.ui.util.MyLinkify.removeTrailingBracket;
import static eu.siacs.conversations.ui.util.MyLinkify.replaceYoutube;
import eu.siacs.conversations.ui.util.ShareUtil;
import de.monocles.chat.SwipeDetector;
import android.net.Uri;
import android.text.style.URLSpan;
import android.Manifest;
import android.annotation.SuppressLint;
@ -75,6 +77,9 @@ import java.util.Locale;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import eu.siacs.conversations.entities.Contact;
import eu.siacs.conversations.entities.Message;
import eu.siacs.conversations.entities.Roster;
import eu.siacs.conversations.Config;
import eu.siacs.conversations.R;
import eu.siacs.conversations.crypto.axolotl.FingerprintStatus;

View file

@ -0,0 +1,72 @@
/*
* Copyright 2012-2015 the original author or authors.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package eu.siacs.conversations.ui.widget;
import android.content.Context;
import android.util.AttributeSet;
import android.view.LayoutInflater;
import android.view.View;
import androidx.constraintlayout.widget.ConstraintLayout;
import androidx.databinding.DataBindingUtil;
import eu.siacs.conversations.databinding.DialpadBinding;
import eu.siacs.conversations.R;
public class DialpadView extends ConstraintLayout implements View.OnClickListener {
protected Consumer<String> clickConsumer = null;
public DialpadView(Context context) {
super(context);
init();
}
public DialpadView(Context context, AttributeSet attrs) {
super(context, attrs);
init();
}
public DialpadView(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
init();
}
public void setClickConsumer(Consumer<String> clickConsumer) {
this.clickConsumer = clickConsumer;
}
private void init() {
DialpadBinding binding = DataBindingUtil.inflate(
LayoutInflater.from(getContext()),
R.layout.dialpad,
this,
true
);
binding.setDialpadView(this);
}
@Override
public void onClick(View v) {
clickConsumer.accept(v.getTag().toString());
}
// Based on java.util.function.Consumer to avoid Android 24 dependency
public interface Consumer<T> {
void accept(T t);
}
}

View file

@ -192,8 +192,7 @@ public class JingleConnectionManager extends AbstractConnectionManager {
return Optional.absent();
}
private boolean hasMatchingRtpSession(
final Account account, final Jid with, final Set<Media> media) {
private String hasMatchingRtpSession(final Account account, final Jid with, final Set<Media> media) {
for (AbstractJingleConnection connection : this.connections.values()) {
if (connection instanceof JingleRtpConnection) {
final JingleRtpConnection rtpConnection = (JingleRtpConnection) connection;
@ -203,11 +202,11 @@ public class JingleConnectionManager extends AbstractConnectionManager {
if (rtpConnection.getId().account == account
&& rtpConnection.getId().with.asBareJid().equals(with.asBareJid())
&& rtpConnection.getMedia().equals(media)) {
return true;
return rtpConnection.getId().sessionId;
}
}
}
return false;
return null;
}
private boolean isWithStrangerAndStrangerNotificationsAreOff(final Account account, Jid with) {
@ -725,8 +724,7 @@ public class JingleConnectionManager extends AbstractConnectionManager {
return id.sessionId;
}
public void proposeJingleRtpSession(
final Account account, final Jid with, final Set<Media> media) {
public String proposeJingleRtpSession(final Account account, final Jid with, final Set<Media> media) {
synchronized (this.rtpSessionProposals) {
for (Map.Entry<RtpSessionProposal, DeviceDiscoveryState> entry :
this.rtpSessionProposals.entrySet()) {
@ -739,16 +737,15 @@ public class JingleConnectionManager extends AbstractConnectionManager {
toneManager.transition(endUserState, media);
mXmppConnectionService.notifyJingleRtpConnectionUpdate(
account, with, proposal.sessionId, endUserState);
return;
return proposal.sessionId;
}
}
}
if (isBusy()) {
if (hasMatchingRtpSession(account, with, media)) {
Log.d(
Config.LOGTAG,
"ignoring request to propose jingle session because the other party already created one for us");
return;
String sessionId = hasMatchingRtpSession(account, with, media);
if (sessionId != null) {
Log.d(Config.LOGTAG, "ignoring request to propose jingle session because the other party already created one for us: " + sessionId);
return sessionId;
}
throw new IllegalStateException(
"There is already a running RTP session. This should have been caught by the UI");
@ -761,6 +758,7 @@ public class JingleConnectionManager extends AbstractConnectionManager {
final MessagePacket messagePacket =
mXmppConnectionService.getMessageGenerator().sessionProposal(proposal);
mXmppConnectionService.sendMessagePacket(account, messagePacket);
return proposal.sessionId;
}
}

View file

@ -26,6 +26,7 @@ import org.webrtc.EglBase;
import org.webrtc.IceCandidate;
import org.webrtc.PeerConnection;
import org.webrtc.VideoTrack;
import org.webrtc.DtmfSender;
import java.util.Arrays;
import java.util.Collection;
@ -270,6 +271,10 @@ public class JingleRtpConnection extends AbstractJingleConnection
}
}
public boolean applyDtmfTone(String tone) {
return webRTCWrapper.applyDtmfTone(tone);
}
private void receiveSessionTerminate(final JinglePacket jinglePacket) {
respondOk(jinglePacket);
final JinglePacket.ReasonWrapper wrapper = jinglePacket.getReason();

View file

@ -13,6 +13,8 @@ import eu.siacs.conversations.entities.Presence;
import eu.siacs.conversations.entities.Presences;
import eu.siacs.conversations.entities.ServiceDiscoveryResult;
import eu.siacs.conversations.xml.Namespace;
import eu.siacs.conversations.xmpp.Jid;
public class RtpCapability {
@ -62,7 +64,13 @@ public class RtpCapability {
public static Capability check(final Contact contact, final boolean allowFallback) {
final Presences presences = contact.getPresences();
if (presences.size() == 0 && allowFallback && contact.getAccount().isEnabled()) {
Contact gateway = contact.getAccount().getRoster().getContact(Jid.of(contact.getJid().getDomain()));
if (gateway.showInRoster() && gateway.getPresences().anyIdentity("gateway", "pstn")) {
return Capability.AUDIO;
}
return contact.getRtpCapability();
}
Capability result = Capability.NONE;

View file

@ -165,7 +165,7 @@ class ToneManager {
}
}
private void startTone(final int toneType, final int durationMs) {
public void startTone(final int toneType, final int durationMs) {
if (this.toneGenerator != null) {
this.toneGenerator.release();;

View file

@ -5,6 +5,7 @@ import android.os.Build;
import android.os.Handler;
import android.os.Looper;
import android.util.Log;
import android.media.ToneGenerator;
import com.google.common.base.Optional;
import com.google.common.base.Preconditions;
@ -13,6 +14,7 @@ import com.google.common.util.concurrent.Futures;
import com.google.common.util.concurrent.ListenableFuture;
import com.google.common.util.concurrent.MoreExecutors;
import com.google.common.util.concurrent.SettableFuture;
import com.google.common.collect.ImmutableMap;
import org.webrtc.AudioSource;
import org.webrtc.AudioTrack;
@ -34,11 +36,13 @@ import org.webrtc.SessionDescription;
import org.webrtc.VideoTrack;
import org.webrtc.audio.JavaAudioDeviceModule;
import org.webrtc.voiceengine.WebRtcAudioEffects;
import org.webrtc.DtmfSender;
import java.util.LinkedList;
import java.util.List;
import java.util.Queue;
import java.util.Set;
import java.util.Map;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.atomic.AtomicBoolean;
@ -76,6 +80,25 @@ public class WebRTCWrapper {
.add("GT-I9505") // Samsung Galaxy S4 (jfltexx)
.build();
private static final int TONE_DURATION = 200;
private static final Map<String,Integer> TONE_CODES;
static {
ImmutableMap.Builder<String,Integer> builder = new ImmutableMap.Builder<>();
builder.put("0", ToneGenerator.TONE_DTMF_0);
builder.put("1", ToneGenerator.TONE_DTMF_1);
builder.put("2", ToneGenerator.TONE_DTMF_2);
builder.put("3", ToneGenerator.TONE_DTMF_3);
builder.put("4", ToneGenerator.TONE_DTMF_4);
builder.put("5", ToneGenerator.TONE_DTMF_5);
builder.put("6", ToneGenerator.TONE_DTMF_6);
builder.put("7", ToneGenerator.TONE_DTMF_7);
builder.put("8", ToneGenerator.TONE_DTMF_8);
builder.put("9", ToneGenerator.TONE_DTMF_9);
builder.put("*", ToneGenerator.TONE_DTMF_S);
builder.put("#", ToneGenerator.TONE_DTMF_P);
TONE_CODES = builder.build();
}
private final EventCallback eventCallback;
private final AtomicBoolean readyToReceivedIceCandidates = new AtomicBoolean(false);
private final Queue<IceCandidate> iceCandidates = new LinkedList<>();
@ -700,6 +723,15 @@ public class WebRTCWrapper {
return peerConnectionFactory;
}
public boolean applyDtmfTone(String tone) {
if (toneManager == null || peerConnection.getSenders().isEmpty()) {
return false;
}
peerConnection.getSenders().get(0).dtmf().insertDtmf(tone, TONE_DURATION, 100);
toneManager.startTone(TONE_CODES.get(tone), TONE_DURATION);
return true;
}
void addIceCandidate(IceCandidate iceCandidate) {
requirePeerConnection().addIceCandidate(iceCandidate);
}

View file

@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:pathData="M12,19c-1.1,0 -2,0.9 -2,2s0.9,2 2,2 2,-0.9 2,-2 -0.9,-2 -2,-2zM6,1c-1.1,0 -2,0.9 -2,2s0.9,2 2,2 2,-0.9 2,-2 -0.9,-2 -2,-2zM6,7c-1.1,0 -2,0.9 -2,2s0.9,2 2,2 2,-0.9 2,-2 -0.9,-2 -2,-2zM6,13c-1.1,0 -2,0.9 -2,2s0.9,2 2,2 2,-0.9 2,-2 -0.9,-2 -2,-2zM18,5c1.1,0 2,-0.9 2,-2s-0.9,-2 -2,-2 -2,0.9 -2,2 0.9,2 2,2zM12,13c-1.1,0 -2,0.9 -2,2s0.9,2 2,2 2,-0.9 2,-2 -0.9,-2 -2,-2zM18,13c-1.1,0 -2,0.9 -2,2s0.9,2 2,2 2,-0.9 2,-2 -0.9,-2 -2,-2zM18,7c-1.1,0 -2,0.9 -2,2s0.9,2 2,2 2,-0.9 2,-2 -0.9,-2 -2,-2zM12,7c-1.1,0 -2,0.9 -2,2s0.9,2 2,2 2,-0.9 2,-2 -0.9,-2 -2,-2zM12,1c-1.1,0 -2,0.9 -2,2s0.9,2 2,2 2,-0.9 2,-2 -0.9,-2 -2,-2z"
android:fillColor="#FFFFFF"/>
</vector>

View file

@ -90,6 +90,14 @@
android:textAppearance="@style/TextAppearance.Conversations.Title.Monospace"
tools:text="01:23" />
<eu.siacs.conversations.ui.widget.DialpadView
layout="@layout/dialpad"
android:id="@+id/dialpad"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_alignParentBottom="true"
android:visibility="gone" />
<com.makeramen.roundedimageview.RoundedImageView
android:id="@+id/contact_photo"
android:layout_width="@dimen/publish_avatar_size"

View file

@ -0,0 +1,385 @@
<?xml version="1.0" encoding="utf-8"?>
<layout xmlns:android="http://schemas.android.com/apk/res/android">
<data>
<variable name="dialpadView" type="eu.siacs.conversations.ui.widget.DialpadView"/>
</data>
<androidx.constraintlayout.widget.ConstraintLayout
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/dialpad_holder"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_alignParentBottom="true"
android:focusableInTouchMode="true"
android:paddingTop="@dimen/medium_margin"
tools:ignore="HardcodedText">
<RelativeLayout
android:onClick="@{dialpadView::onClick}"
android:id="@+id/dialpad_1_holder"
android:tag="1"
android:layout_width="0dp"
android:layout_height="0dp"
android:layout_marginStart="@dimen/activity_margin"
android:background="?attr/selectableItemBackgroundBorderless"
app:layout_constraintBottom_toBottomOf="@+id/dialpad_2_holder"
app:layout_constraintEnd_toStartOf="@+id/dialpad_2_holder"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="@+id/dialpad_2_holder"
android:focusable="true" >
<TextView
android:id="@+id/dialpad_1"
style="@style/DialpadNumberStyle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_alignParentTop="true"
android:layout_centerHorizontal="true"
android:text="1" />
</RelativeLayout>
<RelativeLayout
android:onClick="@{dialpadView::onClick}"
android:id="@+id/dialpad_2_holder"
android:tag="2"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginBottom="@dimen/medium_margin"
android:background="?attr/selectableItemBackgroundBorderless"
app:layout_constraintBottom_toTopOf="@+id/dialpad_5_holder"
app:layout_constraintEnd_toStartOf="@+id/dialpad_3_holder"
app:layout_constraintStart_toEndOf="@+id/dialpad_1_holder">
<TextView
android:id="@+id/dialpad_2"
style="@style/DialpadNumberStyle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_centerHorizontal="true"
android:text="2" />
<TextView
android:id="@+id/dialpad_2_letters"
style="@style/DialpadLetterStyle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_below="@+id/dialpad_2"
android:layout_centerHorizontal="true"
android:layout_marginBottom="@dimen/medium_margin"
android:gravity="center_horizontal"
android:text="ABC" />
</RelativeLayout>
<RelativeLayout
android:onClick="@{dialpadView::onClick}"
android:id="@+id/dialpad_3_holder"
android:tag="3"
android:layout_width="0dp"
android:layout_height="0dp"
android:layout_marginEnd="@dimen/activity_margin"
android:background="?attr/selectableItemBackgroundBorderless"
app:layout_constraintBottom_toBottomOf="@+id/dialpad_2_holder"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@+id/dialpad_2_holder"
app:layout_constraintTop_toTopOf="@+id/dialpad_2_holder">
<TextView
android:id="@+id/dialpad_3"
style="@style/DialpadNumberStyle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_centerHorizontal="true"
android:text="3" />
<TextView
android:id="@+id/dialpad_3_letters"
style="@style/DialpadLetterStyle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_below="@+id/dialpad_3"
android:layout_centerHorizontal="true"
android:layout_marginBottom="@dimen/medium_margin"
android:gravity="center_horizontal"
android:text="DEF" />
</RelativeLayout>
<RelativeLayout
android:onClick="@{dialpadView::onClick}"
android:id="@+id/dialpad_4_holder"
android:tag="4"
android:layout_width="0dp"
android:layout_height="0dp"
android:layout_marginStart="@dimen/activity_margin"
android:background="?attr/selectableItemBackgroundBorderless"
app:layout_constraintBottom_toBottomOf="@+id/dialpad_5_holder"
app:layout_constraintEnd_toStartOf="@+id/dialpad_5_holder"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="@+id/dialpad_5_holder">
<TextView
android:id="@+id/dialpad_4"
style="@style/DialpadNumberStyle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_centerHorizontal="true"
android:text="4" />
<TextView
android:id="@+id/dialpad_4_letters"
style="@style/DialpadLetterStyle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_below="@+id/dialpad_4"
android:layout_centerHorizontal="true"
android:layout_marginBottom="@dimen/medium_margin"
android:gravity="center_horizontal"
android:text="GHI" />
</RelativeLayout>
<RelativeLayout
android:onClick="@{dialpadView::onClick}"
android:id="@+id/dialpad_5_holder"
android:tag="5"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginBottom="@dimen/medium_margin"
android:background="?attr/selectableItemBackgroundBorderless"
app:layout_constraintBottom_toTopOf="@+id/dialpad_8_holder"
app:layout_constraintEnd_toStartOf="@+id/dialpad_6_holder"
app:layout_constraintStart_toEndOf="@+id/dialpad_4_holder">
<TextView
android:id="@+id/dialpad_5"
style="@style/DialpadNumberStyle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_centerHorizontal="true"
android:text="5" />
<TextView
android:id="@+id/dialpad_5_letters"
style="@style/DialpadLetterStyle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_below="@+id/dialpad_5"
android:layout_centerHorizontal="true"
android:layout_marginBottom="@dimen/medium_margin"
android:gravity="center_horizontal"
android:text="JKL" />
</RelativeLayout>
<RelativeLayout
android:onClick="@{dialpadView::onClick}"
android:id="@+id/dialpad_6_holder"
android:tag="6"
android:layout_width="0dp"
android:layout_height="0dp"
android:layout_marginEnd="@dimen/activity_margin"
android:background="?attr/selectableItemBackgroundBorderless"
app:layout_constraintBottom_toBottomOf="@+id/dialpad_5_holder"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@+id/dialpad_5_holder"
app:layout_constraintTop_toTopOf="@+id/dialpad_5_holder">
<TextView
android:id="@+id/dialpad_6"
style="@style/DialpadNumberStyle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_centerHorizontal="true"
android:text="6" />
<TextView
android:id="@+id/dialpad_6_letters"
style="@style/DialpadLetterStyle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_below="@+id/dialpad_6"
android:layout_centerHorizontal="true"
android:layout_marginBottom="@dimen/medium_margin"
android:gravity="center_horizontal"
android:text="MNO" />
</RelativeLayout>
<RelativeLayout
android:onClick="@{dialpadView::onClick}"
android:id="@+id/dialpad_7_holder"
android:tag="7"
android:layout_width="0dp"
android:layout_height="0dp"
android:layout_marginStart="@dimen/activity_margin"
android:background="?attr/selectableItemBackgroundBorderless"
app:layout_constraintBottom_toBottomOf="@+id/dialpad_8_holder"
app:layout_constraintEnd_toStartOf="@+id/dialpad_8_holder"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="@+id/dialpad_8_holder">
<TextView
android:id="@+id/dialpad_7"
style="@style/DialpadNumberStyle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_centerHorizontal="true"
android:text="7" />
<TextView
android:id="@+id/dialpad_7_letters"
style="@style/DialpadLetterStyle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_below="@+id/dialpad_7"
android:layout_centerHorizontal="true"
android:layout_marginBottom="@dimen/medium_margin"
android:gravity="center_horizontal"
android:text="PQRS" />
</RelativeLayout>
<RelativeLayout
android:onClick="@{dialpadView::onClick}"
android:id="@+id/dialpad_8_holder"
android:tag="8"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginBottom="@dimen/medium_margin"
android:background="?attr/selectableItemBackgroundBorderless"
app:layout_constraintBottom_toTopOf="@+id/dialpad_0_holder"
app:layout_constraintEnd_toStartOf="@+id/dialpad_9_holder"
app:layout_constraintStart_toEndOf="@+id/dialpad_7_holder">
<TextView
android:id="@+id/dialpad_8"
style="@style/DialpadNumberStyle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_centerHorizontal="true"
android:text="8" />
<TextView
android:id="@+id/dialpad_8_letters"
style="@style/DialpadLetterStyle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_below="@+id/dialpad_8"
android:layout_centerHorizontal="true"
android:layout_marginBottom="@dimen/medium_margin"
android:gravity="center_horizontal"
android:text="TUV" />
</RelativeLayout>
<RelativeLayout
android:onClick="@{dialpadView::onClick}"
android:id="@+id/dialpad_9_holder"
android:tag="9"
android:layout_width="0dp"
android:layout_height="0dp"
android:layout_marginEnd="@dimen/activity_margin"
android:background="?attr/selectableItemBackgroundBorderless"
app:layout_constraintBottom_toBottomOf="@+id/dialpad_8_holder"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@+id/dialpad_8_holder"
app:layout_constraintTop_toTopOf="@+id/dialpad_8_holder">
<TextView
android:id="@+id/dialpad_9"
style="@style/DialpadNumberStyle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_centerHorizontal="true"
android:text="9" />
<TextView
android:id="@+id/dialpad_9_letters"
style="@style/DialpadLetterStyle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_below="@+id/dialpad_9"
android:layout_centerHorizontal="true"
android:layout_marginBottom="@dimen/medium_margin"
android:gravity="center_horizontal"
android:text="WXYZ" />
</RelativeLayout>
<RelativeLayout
android:onClick="@{dialpadView::onClick}"
android:id="@+id/dialpad_asterisk_holder"
android:tag="*"
android:layout_width="0dp"
android:layout_height="0dp"
android:layout_marginStart="@dimen/activity_margin"
android:background="?attr/selectableItemBackgroundBorderless"
app:layout_constraintBottom_toBottomOf="@+id/dialpad_0_holder"
app:layout_constraintEnd_toStartOf="@+id/dialpad_0_holder"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="@+id/dialpad_0_holder">
<TextView
android:id="@+id/dialpad_asterisk"
style="@style/DialpadNumberStyle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_alignParentTop="true"
android:layout_centerHorizontal="true"
android:text="*" />
</RelativeLayout>
<RelativeLayout
android:onClick="@{dialpadView::onClick}"
android:id="@+id/dialpad_0_holder"
android:tag="0"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginBottom="@dimen/activity_margin"
android:background="?attr/selectableItemBackgroundBorderless"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toStartOf="@+id/dialpad_pound_holder"
app:layout_constraintStart_toEndOf="@+id/dialpad_asterisk_holder">
<TextView
android:id="@+id/dialpad_0"
style="@style/DialpadNumberStyle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_centerHorizontal="true"
android:text="0" />
<TextView
android:id="@+id/dialpad_plus"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_alignTop="@+id/dialpad_0"
android:layout_alignBottom="@+id/dialpad_0"
android:layout_centerHorizontal="true"
android:layout_toEndOf="@+id/dialpad_0"
android:gravity="center"
android:paddingStart="@dimen/small_margin"
android:paddingTop="@dimen/small_margin"
android:text="+"
android:textSize="@dimen/actionbar_text_size" />
</RelativeLayout>
<RelativeLayout
android:onClick="@{dialpadView::onClick}"
android:id="@+id/dialpad_pound_holder"
android:tag="#"
android:layout_width="0dp"
android:layout_height="0dp"
android:layout_marginEnd="@dimen/activity_margin"
android:background="?attr/selectableItemBackgroundBorderless"
app:layout_constraintBottom_toBottomOf="@+id/dialpad_0_holder"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@+id/dialpad_0_holder"
app:layout_constraintTop_toTopOf="@+id/dialpad_0_holder">
<TextView
android:id="@+id/dialpad_pound"
style="@style/DialpadNumberStyle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_centerHorizontal="true"
android:layout_marginEnd="@dimen/activity_margin"
android:text="#" />
</RelativeLayout>
</androidx.constraintlayout.widget.ConstraintLayout>
</layout>

View file

@ -8,12 +8,18 @@
android:icon="?attr/icon_help"
android:title="@string/help"
app:showAsAction="always" />
<item
android:id="@+id/action_dialpad"
android:icon="@drawable/ic_dialpad_white_24dp"
android:title="@string/action_dialpad"
app:showAsAction="always" />
<item
android:id="@+id/action_goto_chat"
android:icon="?attr/icon_goto_chat"
android:title="@string/switch_to_conversation"
app:showAsAction="always" />
<item android:id="@+id/action_switch_to_video"
app:showAsAction="ifRoom" />
<item
android:id="@+id/action_switch_to_video"
android:title="@string/switch_to_video"
app:showAsAction="never"/>
</menu>

View file

@ -44,4 +44,11 @@
<dimen name="local_video_preview_height">128dp</dimen>
<dimen name="local_video_preview_width">96dp</dimen>
<dimen name="rtp_session_duration_top_margin">24dp</dimen>
<dimen name="dialpad_text_size">30sp</dimen>
<dimen name="smaller_text_size">12sp</dimen>
<dimen name="medium_margin">8dp</dimen>
<dimen name="activity_margin">16dp</dimen>
<dimen name="small_margin">4dp</dimen>
<dimen name="actionbar_text_size">20sp</dimen>
</resources>

View file

@ -1,6 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<resources>
<string name="action_settings">Settings</string>
<string name="action_dialpad">Dialpad</string>
<string name="action_add">New conversation</string>
<string name="action_accounts">Manage accounts</string>
<string name="action_end_conversation">End this conversation</string>

View file

@ -190,4 +190,13 @@
<item name="android:visibility">gone</item>
</style>
<style name="DialpadNumberStyle">
<item name="android:includeFontPadding">false</item>
<item name="android:textSize">@dimen/dialpad_text_size</item>
</style>
<style name="DialpadLetterStyle">
<item name="android:textSize">@dimen/smaller_text_size</item>
<item name="android:alpha">0.8</item>
</style>
</resources>