Display local IP addresses & Safe messages parsing

This commit is contained in:
Matéo Duparc 2021-05-04 20:01:06 +02:00
parent 19ccc94453
commit 490673052d
Signed by: hardcoresushi
GPG Key ID: 007F84120107191E
16 changed files with 217 additions and 87 deletions

View File

@ -1,5 +1,5 @@
# AIRA Android # AIRA Android
AIRA is peer-to-peer encrypted communication tool for local networks built on the [PSEC protocol](https://forge.chapril.org/hardcoresushi/PSEC). It allows to securely send text messages and files without any server or Internet access. AIRA is peer-to-peer encrypted communication tool for local networks built on the [PSEC protocol](https://forge.chapril.org/hardcoresushi/PSEC). It allows to securely send text messages and files without any server or Internet access. AIRA automatically discovers and connects to other peers on your network, so you don't need any prior configuration to start communicating.
Here is the Android version. You can find the original AIRA desktop version [here](https://forge.chapril.org/hardcoresushi/AIRA). Here is the Android version. You can find the original AIRA desktop version [here](https://forge.chapril.org/hardcoresushi/AIRA).

View File

@ -40,7 +40,7 @@ dependencies {
implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version" implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version"
implementation 'androidx.core:core-ktx:1.3.2' implementation 'androidx.core:core-ktx:1.3.2'
implementation 'androidx.appcompat:appcompat:1.2.0' implementation 'androidx.appcompat:appcompat:1.2.0'
implementation "androidx.fragment:fragment-ktx:1.3.2" implementation "androidx.fragment:fragment-ktx:1.3.3"
implementation "androidx.preference:preference-ktx:1.1.1" implementation "androidx.preference:preference-ktx:1.1.1"
implementation 'com.google.android.material:material:1.3.0' implementation 'com.google.android.material:material:1.3.0'
//implementation 'androidx.constraintlayout:constraintlayout:2.0.4' //implementation 'androidx.constraintlayout:constraintlayout:2.0.4'

View File

@ -1,7 +1,8 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android" <manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools" xmlns:tools="http://schemas.android.com/tools"
package="sushi.hardcore.aira"> package="sushi.hardcore.aira"
android:installLocation="auto">
<uses-permission android:name="android.permission.FOREGROUND_SERVICE"/> <uses-permission android:name="android.permission.FOREGROUND_SERVICE"/>
<uses-permission android:name="android.permission.INTERNET"/> <uses-permission android:name="android.permission.INTERNET"/>

View File

@ -7,8 +7,6 @@ import android.provider.OpenableColumns
import android.view.Menu import android.view.Menu
import android.view.MenuItem import android.view.MenuItem
import android.view.View import android.view.View
import android.widget.LinearLayout
import android.widget.TextView
import android.widget.Toast import android.widget.Toast
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AlertDialog import androidx.appcompat.app.AlertDialog
@ -21,6 +19,8 @@ import sushi.hardcore.aira.background_service.AIRAService
import sushi.hardcore.aira.background_service.Protocol import sushi.hardcore.aira.background_service.Protocol
import sushi.hardcore.aira.background_service.ReceiveFileTransfer import sushi.hardcore.aira.background_service.ReceiveFileTransfer
import sushi.hardcore.aira.databinding.ActivityChatBinding import sushi.hardcore.aira.databinding.ActivityChatBinding
import sushi.hardcore.aira.databinding.DialogFingerprintsBinding
import sushi.hardcore.aira.databinding.DialogInfoBinding
import sushi.hardcore.aira.utils.FileUtils import sushi.hardcore.aira.utils.FileUtils
import sushi.hardcore.aira.utils.StringUtils import sushi.hardcore.aira.utils.StringUtils
import java.util.* import java.util.*
@ -36,7 +36,7 @@ class ChatActivity : AppCompatActivity() {
private var lastLoadedMessageOffset = 0 private var lastLoadedMessageOffset = 0
private var isActivityInForeground = false private var isActivityInForeground = false
private val filePicker = registerForActivityResult(ActivityResultContracts.GetContent()) { uri -> private val filePicker = registerForActivityResult(ActivityResultContracts.GetContent()) { uri ->
if (::airaService.isInitialized) { if (::airaService.isInitialized && uri != null) {
contentResolver.query(uri, null, null, null, null)?.let { cursor -> contentResolver.query(uri, null, null, null, null)?.let { cursor ->
if (cursor.moveToFirst()) { if (cursor.moveToFirst()) {
contentResolver.openInputStream(uri)?.let { inputStream -> contentResolver.openInputStream(uri)?.let { inputStream ->
@ -238,13 +238,14 @@ class ChatActivity : AppCompatActivity() {
val contact = airaService.contacts[sessionId] val contact = airaService.contacts[sessionId]
val session = airaService.sessions[sessionId] val session = airaService.sessions[sessionId]
val publicKey = contact?.publicKey ?: session?.peerPublicKey val publicKey = contact?.publicKey ?: session?.peerPublicKey
val dialogView = layoutInflater.inflate(R.layout.dialog_info, null) val dialogBinding = DialogInfoBinding.inflate(layoutInflater)
dialogView.findViewById<TextView>(R.id.text_fingerprint).text = StringUtils.beautifyFingerprint(generateFingerprint(publicKey!!)) dialogBinding.textAvatar.setLetterFrom(sessionName)
dialogBinding.textFingerprint.text = StringUtils.beautifyFingerprint(generateFingerprint(publicKey!!))
if (session == null) { if (session == null) {
dialogView.findViewById<LinearLayout>(R.id.online_fields).visibility = View.GONE dialogBinding.onlineFields.visibility = View.GONE
} else { } else {
dialogView.findViewById<TextView>(R.id.text_ip).text = session.ip dialogBinding.textIp.text = session.ip
dialogView.findViewById<TextView>(R.id.text_outgoing).text = getString(if (session.outgoing) { dialogBinding.textOutgoing.text = getString(if (session.outgoing) {
R.string.outgoing R.string.outgoing
} else { } else {
R.string.incoming R.string.incoming
@ -252,7 +253,7 @@ class ChatActivity : AppCompatActivity() {
} }
AlertDialog.Builder(this) AlertDialog.Builder(this)
.setTitle(sessionName) .setTitle(sessionName)
.setView(dialogView) .setView(dialogBinding.root)
.setPositiveButton(R.string.ok, null) .setPositiveButton(R.string.ok, null)
.show() .show()
true true
@ -282,12 +283,12 @@ class ChatActivity : AppCompatActivity() {
airaService.contacts[sessionId]?.let { contact -> airaService.contacts[sessionId]?.let { contact ->
val localFingerprint = StringUtils.beautifyFingerprint(AIRADatabase.getIdentityFingerprint()) val localFingerprint = StringUtils.beautifyFingerprint(AIRADatabase.getIdentityFingerprint())
val peerFingerprint = StringUtils.beautifyFingerprint(generateFingerprint(contact.publicKey)) val peerFingerprint = StringUtils.beautifyFingerprint(generateFingerprint(contact.publicKey))
val dialogView = layoutInflater.inflate(R.layout.dialog_fingerprints, null) val dialogBinding = DialogFingerprintsBinding.inflate(layoutInflater)
dialogView.findViewById<TextView>(R.id.text_local_fingerprint).text = localFingerprint dialogBinding.textLocalFingerprint.text = localFingerprint
dialogView.findViewById<TextView>(R.id.text_peer_fingerprint).text = peerFingerprint dialogBinding.textPeerFingerprint.text = peerFingerprint
AlertDialog.Builder(this) AlertDialog.Builder(this)
.setTitle(R.string.verifying_contact) .setTitle(R.string.verifying_contact)
.setView(dialogView) .setView(dialogBinding.root)
.setPositiveButton(R.string.they_match) { _, _ -> .setPositiveButton(R.string.they_match) { _, _ ->
if (airaService.setVerified(sessionId)) { if (airaService.setVerified(sessionId)) {
invalidateOptionsMenu() invalidateOptionsMenu()

View File

@ -10,6 +10,7 @@ import android.os.IBinder
import android.provider.OpenableColumns import android.provider.OpenableColumns
import android.view.Menu import android.view.Menu
import android.view.MenuItem import android.view.MenuItem
import android.widget.AbsListView
import android.widget.AdapterView import android.widget.AdapterView
import android.widget.Toast import android.widget.Toast
import androidx.appcompat.app.AlertDialog import androidx.appcompat.app.AlertDialog
@ -19,13 +20,31 @@ import sushi.hardcore.aira.adapters.SessionAdapter
import sushi.hardcore.aira.background_service.AIRAService import sushi.hardcore.aira.background_service.AIRAService
import sushi.hardcore.aira.background_service.ReceiveFileTransfer import sushi.hardcore.aira.background_service.ReceiveFileTransfer
import sushi.hardcore.aira.databinding.ActivityMainBinding import sushi.hardcore.aira.databinding.ActivityMainBinding
import sushi.hardcore.aira.databinding.DialogIpAddressesBinding
import sushi.hardcore.aira.utils.FileUtils import sushi.hardcore.aira.utils.FileUtils
import sushi.hardcore.aira.utils.StringUtils
import java.lang.StringBuilder
import java.net.NetworkInterface
class MainActivity : AppCompatActivity() { class MainActivity : AppCompatActivity() {
private lateinit var binding: ActivityMainBinding private lateinit var binding: ActivityMainBinding
private lateinit var airaService: AIRAService private lateinit var airaService: AIRAService
private lateinit var onlineSessionAdapter: SessionAdapter private lateinit var onlineSessionAdapter: SessionAdapter
private lateinit var offlineSessionAdapter: SessionAdapter private lateinit var offlineSessionAdapter: SessionAdapter
private val onSessionsItemClick = AdapterView.OnItemClickListener { adapter, _, position, _ ->
launchChatActivity(adapter.getItemAtPosition(position) as Session)
}
private val onSessionsItemClickSendFile = AdapterView.OnItemClickListener { adapter, _, position, _ ->
askShareFileTo(adapter.getItemAtPosition(position) as Session)
}
private val onSessionsScrollListener = object : AbsListView.OnScrollListener {
override fun onScrollStateChanged(view: AbsListView?, scrollState: Int) {}
override fun onScroll(listView: AbsListView, firstVisibleItem: Int, visibleItemCount: Int, totalItemCount: Int) {
if (listView.getChildAt(0) != null) {
binding.refresher.isEnabled = listView.firstVisiblePosition == 0 && listView.getChildAt(0).top == 0
}
}
}
private val uiCallbacks = object : AIRAService.UiCallbacks { private val uiCallbacks = object : AIRAService.UiCallbacks {
override fun onNewSession(sessionId: Int, ip: String) { override fun onNewSession(sessionId: Int, ip: String) {
runOnUiThread { runOnUiThread {
@ -75,27 +94,21 @@ class MainActivity : AppCompatActivity() {
binding.onlineSessions.apply { binding.onlineSessions.apply {
adapter = onlineSessionAdapter adapter = onlineSessionAdapter
onItemClickListener = if (openedToShareFile) { onItemClickListener = if (openedToShareFile) {
AdapterView.OnItemClickListener { _, _, position, _ -> onSessionsItemClickSendFile
askShareFileTo(onlineSessionAdapter.getItem(position))
}
} else { } else {
AdapterView.OnItemClickListener { _, _, position, _ -> onSessionsItemClick
launchChatActivity(onlineSessionAdapter.getItem(position))
}
} }
setOnScrollListener(onSessionsScrollListener)
} }
offlineSessionAdapter = SessionAdapter(this) offlineSessionAdapter = SessionAdapter(this)
binding.offlineSessions.apply { binding.offlineSessions.apply {
adapter = offlineSessionAdapter adapter = offlineSessionAdapter
onItemClickListener = if (openedToShareFile) { onItemClickListener = if (openedToShareFile) {
AdapterView.OnItemClickListener { _, _, position, _ -> onSessionsItemClickSendFile
askShareFileTo(offlineSessionAdapter.getItem(position))
}
} else { } else {
AdapterView.OnItemClickListener { _, _, position, _ -> onSessionsItemClick
launchChatActivity(offlineSessionAdapter.getItem(position))
}
} }
setOnScrollListener(onSessionsScrollListener)
} }
Intent(this, AIRAService::class.java).also { serviceIntent -> Intent(this, AIRAService::class.java).also { serviceIntent ->
bindService(serviceIntent, object : ServiceConnection { bindService(serviceIntent, object : ServiceConnection {
@ -121,6 +134,23 @@ class MainActivity : AppCompatActivity() {
}, Context.BIND_AUTO_CREATE) }, Context.BIND_AUTO_CREATE)
} }
binding.buttonShowIp.setOnClickListener {
val ipAddresses = StringBuilder()
for (iface in NetworkInterface.getNetworkInterfaces()) {
for (addr in iface.inetAddresses) {
if (!addr.isLoopbackAddress) {
ipAddresses.appendLine(StringUtils.getIpFromInetAddress(addr)+" ("+iface.displayName+')')
}
}
}
val dialogBinding = DialogIpAddressesBinding.inflate(layoutInflater)
dialogBinding.textIpAddresses.text = ipAddresses.substring(0, ipAddresses.length-1) //remove last LF
AlertDialog.Builder(this)
.setTitle(R.string.your_addresses)
.setView(dialogBinding.root)
.setPositiveButton(R.string.ok, null)
.show()
}
binding.editPeerIp.setOnEditorActionListener { _, _, _ -> binding.editPeerIp.setOnEditorActionListener { _, _, _ ->
if (::airaService.isInitialized){ if (::airaService.isInitialized){
airaService.connectTo(binding.editPeerIp.text.toString()) airaService.connectTo(binding.editPeerIp.text.toString())

View File

@ -31,14 +31,15 @@ class SessionAdapter(context: Context): BaseAdapter() {
val view: View = convertView ?: inflater.inflate(R.layout.adapter_session, parent, false) val view: View = convertView ?: inflater.inflate(R.layout.adapter_session, parent, false)
val currentSession = getItem(position) val currentSession = getItem(position)
view.findViewById<TextView>(R.id.text_name).apply { view.findViewById<TextView>(R.id.text_name).apply {
if (currentSession.name == null) { view.findViewById<TextAvatar>(R.id.text_avatar).setLetterFrom(if (currentSession.name == null) {
text = currentSession.ip text = currentSession.ip
setTextColor(Color.RED) setTextColor(Color.RED)
"?"
} else { } else {
text = currentSession.name text = currentSession.name
setTextColor(Color.WHITE) setTextColor(Color.WHITE)
} currentSession.name!!
view.findViewById<TextAvatar>(R.id.text_avatar).setLetterFrom(text.toString()) })
} }
view.findViewById<ImageView>(R.id.image_trust_level).apply { view.findViewById<ImageView>(R.id.image_trust_level).apply {
if (currentSession.isVerified) { if (currentSession.isVerified) {

View File

@ -610,9 +610,8 @@ class AIRAService : Service() {
} }
Protocol.ASK_LARGE_FILE -> { Protocol.ASK_LARGE_FILE -> {
if (receiveFileTransfers[sessionId] == null) { if (receiveFileTransfers[sessionId] == null) {
val fileSize = ByteBuffer.wrap(buffer.sliceArray(1..8)).long Protocol.parseAskFile(buffer)?.let { fileInfo ->
val fileName = buffer.sliceArray(9 until buffer.size).decodeToString() val fileTransfer = ReceiveFileTransfer(fileInfo.fileName, fileInfo.fileSize, { fileTransfer ->
val fileTransfer = ReceiveFileTransfer(fileName, fileSize, { fileTransfer ->
createFileTransferNotification(sessionId, fileTransfer) createFileTransferNotification(sessionId, fileTransfer)
sendTo(sessionId, Protocol.acceptLargeFile()) sendTo(sessionId, Protocol.acceptLargeFile())
}, { }, {
@ -633,7 +632,7 @@ class AIRAService : Service() {
.setCategory(NotificationCompat.CATEGORY_EVENT) .setCategory(NotificationCompat.CATEGORY_EVENT)
.setSmallIcon(R.drawable.ic_launcher) .setSmallIcon(R.drawable.ic_launcher)
.setContentTitle(getString(R.string.download_file_request)) .setContentTitle(getString(R.string.download_file_request))
.setContentText(getString(R.string.want_to_send_a_file, name, ": $fileName")) .setContentText(getString(R.string.want_to_send_a_file, name, ": "+fileInfo.fileName))
.setOngoing(true) //not cancelable .setOngoing(true) //not cancelable
.setContentIntent( .setContentIntent(
PendingIntent.getActivity(this, 0, Intent(this, ChatActivity::class.java).apply { PendingIntent.getActivity(this, 0, Intent(this, ChatActivity::class.java).apply {
@ -649,17 +648,21 @@ class AIRAService : Service() {
} }
} }
} }
}
else -> { else -> {
when (buffer[0]){ when (buffer[0]){
Protocol.MESSAGE -> buffer Protocol.MESSAGE -> buffer
Protocol.FILE -> { Protocol.FILE -> {
val filenameLen = ByteBuffer.wrap(ByteArray(2) +buffer.sliceArray(1..2)).int val smallFile = Protocol.parseSmallFile(buffer)
val filename = buffer.sliceArray(3 until 3+filenameLen) if (smallFile == null) {
val rawFileUuid = AIRADatabase.storeFile(contacts[sessionId]?.uuid, buffer.sliceArray(3+filenameLen until buffer.size)) null
} else {
val rawFileUuid = AIRADatabase.storeFile(contacts[sessionId]?.uuid, smallFile.fileContent)
if (rawFileUuid == null) { if (rawFileUuid == null) {
null null
} else { } else {
byteArrayOf(Protocol.FILE)+rawFileUuid+filename byteArrayOf(Protocol.FILE)+rawFileUuid+smallFile.rawFileName
}
} }
} }
else -> { else -> {

View File

@ -45,5 +45,30 @@ class Protocol {
fun ackChunk(): ByteArray { fun ackChunk(): ByteArray {
return byteArrayOf(ACK_CHUNK) return byteArrayOf(ACK_CHUNK)
} }
class SmallFile(val rawFileName: ByteArray, val fileContent: ByteArray)
fun parseSmallFile(buffer: ByteArray): SmallFile? {
if (buffer.size > 3) {
val filenameLen = ByteBuffer.wrap(ByteArray(2) +buffer.sliceArray(1..2)).int
if (buffer.size > 3+filenameLen) {
val rawFileName = buffer.sliceArray(3 until 3+filenameLen)
return SmallFile(rawFileName, buffer.sliceArray(3+filenameLen until buffer.size))
}
}
return null
}
class FileInfo(val fileName: String, val fileSize: Long)
fun parseAskFile(buffer: ByteArray): FileInfo? {
return if (buffer.size > 9) {
val fileSize = ByteBuffer.wrap(buffer.sliceArray(1..8)).long
val fileName = buffer.sliceArray(9 until buffer.size).decodeToString()
FileInfo(fileName, fileSize)
} else {
null
}
}
} }
} }

View File

@ -46,7 +46,7 @@ object FileUtils {
} }
} else { } else {
@Suppress("Deprecation") @Suppress("Deprecation")
File(Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS), datedFilename).outputStream() File(Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS), File(datedFilename).name).outputStream()
} }
} }
} }

View File

@ -1,5 +1,7 @@
package sushi.hardcore.aira.utils package sushi.hardcore.aira.utils
import java.net.InetAddress
object StringUtils { object StringUtils {
fun beautifyFingerprint(fingerprint: String): String { fun beautifyFingerprint(fingerprint: String): String {
val newFingerprint = StringBuilder(fingerprint.length+7) val newFingerprint = StringBuilder(fingerprint.length+7)
@ -9,4 +11,14 @@ object StringUtils {
newFingerprint.append(fingerprint.slice(fingerprint.length-4 until fingerprint.length)) newFingerprint.append(fingerprint.slice(fingerprint.length-4 until fingerprint.length))
return newFingerprint.toString() return newFingerprint.toString()
} }
fun getIpFromInetAddress(addr: InetAddress): String {
val rawIp = addr.hostAddress
val i = rawIp.lastIndexOf('%')
return if (i == -1) {
rawIp
} else {
rawIp.substring(0, i)
}
}
} }

View File

@ -33,6 +33,8 @@ class TextAvatar @JvmOverloads constructor(
} }
fun setLetterFrom(name: String) { fun setLetterFrom(name: String) {
if (name.isNotEmpty()) {
view.findViewById<TextView>(R.id.text_letter).text = name[0].toUpperCase().toString() view.findViewById<TextView>(R.id.text_letter).text = name[0].toUpperCase().toString()
} }
} }
}

View File

@ -22,7 +22,7 @@
<ListView <ListView
android:id="@+id/online_sessions" android:id="@+id/online_sessions"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="0dp"
app:layout_constraintTop_toBottomOf="@id/text_online_sessions"/> app:layout_constraintTop_toBottomOf="@id/text_online_sessions"/>
<TextView <TextView
@ -36,19 +36,46 @@
<ListView <ListView
android:id="@+id/offline_sessions" android:id="@+id/offline_sessions"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="0dp"
app:layout_constraintTop_toBottomOf="@id/text_offline_sessions"
app:layout_constraintBottom_toTopOf="@+id/bottom_panel"/>
<androidx.constraintlayout.widget.ConstraintLayout
android:id="@+id/bottom_panel"
android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
app:layout_constraintTop_toBottomOf="@id/text_offline_sessions"/> android:orientation="horizontal"
android:layout_marginHorizontal="20dp"
app:layout_constraintBottom_toBottomOf="parent">
<EditText <EditText
android:id="@+id/edit_peer_ip" android:id="@+id/edit_peer_ip"
android:layout_width="match_parent" android:layout_width="0dp"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:inputType="text" android:inputType="text"
android:maxLines="1" android:maxLines="1"
android:imeOptions="actionGo" android:imeOptions="actionGo"
android:hint="@string/add_peer_ip" android:hint="@string/add_peer_ip"
android:layout_margin="30dp" android:layout_marginEnd="10dp"
app:layout_constraintBottom_toBottomOf="parent"/> app:layout_constraintTop_toTopOf="parent"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toStartOf="@id/button_show_ip"/>
<ImageButton
android:id="@+id/button_show_ip"
android:layout_width="30dp"
android:layout_height="30dp"
android:src="@drawable/ic_info"
android:scaleType="fitXY"
android:background="#00000000"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toEndOf="@id/edit_peer_ip"
app:layout_constraintEnd_toEndOf="parent"/>
</androidx.constraintlayout.widget.ConstraintLayout>
</androidx.constraintlayout.widget.ConstraintLayout> </androidx.constraintlayout.widget.ConstraintLayout>
</androidx.swiperefreshlayout.widget.SwipeRefreshLayout> </androidx.swiperefreshlayout.widget.SwipeRefreshLayout>

View File

@ -1,9 +1,18 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" <LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:orientation="vertical" android:layout_width="match_parent" xmlns:app="http://schemas.android.com/apk/res-auto"
android:orientation="vertical"
android:layout_width="match_parent"
android:layout_height="match_parent" android:layout_height="match_parent"
android:paddingHorizontal="20dp"> android:paddingHorizontal="20dp">
<sushi.hardcore.aira.widgets.TextAvatar
android:id="@+id/text_avatar"
android:layout_width="100dp"
android:layout_height="100dp"
app:textSize="20sp"
android:layout_gravity="center_horizontal"/>
<TextView <TextView
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"

View File

@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:orientation="vertical" android:layout_width="match_parent"
android:layout_height="match_parent">
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/your_addresses"
style="@style/Label"/>
<TextView
android:id="@+id/text_ip_addresses"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textAlignment="center"/>
</LinearLayout>

View File

@ -70,4 +70,5 @@
<string name="add_contact">Add contact</string> <string name="add_contact">Add contact</string>
<string name="remove_contact">Remove contact</string> <string name="remove_contact">Remove contact</string>
<string name="details">Details</string> <string name="details">Details</string>
<string name="your_addresses">Your IP addresses:</string>
</resources> </resources>

View File

@ -1,6 +1,6 @@
// Top-level build file where you can add configuration options common to all sub-projects/modules. // Top-level build file where you can add configuration options common to all sub-projects/modules.
buildscript { buildscript {
ext.kotlin_version = "1.4.21" ext.kotlin_version = "1.4.32"
repositories { repositories {
google() google()
jcenter() jcenter()