~singpolyma/cheogram-android

7296a11945c14843cd797601ab743451c240e61b — Stephen Paul Weber 2 years ago 6132024
First version of dialer integration

When a contact comes online, we check if it is a gateway/pstn.  If so, we
register a new PhoneAccount with the OS for the account+gateway pair.

When the roster is being saved after any change, we check for any items that
have been removed and remove any associated PhoneAccount registration.

To activate a PhoneAccount, a user navigates to Phone App > Settings > Calls >
Calling Accounts.

When a call is placed from the Phone app using one of our PhoneAccount, the
ConnectionService is called by the OS.  Its job is to place the call and keep
the OS calling UI up to date via a returned Connection subclass.  Calling in
Conversations is currently rather tied to the UI, so rather than seperate it out
for this prototype, I launch the Intent to bring up the UI for the desired call.
This should do jabber:iq:gateway on the gateway and possibly other fallbacks,
but for this prototype it just strips any non-digit and prepend +1 if not
present, appending @gateway.tld for the associated gateway.

We don't actually tell the OS UI when the call is active, because if we do it
steals focus and puts the whole system in "in a call" mode, which causes
Conversations to deactivate its UI.  This is something to explore more if we
want to use the OS in-call UI completely.  We also haven't wired up the OS UI
for DTMF since it never shows if the call is never active.

We *do* tell the OS UI when the call is over, so it can clean up and close the
other window.  This means that after you hang up in Conversations, you are taken
back to the OS UI showing "call ended" for a few moments.  It also means that if
an outgoing call fails you will see the OS UI for a few moments before being
returned to Conversations to see the normal call failure screen.  This is
perhaps the biggest wart of the current prototype.

As an alternative, we could just pretend the call immediately failed and have
the OS UI close itself before the Conversations UI ever comes up.  This
basically causes a indeterminately-long "flash" of the OS UI, possibly long
enough to see it say "call ended" before we get the Conversations UI which then
works after that.  I thought that was more confusing that what I'm doing now,
but maybe others disagree.

Finally, outbound calls placed from the Phone app do show in the Phone app call
log.  Other calls started from Conversations or inbound calls could show there
with a full integration, but that's not part of this work.
M src/cheogram/AndroidManifest.xml => src/cheogram/AndroidManifest.xml +11 -0
@@ 3,7 3,18 @@
    xmlns:tools="http://schemas.android.com/tools"
    package="eu.siacs.conversations">

    <uses-permission android:name="android.permission.BIND_TELECOM_CONNECTION_SERVICE" />

    <application tools:ignore="GoogleAppIndexingWarning">

        <service android:name="com.cheogram.android.ConnectionService"
            android:label="Cheogram"
            android:permission="android.permission.BIND_TELECOM_CONNECTION_SERVICE">
            <intent-filter>
                <action android:name="android.telecom.ConnectionService" />
            </intent-filter>
        </service>

        <activity
            android:name=".ui.ManageAccountActivity"
            android:label="@string/title_activity_manage_accounts"

A src/cheogram/java/com/cheogram/android/ConnectionService.java => src/cheogram/java/com/cheogram/android/ConnectionService.java +114 -0
@@ 0,0 1,114 @@
package com.cheogram.android;

import android.telecom.Connection;
import android.telecom.ConnectionRequest;
import android.telecom.PhoneAccount;
import android.telecom.PhoneAccountHandle;
import android.telecom.StatusHints;
import android.telecom.TelecomManager;
import android.telecom.DisconnectCause;

import android.content.Intent;
import android.os.Bundle;
import android.os.Parcel;

import eu.siacs.conversations.xmpp.Jid;
import eu.siacs.conversations.ui.RtpSessionActivity;

public class ConnectionService extends android.telecom.ConnectionService {
	@Override
	public Connection onCreateOutgoingConnection(
		PhoneAccountHandle phoneAccountHandle,
		ConnectionRequest request
	) {
		String[] gateway = phoneAccountHandle.getId().split("/", 2);
		Connection connection = new CheogramConnection();
		connection.setAddress(
			request.getAddress(),
			TelecomManager.PRESENTATION_ALLOWED
		);
		connection.setAudioModeIsVoip(true);
		connection.setDialing();
		connection.setRingbackRequested(true);
		connection.setConnectionCapabilities(
			Connection.CAPABILITY_CAN_SEND_RESPONSE_VIA_CONNECTION
		);

		// TODO: jabber:iq:gateway
		String tel = request.getAddress().getSchemeSpecificPart().
		           replaceAll("[^\\+0-9]", "");
		if (!tel.startsWith("+1")) {
			tel = "+1" + tel;
		}

		// Instead of wiring the call up to the Android call UI,
		// just show our UI for now.  This means both are showing during a call.
		final Intent intent = new Intent(this, RtpSessionActivity.class);
		intent.setAction(RtpSessionActivity.ACTION_MAKE_VOICE_CALL);
		Bundle extras = new Bundle();
		extras.putString(
			RtpSessionActivity.EXTRA_ACCOUNT,
			Jid.of(gateway[0]).toEscapedString()
		);
		extras.putString(
			RtpSessionActivity.EXTRA_WITH,
			Jid.ofLocalAndDomain(tel, gateway[1]).toEscapedString()
		);
		extras.putBinder(
			RtpSessionActivity.EXTRA_CONNECTION_BINDER,
			new ConnectionBinder(connection)
		);
		intent.putExtras(extras);
		intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
		intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TASK);
		startActivity(intent);

		return connection;
	}

	public class ConnectionBinder extends android.os.Binder {
		protected Connection connection;

		public static final int TRANSACT_ACTIVE = android.os.IBinder.FIRST_CALL_TRANSACTION + 1;
		public static final int TRANSACT_DISCONNECT = TRANSACT_ACTIVE + 1;

		ConnectionBinder(Connection connection) {
			super();
			this.connection = connection;
		}

		@Override
		protected boolean onTransact(int code, Parcel data, Parcel reply, int flags) {
			switch(code) {
				case TRANSACT_ACTIVE:
					this.connection.setActive();
					connection.setRingbackRequested(false);
					return true;
				case TRANSACT_DISCONNECT:
					this.connection.setDisconnected(
						new DisconnectCause(DisconnectCause.UNKNOWN)
					);
					return true;
				default:
					return false;
			}
		}
	}

	public class CheogramConnection extends Connection {
		@Override
		public void onDisconnect() {
			destroy();
		}

		@Override
		public void onAbort() {
			onDisconnect();
		}

		@Override
		public void onPlayDtmfTone(char c) {
			// TODO
		}
	}
}

M src/main/java/eu/siacs/conversations/entities/Contact.java => src/main/java/eu/siacs/conversations/entities/Contact.java +37 -0
@@ 1,9 1,14 @@
package eu.siacs.conversations.entities;

import android.content.ComponentName;
import android.content.ContentValues;
import android.content.Context;
import android.database.Cursor;
import android.net.Uri;
import android.os.Bundle;
import android.telecom.PhoneAccount;
import android.telecom.PhoneAccountHandle;
import android.telecom.TelecomManager;
import android.text.TextUtils;

import androidx.annotation.NonNull;


@@ 559,6 564,38 @@ 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(
            "com.cheogram.android",
            "com.cheogram.android.ConnectionService"
        );
        return new PhoneAccountHandle(componentName, phoneAccountLabel());
    }

    // This Contact is a gateway to use for voice calls, register it with OS
    public void registerAsPhoneAccount(Context ctx) {
        TelecomManager telecomManager = ctx.getSystemService(TelecomManager.class);

        PhoneAccount phoneAccount = PhoneAccount.builder(
            phoneAccountHandle(), phoneAccountLabel()
        ).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;

M src/main/java/eu/siacs/conversations/entities/Presences.java => src/main/java/eu/siacs/conversations/entities/Presences.java +15 -0
@@ 149,6 149,21 @@ public class Presences {
        return false;
    }

    public boolean anyIdentity(final String category, final String type) {
        synchronized (this.presences) {
            if (this.presences.size() == 0) {
                return true;
            }
            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<>();

M src/main/java/eu/siacs/conversations/services/XmppConnectionService.java => src/main/java/eu/siacs/conversations/services/XmppConnectionService.java +14 -0
@@ 233,6 233,11 @@ public class XmppConnectionService extends Service {
                }
            }
        }

        if (contact.getPresences().size() > 0 &&
            contact.getPresences().anyIdentity("gateway", "pstn")) {
            contact.registerAsPhoneAccount(this);
        }
    };
    private final PresenceGenerator mPresenceGenerator = new PresenceGenerator(this);
    private List<Account> accounts;


@@ 1968,6 1973,7 @@ public class XmppConnectionService extends Service {


    public void syncRoster(final Account account) {
        unregisterPhoneAccounts(account);
        mRosterSyncTaskManager.execute(account, () -> databaseBackend.writeRoster(account.getRoster()));
    }



@@ 3449,6 3455,14 @@ 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);
    }

M src/main/java/eu/siacs/conversations/ui/RtpSessionActivity.java => src/main/java/eu/siacs/conversations/ui/RtpSessionActivity.java +37 -0
@@ 71,12 71,18 @@ import eu.siacs.conversations.xmpp.jingle.JingleRtpConnection;
import eu.siacs.conversations.xmpp.jingle.Media;
import eu.siacs.conversations.xmpp.jingle.RtpEndUserState;

import com.cheogram.android.ConnectionService.ConnectionBinder;

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 {

    public static final String EXTRA_WITH = "with";
    public static final String EXTRA_SESSION_ID = "session_id";
    public static final String EXTRA_LAST_REPORTED_STATE = "last_reported_state";
    public static final String EXTRA_LAST_ACTION = "last_action";
    public static final String EXTRA_CONNECTION_BINDER = "connection_binder";
    public static final String ACTION_ACCEPT_CALL = "action_accept_call";
    public static final String ACTION_MAKE_VOICE_CALL = "action_make_voice_call";
    public static final String ACTION_MAKE_VIDEO_CALL = "action_make_video_call";


@@ 142,6 148,8 @@ public class RtpSessionActivity extends XmppActivity implements XmppConnectionSe
        }
    }

    protected android.os.IBinder connectionBinder = null;

    @Override
    public void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);


@@ 160,6 168,8 @@ public class RtpSessionActivity extends XmppActivity implements XmppConnectionSe
            boolean dialpadVisible = savedInstanceState.getBoolean("dialpad_visible");
            binding.dialpad.setVisibility(dialpadVisible ? View.VISIBLE : View.GONE);
        }

        this.connectionBinder = getIntent().getExtras().getBinder(EXTRA_CONNECTION_BINDER);
    }

    @Override


@@ 262,6 272,29 @@ public class RtpSessionActivity extends XmppActivity implements XmppConnectionSe
        } else {
            requireRtpConnection().endCall();
        }
        disconnectConnectionBinder();
    }

    private void disconnectConnectionBinder() {
        if (connectionBinder != null) {
            android.os.Parcel args = android.os.Parcel.obtain();
            try {
                connectionBinder.transact(ConnectionBinder.TRANSACT_DISCONNECT, args, null, 0);
            } catch (android.os.RemoteException e) {}
            args.recycle();
        }
    }

    private void activateConnectionBinder() {
        // If we do this, the other UI takes over and kills our call
        // So we can't activate that UI unless we are going to use it.
        /*if (connectionBinder != null) {
            android.os.Parcel args = android.os.Parcel.obtain();
            try {
                connectionBinder.transact(ConnectionBinder.TRANSACT_ACTIVE, args, null, 0);
            } catch (android.os.RemoteException e) {}
            args.recycle();
        }*/
    }

    private void retractSessionProposal() {


@@ 1153,10 1186,14 @@ public class RtpSessionActivity extends XmppActivity implements XmppConnectionSe
    @Override
    public void onJingleRtpConnectionUpdate(Account account, Jid with, final String sessionId, RtpEndUserState state) {
        Log.d(Config.LOGTAG, "onJingleRtpConnectionUpdate(" + state + ")");
        if (state == RtpEndUserState.CONNECTED) {
            activateConnectionBinder();
        }
        if (END_CARD.contains(state)) {
            Log.d(Config.LOGTAG, "end card reached");
            releaseProximityWakeLock();
            runOnUiThread(() -> getWindow().clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON));
            disconnectConnectionBinder();
        }
        if (with.isBareJid()) {
            updateRtpSessionProposalState(account, with, state);