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.
-
Î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.gitignoresau a a fișieruluiLICENSE). -
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
- 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
- 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).
- În activitatea
MainActivity, layoutul principal, să se implementeze ascultători pentru butoanele Register și Unregister:
-
Register
- va configura motorul Linphone, ai cărui parametri sunt plasați sub forma unor valori asociate unor chei
- 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).
- 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 -> {}
}
}
- Sa se adauge în OnCreate listenere pentru butoanele
CallșiHang-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.orgthetestcall@sip.linphone.org
- 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.
- 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
tcpdumpse 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 defaultOSX
OSX: TODO - utilitarul airport, instalare wiresharkSe 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