Activitate de Laborator

Se dorește implementarea unei aplicații Android care folosește stiva Android Linphone pentru a realiza apeluri de voce precum și comunicație prin mesagerie instantanee folosind SIP.

Proiectul conține o singură activitate cu două layouturi care primesc alternativ vizibilitatea View.GONE și View.VISIBLE pentru a apărea și a dispărea din spațiul activitătii curente. Varianta inițială a scheletului schimbă între cele două layouturi folosind butoanele Register/Unregister.

  1. În contul Github personal, să se creeze un depozit denumit 'Laborator10'. Inițial, acesta trebuie să fie gol (nu trebuie să bifați nici adăugarea unui fișier README.md, nici a fișierului .gitignore sau a a fișierului LICENSE).

  2. Să se cloneze în directorul de pe discul local conținutul depozitului la distanță de la .

În urma acestei operații, directorul Laborator10 va trebui să se conțină directorul labtasks.

student@eim-lab:~$ git clone https://github.com/eim-lab/Laborator10.git

Pentru java, comutați pe branchul master-java:

student@eim-lab:~$ git checkout -b master-java

Pentru multimodule & Kotlin, comutati pe branch-ul multimodule

student@eim-lab:~$ git checkout -b multimodule
  1. Să se încarce conținutul descărcat în cadrul depozitului 'Laborator10' de pe contul Github personal.
student@eim-lab:~$ cd Laborator10
student@eim-lab:~/Laborator10$ git remote add Laborator10_perfectstudent https://github.com/perfectstudent/Laborator10
student@eim-lab:~/Laborator10$ git push Laborator10_perfectstudent master
  1. Să se importe în mediul integrat de dezvoltare Android Studio proiectul labtasks/siplinphone.

4.b Sa se inregistreze un cont gratuit pe platforma https://www.linphone.org/en/getting-started/ pentru a obtine credentialele necesare (username & password).

  1. În activitatea MainActivity, layoutul principal, să se implementeze ascultători pentru butoanele Register și Unregister:
  • Register

    1. va configura motorul Linphone, ai cărui parametri sunt plasați sub forma unor valori asociate unor chei
    2. va porni motorul Linphone (metoda start())

    Aceste funcționalități for fi grupate în funcția login() care poate fi apelată din listener



private boolean login() {
    String username = ((EditText) findViewById(R.id.username)).getText().toString();
    String password = ((EditText) findViewById(R.id.password)).getText().toString();
    String domain = ((EditText) findViewById(R.id.domain)).getText().toString();

    TransportType transportType;
    int checkedId = ((RadioGroup) findViewById(R.id.transport)).getCheckedRadioButtonId();
    if (checkedId == R.id.udp) {
        transportType = TransportType.Udp;
    } else if (checkedId == R.id.tcp) {
        transportType = TransportType.Tcp;
    } else {
        transportType = TransportType.Tls;
    }

    AuthInfo authInfo = Factory.instance().createAuthInfo(username, null, password, null, null, domain, null);

    AccountParams params = core.createAccountParams();
    Address identity = Factory.instance().createAddress("sip:" + username + "@" + domain);
    if (identity == null) {
        Toast.makeText(this, "Identity not valid", Toast.LENGTH_LONG).show();
        return false;
    }
    params.setIdentityAddress(identity);

    Address address = Factory.instance().createAddress("sip:" + domain);
    if (address != null) {
        address.setTransport(transportType);
    }
    params.setServerAddress(address);
    params.setRegisterEnabled(true);

    Account account = core.createAccount(params);
    core.addAuthInfo(authInfo);
    core.addAccount(account);

    core.setDefaultAccount(account);
    core.addListener(coreListener);

    core.start();

    if (checkSelfPermission(Manifest.permission.RECORD_AUDIO) != PackageManager.PERMISSION_GRANTED) {
        requestPermissions(new String[]{Manifest.permission.RECORD_AUDIO}, 0);
        return false;
    }
    return true;
}



private fun login(): Boolean {
    val username = findViewById<EditText>(R.id.username).text.toString()
    val password = findViewById<EditText>(R.id.password).text.toString()
    val domain = findViewById<EditText>(R.id.domain).text.toString()
    val transportType = when (findViewById<RadioGroup>(R.id.transport).checkedRadioButtonId) {
        R.id.udp -> TransportType.Udp
        R.id.tcp -> TransportType.Tcp
        else -> TransportType.Tls
    }
    val authInfo = Factory.instance().createAuthInfo(username, null, password, null, null, domain, null)

    val params = core.createAccountParams()
    val identity = Factory.instance().createAddress("sip:$username@$domain")
    if (identity == null) {
        Toast.makeText(this, "Identity not valid", Toast.LENGTH_LONG).show()
        return false
    }
    params.identityAddress = identity

    val address = Factory.instance().createAddress("sip:$domain")
    address?.transport = transportType
    params.serverAddress = address
    params.setRegisterEnabled(true)
    val account = core.createAccount(params)

    core.addAuthInfo(authInfo)
    core.addAccount(account)

    core.defaultAccount = account
    core.addListener(coreListener)
    core.start()

    if (packageManager.checkPermission(Manifest.permission.RECORD_AUDIO, packageName) != PackageManager.PERMISSION_GRANTED) {
        requestPermissions(arrayOf(Manifest.permission.RECORD_AUDIO), 0)
        return false
    }
    return true
}

Unregister



findViewById(R.id.unregister).setOnClickListener(v -> {
    Account account = core.getDefaultAccount();
    if (account != null) {
        AccountParams params = account.getParams();
        AccountParams clonedParams = params.clone();
        clonedParams.setRegisterEnabled(false);
        account.setParams(clonedParams);
        v.setEnabled(false);
    }
});



findViewById<Button>(R.id.unregister).setOnClickListener {
    val account = core.defaultAccount
    if (account != null) {
        val params = account.params
        val clonedParams = params.clone()
        clonedParams.setRegisterEnabled(false)
        account.params = clonedParams
        it.isEnabled = false
    }
}

Succesul acestor operații nu poate fi vizualizat imediat, deoarece aceste rezultate vor fi furnizate prin callback-uri ale obiectului corelistener care va fi definit ca mebru privat al activității:



public class MainActivity extends AppCompatActivity {
    private Core core;

    private final CoreListener coreListener = new CoreListenerStub() {
        @Override
        public void onAccountRegistrationStateChanged(Core core, Account account, RegistrationState state, String message) {
            ...
        }
        @Override
        public void onCallStateChanged(Core core, Call call, Call.State state, String message) {
            ...
        }
    };
}



class MainActivity: AppCompatActivity() {
    private lateinit var core: Core
    private val coreListener = object: CoreListenerStub() {
        override fun onAccountRegistrationStateChanged(core: Core, account: Account, state: RegistrationState?, message: String) {
            ...
        }
        override fun onCallStateChanged(core: Core, call: Call, state: Call.State?, message: String) {
            ...
        }
    }
}

Pentru proiectul de astăzi, coreListener va primi callback-uri legate de starea procedurilor de REGISTER, și a stării apelului (incoming sau outgoing).

  1. Să se implementeze callback-urile din coreListener pentru starea înregistrării în care se activează și se dezactivează butoanele sau layouturile:


@Override
public void onAccountRegistrationStateChanged(Core core, Account account, RegistrationState state, String message) {
    ((TextView) findViewById(R.id.registration_status)).setText(message);

    if (state != null) {
        switch (state) {
            case Failed:
                findViewById(R.id.register).setEnabled(true);
                break;
            case Cleared:
                findViewById(R.id.register_layout).setVisibility(View.VISIBLE);
                findViewById(R.id.call_layout).setVisibility(View.GONE);
                findViewById(R.id.register).setEnabled(true);
                break;
            case Ok:
                findViewById(R.id.register_layout).setVisibility(View.GONE);
                findViewById(R.id.call_layout).setVisibility(View.VISIBLE);
                findViewById(R.id.unregister).setEnabled(true);
                findViewById(R.id.remote_address).setEnabled(true);
                break;
        }
    }
}



override fun onAccountRegistrationStateChanged(core: Core, account: Account, state: RegistrationState?, message: String) {
    findViewById<TextView>(R.id.registration_status).text = message
    when (state) {
        RegistrationState.Failed -> {
            findViewById<Button>(R.id.register).isEnabled = true
        }

        RegistrationState.Cleared -> {
            findViewById<LinearLayout>(R.id.register_layout).visibility = View.VISIBLE
            findViewById<RelativeLayout>(R.id.call_layout).visibility = View.GONE
            findViewById<Button>(R.id.register).isEnabled = true
        }

        RegistrationState.Ok -> {
            findViewById<LinearLayout>(R.id.register_layout).visibility = View.GONE
            findViewById<RelativeLayout>(R.id.call_layout).visibility = View.VISIBLE
            findViewById<Button>(R.id.unregister).isEnabled = true
            findViewById<EditText>(R.id.remote_address).isEnabled = true
        }
        else -> {}
    }
}

  1. Sa se adauge în OnCreate listenere pentru butoanele Call și Hang-up


findViewById(R.id.call).setOnClickListener(v -> {
    outgoingCall();
    findViewById(R.id.remote_address).setEnabled(false);
    v.setEnabled(false);
    findViewById(R.id.hang_up).setEnabled(true);
});

private void outgoingCall() {
    String remoteSipUri = ((EditText) findViewById(R.id.remote_address)).getText().toString();
    Address remoteAddress = Factory.instance().createAddress("sip:" + remoteSipUri);
    if (remoteAddress == null) {
        return;
    }

    CallParams params = core.createCallParams(null);
    if (params == null) {
        return;
    }

    params.setMediaEncryption(MediaEncryption.None);
    core.inviteAddressWithParams(remoteAddress, params);
}

findViewById(R.id.hang_up).setOnClickListener(v -> {
    findViewById(R.id.remote_address).setEnabled(true);
    findViewById(R.id.call).setEnabled(true);

    if (core.getCallsNb() != 0) {
        Call call = core.getCurrentCall() != null ? core.getCurrentCall() : core.getCalls()[0];
        if (call != null) {
            call.terminate();
        }
    }
});



findViewById<Button>(R.id.call).setOnClickListener {
    outgoingCall()
    findViewById<EditText>(R.id.remote_address).isEnabled = false
    findViewById<Button>(R.id.hang_up).isEnabled = true
    it.isEnabled = false
}

private fun outgoingCall() {
    val remoteSipUri = findViewById<EditText>(R.id.remote_address).text.toString()
    val remoteAddress = Factory.instance().createAddress("sip:$remoteSipUri")
    remoteAddress ?: return
    val params = core.createCallParams(null)
    params ?: return
    params.mediaEncryption = MediaEncryption.None
    // initiate call, but status will be in coreListener:onCallStateChanged
    core.inviteAddressWithParams(remoteAddress, params)
}

findViewById<Button>(R.id.hang_up).setOnClickListener {
    findViewById<EditText>(R.id.remote_address).isEnabled = true
    findViewById<Button>(R.id.call).isEnabled = true

    if (core.callsNb != 0) {
        val call = if (core.currentCall != null) core.currentCall else core.calls[0]
        if (call != null)
            call.terminate()
    }
}

În acest moment pot fi testate apeluri către roboți de SIP, cum ar fi:

  • 904@mouselike.org
  • thetestcall@sip.linphone.org
  1. Să se implementeze callback-urile din coreListener pentru starea apelului:


@Override
public void onCallStateChanged(Core core, Call call, Call.State state, String message) {
    ((TextView) findViewById(R.id.call_status)).setText(message);

    if (state != null) {
        switch (state) {
            case IncomingReceived:
                findViewById(R.id.hang_up).setEnabled(true);
                findViewById(R.id.answer).setEnabled(true);

                String remoteAddress = call.getRemoteAddressAsString();
                if (remoteAddress != null) {
                    ((EditText) findViewById(R.id.remote_address)).setText(remoteAddress);
                }
                break;
            case Connected:
                findViewById(R.id.mute_mic).setEnabled(true);
                findViewById(R.id.toggle_speaker).setEnabled(true);
                Toast.makeText(MainActivity.this, "Remote party answered", Toast.LENGTH_LONG).show();
                break;
            case Released:
                findViewById(R.id.hang_up).setEnabled(false);
                findViewById(R.id.answer).setEnabled(false);
                findViewById(R.id.mute_mic).setEnabled(false);
                findViewById(R.id.toggle_speaker).setEnabled(false);
                ((EditText) findViewById(R.id.remote_address)).getText().clear();
                findViewById(R.id.call).setEnabled(true);
                break;
        }
    }
}



override fun onCallStateChanged(core: Core, call: Call, state: Call.State?, message: String) {
    findViewById<TextView>(R.id.call_status).text = message

    when (state) {
        Call.State.IncomingReceived -> {
            findViewById<Button>(R.id.hang_up).isEnabled = true
            findViewById<Button>(R.id.answer).isEnabled = true

            val remoteAddress = call.remoteAddressAsString
            if (remoteAddress != null) {
                findViewById<EditText>(R.id.remote_address).setText(remoteAddress)
            }
        }
        Call.State.Connected -> {
            findViewById<Button>(R.id.mute_mic).isEnabled = true
            findViewById<Button>(R.id.toggle_speaker).isEnabled = true
            Toast.makeText(this@MainActivity, "remote party answered", Toast.LENGTH_LONG).show()
        }
        Call.State.Released -> {
            findViewById<Button>(R.id.hang_up).isEnabled = false
            findViewById<Button>(R.id.answer).isEnabled = false
            findViewById<Button>(R.id.mute_mic).isEnabled = false
            findViewById<Button>(R.id.toggle_speaker).isEnabled = false
            findViewById<EditText>(R.id.remote_address).text.clear()
            findViewById<Button>(R.id.call).isEnabled = true
        }

        else -> {}
    }
}

Pentru a accepta apeluri, trebuie activat butonul Answer cu un apel la core:



findViewById(R.id.answer).setOnClickListener(v -> {
    Call currentCall = core.getCurrentCall();
    if (currentCall != null) {
        currentCall.accept();
    }
});



findViewById<Button>(R.id.answer).setOnClickListener {
        core.currentCall?.accept()
}

În acest moment pot fi testate apeluri între diverse instanțe ale laboratorului, sau comunica cu un alt client SIP.

  1. Să se analizeze conversația SIP la nivel pachet folosind un apel de voce către o adresă SIP de test (thetestcall@sip.linphone.org). Pentru a captura pachetele în device este nevoie de root (telefon rutat, imagine root-ată).
Aveți root pe device

Aveți root pe device

    student@eim-lab:/opt/android-sdk-linux/platform-tools$ ./adb -s 192.168.56.101:5555 shell

În consola sistemului de operare Android, se folosește utilitarul tcpdump pentru monitorizarea traficului de pachete.

Binarele pentru acest utilitar, precompilate pentru sisteme de operare Android, folosind arhitecturi ARM, pot fi descărcate de pe Android TCP Dump.

În situația în care este necesar ca acest utilitar să fie instalat pe alte arhitecturi (de exemplu, Genymotion folosește x86), binarul acestuia poate fi obținut folosind utilitarul build-android-tcpdump care însă are nevoie de NDK precum și de alte programe (flex, bison).

Transferul binarului tcpdump de pe mașina fizică pe dispozitivul mobil (rootat) sau pe emulator se face astfel:

    student@eim-lab:/android/sdk/platform-tools$ ./adb -s 192.168.65.101:5555 push tcpdump /data/bin

Notă:

Utilitarul tcpdump se instalează în /data/bin, apoi se conferă drepturi de execuție pentru binar:

root@android:/data/bin# chmod 777 tcpdump


Monitorizarea propriu-zisă a pachetelor UDP pe interfața de rețea eth1 poate fi realizată prin intermediul următoarei comenzi:

root@android:/# ./tcpdump -s0 -ni eth1 -w /sdcard/DCIM/sip.pcap 'udp'

Se pornește apelul audio și după ce se termină mesajul, se oprește.

Programul tcpdump este terminat prin Ctrl-C.

student@eim-lab:/opt/android-sdk-linux/platform-tools$ ./adb -s 192.168.56.101:5555 pull /sdcard/DCIM/sip.pcap
student@eim-lab:/opt/android-sdk-linux/platform-tools$ wireshark sip.pcap

Nu aveți root pe device

Va trebui să capturați pachetele de tip UDP din mașina de dezvoltare care funcționează ca un ruter pentru device-ul virtual. Dacă folosiți un device real, și acesta va trimite pachetele VoiP/SIP prin interfeța 4G, atunci nu vor putea fi capturate în mașina de dezvoltare. Pentru pachetele trimise de pe device prin WiFi se poate face o captură numai dacă WiFi nu folosește WPA și pachetele zboară in clear (nu sunt criptate prin aer).

Linux
$ ip ro 
default via 192.168.100.1 dev enp0s31f6 proto dhcp src 192.168.100.107 metric 100 
172.17.17.0/24 dev ztrf26yfym proto kernel scope link src 172.17.17.18 
172.18.0.0/16 dev docker0 proto kernel scope link src 172.18.0.1 linkdown 
192.168.56.0/24 dev vboxnet0 proto kernel scope link src 192.168.56.1 linkdown 
192.168.100.0/24 dev enp0s31f6 proto kernel scope link src 192.168.100.107 metric 100 

interfața default a mașinii de dezvoltare este enp0s31f6, așadar aici pot fi capturate cu wireshark pachetele forwardate pentru device-ul virtual.

Windows Windows: TODO - instalare wireshark și captură fie din aer, fie dacă rulăm AVD/geny de pe interfața default
OSX OSX: TODO - utilitarul airport, instalare wireshark

Se obține dump-ul fie din dispozitivul virtual, fie din masina de dezvoltare și se analizează folosind wireshark instalat local.

  • Să se identifice operația REGISTER. Ce port se utilizează? Care este adresa serverului?

  • Să se găsească, în răspunsul de confirmare, adresele NAT prin care trece conversația, odată ce a fost acceptată cererea.

  • Să se identifice operația INVITE. Apar retransmisii?

  • Ce fel de codificare este utilizată pentru semnalul audio?

  • Ce parametri are fluxul de voce (protocol, dimensiune pachet, rata pachetelor)?

  • Ce adrese sunt folosite pentru traficul de voce și cum au fost negociate?


Note

Pornirea monitorizării (pornirea utilitarului tcpdump) trebuie realizată anterior operației de înregistrare. Similar, oprirea monitorizării trebuie realizată ulterior operației de deînregistrare. În acest fel, pot fi surprinse toate operațiile.


10. (opțional) Pentru a trimite coduri numerice DTMF (Dual Tone Multi Frequency) se creează un buton și un câmp text editabil asociat. Transmiterea unui astfel de caracter se realizează prin intermediul metodei sendDtmf() a obiectului core.currentCall cu valorile întregi 0-9, sau 10 pentru * și 11 pentru #. Folosind o adresa de test (thetestcall@sip.linphone.org sau 904@mouselike.org) să se testeze codurile și navigarea prin meniuri.

Să se implementeze metoda asociată clasei ascultător corespunzătoare operației de apăsare a butonului respectiv.

Indicații de Rezolvare


findViewById(R.id.dtmfsend).setOnClickListener(v -> {
    String keypress = ((EditText) findViewById(R.id.dtmfedit)).getText().toString();
    if (keypress.isEmpty()) {
        Toast.makeText(MainActivity.this, "Need phone key character 0-9, +, #", Toast.LENGTH_LONG).show();
        return;
    }

    Call call = core.getCurrentCall() != null
            ? core.getCurrentCall()
            : (core.getCalls().length > 0 ? core.getCalls()[0] : null);

    if (call != null) {
        call.sendDtmf(keypress.charAt(0));
    }
});



findViewById<Button>(R.id.dtmfsend).setOnClickListener {
    val keypress = (findViewById<EditText>(R.id.dtmfedit)).text.toString()
    if (keypress.isEmpty()) {
        Toast.makeText(this@MainActivity, "Need phone key character 0-9, +, #", Toast.LENGTH_LONG).show()
        return@setOnClickListener
    }

    val call = if (core.currentCall != null)
        core.currentCall
    else if (core.calls.size > 0)
        core.calls[0]
    else null
    if (call != null)
        call.sendDtmf(keypress[0])
}

11. (Opțional) TODO Implementare instant messenging. Activitatea InstantMessagingActivity poate fi lansată din activitatea principală, doar ulterior operației de înregistrare. Aceasta primește ca argument, în intenția cu care este lansată în execuție, adresa SIP cu care se va desfașura sesiunea de mesagerie instantanee.

12. Să se încarce modificările realizate în cadrul depozitului 'Laborator10' de pe contul Github personal, folosind un mesaj sugestiv.

student@eim-lab:~/Laborator10$ git add * # dar ștergeți credențialele 
student@eim-lab:~/Laborator10$ git commit -m "implemented taks for laboratory 10"
student@eim-lab:~/Laborator10$ git push Laborator10_perfectstudent master

Resurse Utile

Introduction to SIP - A Beginners' Tutorial as part of Internet Multimedia
How VoIP Works?
Session Initiation Protocol (Tutorial's Point)
Session Initiation Protocol - Wikipedia
Linphone app
WebRTC