Display local IP addresses & Safe messages parsing
This commit is contained in:
parent
19ccc94453
commit
490673052d
@ -1,5 +1,5 @@
|
||||
# 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).
|
||||
|
||||
|
@ -40,7 +40,7 @@ dependencies {
|
||||
implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version"
|
||||
implementation 'androidx.core:core-ktx:1.3.2'
|
||||
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 'com.google.android.material:material:1.3.0'
|
||||
//implementation 'androidx.constraintlayout:constraintlayout:2.0.4'
|
||||
|
@ -1,7 +1,8 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
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.INTERNET"/>
|
||||
|
@ -7,8 +7,6 @@ import android.provider.OpenableColumns
|
||||
import android.view.Menu
|
||||
import android.view.MenuItem
|
||||
import android.view.View
|
||||
import android.widget.LinearLayout
|
||||
import android.widget.TextView
|
||||
import android.widget.Toast
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
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.ReceiveFileTransfer
|
||||
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.StringUtils
|
||||
import java.util.*
|
||||
@ -36,7 +36,7 @@ class ChatActivity : AppCompatActivity() {
|
||||
private var lastLoadedMessageOffset = 0
|
||||
private var isActivityInForeground = false
|
||||
private val filePicker = registerForActivityResult(ActivityResultContracts.GetContent()) { uri ->
|
||||
if (::airaService.isInitialized) {
|
||||
if (::airaService.isInitialized && uri != null) {
|
||||
contentResolver.query(uri, null, null, null, null)?.let { cursor ->
|
||||
if (cursor.moveToFirst()) {
|
||||
contentResolver.openInputStream(uri)?.let { inputStream ->
|
||||
@ -238,13 +238,14 @@ class ChatActivity : AppCompatActivity() {
|
||||
val contact = airaService.contacts[sessionId]
|
||||
val session = airaService.sessions[sessionId]
|
||||
val publicKey = contact?.publicKey ?: session?.peerPublicKey
|
||||
val dialogView = layoutInflater.inflate(R.layout.dialog_info, null)
|
||||
dialogView.findViewById<TextView>(R.id.text_fingerprint).text = StringUtils.beautifyFingerprint(generateFingerprint(publicKey!!))
|
||||
val dialogBinding = DialogInfoBinding.inflate(layoutInflater)
|
||||
dialogBinding.textAvatar.setLetterFrom(sessionName)
|
||||
dialogBinding.textFingerprint.text = StringUtils.beautifyFingerprint(generateFingerprint(publicKey!!))
|
||||
if (session == null) {
|
||||
dialogView.findViewById<LinearLayout>(R.id.online_fields).visibility = View.GONE
|
||||
dialogBinding.onlineFields.visibility = View.GONE
|
||||
} else {
|
||||
dialogView.findViewById<TextView>(R.id.text_ip).text = session.ip
|
||||
dialogView.findViewById<TextView>(R.id.text_outgoing).text = getString(if (session.outgoing) {
|
||||
dialogBinding.textIp.text = session.ip
|
||||
dialogBinding.textOutgoing.text = getString(if (session.outgoing) {
|
||||
R.string.outgoing
|
||||
} else {
|
||||
R.string.incoming
|
||||
@ -252,7 +253,7 @@ class ChatActivity : AppCompatActivity() {
|
||||
}
|
||||
AlertDialog.Builder(this)
|
||||
.setTitle(sessionName)
|
||||
.setView(dialogView)
|
||||
.setView(dialogBinding.root)
|
||||
.setPositiveButton(R.string.ok, null)
|
||||
.show()
|
||||
true
|
||||
@ -282,12 +283,12 @@ class ChatActivity : AppCompatActivity() {
|
||||
airaService.contacts[sessionId]?.let { contact ->
|
||||
val localFingerprint = StringUtils.beautifyFingerprint(AIRADatabase.getIdentityFingerprint())
|
||||
val peerFingerprint = StringUtils.beautifyFingerprint(generateFingerprint(contact.publicKey))
|
||||
val dialogView = layoutInflater.inflate(R.layout.dialog_fingerprints, null)
|
||||
dialogView.findViewById<TextView>(R.id.text_local_fingerprint).text = localFingerprint
|
||||
dialogView.findViewById<TextView>(R.id.text_peer_fingerprint).text = peerFingerprint
|
||||
val dialogBinding = DialogFingerprintsBinding.inflate(layoutInflater)
|
||||
dialogBinding.textLocalFingerprint.text = localFingerprint
|
||||
dialogBinding.textPeerFingerprint.text = peerFingerprint
|
||||
AlertDialog.Builder(this)
|
||||
.setTitle(R.string.verifying_contact)
|
||||
.setView(dialogView)
|
||||
.setView(dialogBinding.root)
|
||||
.setPositiveButton(R.string.they_match) { _, _ ->
|
||||
if (airaService.setVerified(sessionId)) {
|
||||
invalidateOptionsMenu()
|
||||
|
@ -10,6 +10,7 @@ import android.os.IBinder
|
||||
import android.provider.OpenableColumns
|
||||
import android.view.Menu
|
||||
import android.view.MenuItem
|
||||
import android.widget.AbsListView
|
||||
import android.widget.AdapterView
|
||||
import android.widget.Toast
|
||||
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.ReceiveFileTransfer
|
||||
import sushi.hardcore.aira.databinding.ActivityMainBinding
|
||||
import sushi.hardcore.aira.databinding.DialogIpAddressesBinding
|
||||
import sushi.hardcore.aira.utils.FileUtils
|
||||
import sushi.hardcore.aira.utils.StringUtils
|
||||
import java.lang.StringBuilder
|
||||
import java.net.NetworkInterface
|
||||
|
||||
class MainActivity : AppCompatActivity() {
|
||||
private lateinit var binding: ActivityMainBinding
|
||||
private lateinit var airaService: AIRAService
|
||||
private lateinit var onlineSessionAdapter: 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 {
|
||||
override fun onNewSession(sessionId: Int, ip: String) {
|
||||
runOnUiThread {
|
||||
@ -75,27 +94,21 @@ class MainActivity : AppCompatActivity() {
|
||||
binding.onlineSessions.apply {
|
||||
adapter = onlineSessionAdapter
|
||||
onItemClickListener = if (openedToShareFile) {
|
||||
AdapterView.OnItemClickListener { _, _, position, _ ->
|
||||
askShareFileTo(onlineSessionAdapter.getItem(position))
|
||||
}
|
||||
onSessionsItemClickSendFile
|
||||
} else {
|
||||
AdapterView.OnItemClickListener { _, _, position, _ ->
|
||||
launchChatActivity(onlineSessionAdapter.getItem(position))
|
||||
}
|
||||
onSessionsItemClick
|
||||
}
|
||||
setOnScrollListener(onSessionsScrollListener)
|
||||
}
|
||||
offlineSessionAdapter = SessionAdapter(this)
|
||||
binding.offlineSessions.apply {
|
||||
adapter = offlineSessionAdapter
|
||||
onItemClickListener = if (openedToShareFile) {
|
||||
AdapterView.OnItemClickListener { _, _, position, _ ->
|
||||
askShareFileTo(offlineSessionAdapter.getItem(position))
|
||||
}
|
||||
onSessionsItemClickSendFile
|
||||
} else {
|
||||
AdapterView.OnItemClickListener { _, _, position, _ ->
|
||||
launchChatActivity(offlineSessionAdapter.getItem(position))
|
||||
}
|
||||
onSessionsItemClick
|
||||
}
|
||||
setOnScrollListener(onSessionsScrollListener)
|
||||
}
|
||||
Intent(this, AIRAService::class.java).also { serviceIntent ->
|
||||
bindService(serviceIntent, object : ServiceConnection {
|
||||
@ -121,6 +134,23 @@ class MainActivity : AppCompatActivity() {
|
||||
}, 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 { _, _, _ ->
|
||||
if (::airaService.isInitialized){
|
||||
airaService.connectTo(binding.editPeerIp.text.toString())
|
||||
|
@ -31,14 +31,15 @@ class SessionAdapter(context: Context): BaseAdapter() {
|
||||
val view: View = convertView ?: inflater.inflate(R.layout.adapter_session, parent, false)
|
||||
val currentSession = getItem(position)
|
||||
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
|
||||
setTextColor(Color.RED)
|
||||
"?"
|
||||
} else {
|
||||
text = currentSession.name
|
||||
setTextColor(Color.WHITE)
|
||||
}
|
||||
view.findViewById<TextAvatar>(R.id.text_avatar).setLetterFrom(text.toString())
|
||||
currentSession.name!!
|
||||
})
|
||||
}
|
||||
view.findViewById<ImageView>(R.id.image_trust_level).apply {
|
||||
if (currentSession.isVerified) {
|
||||
|
@ -610,9 +610,8 @@ class AIRAService : Service() {
|
||||
}
|
||||
Protocol.ASK_LARGE_FILE -> {
|
||||
if (receiveFileTransfers[sessionId] == null) {
|
||||
val fileSize = ByteBuffer.wrap(buffer.sliceArray(1..8)).long
|
||||
val fileName = buffer.sliceArray(9 until buffer.size).decodeToString()
|
||||
val fileTransfer = ReceiveFileTransfer(fileName, fileSize, { fileTransfer ->
|
||||
Protocol.parseAskFile(buffer)?.let { fileInfo ->
|
||||
val fileTransfer = ReceiveFileTransfer(fileInfo.fileName, fileInfo.fileSize, { fileTransfer ->
|
||||
createFileTransferNotification(sessionId, fileTransfer)
|
||||
sendTo(sessionId, Protocol.acceptLargeFile())
|
||||
}, {
|
||||
@ -633,7 +632,7 @@ class AIRAService : Service() {
|
||||
.setCategory(NotificationCompat.CATEGORY_EVENT)
|
||||
.setSmallIcon(R.drawable.ic_launcher)
|
||||
.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
|
||||
.setContentIntent(
|
||||
PendingIntent.getActivity(this, 0, Intent(this, ChatActivity::class.java).apply {
|
||||
@ -649,17 +648,21 @@ class AIRAService : Service() {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
else -> {
|
||||
when (buffer[0]){
|
||||
Protocol.MESSAGE -> buffer
|
||||
Protocol.FILE -> {
|
||||
val filenameLen = ByteBuffer.wrap(ByteArray(2) +buffer.sliceArray(1..2)).int
|
||||
val filename = buffer.sliceArray(3 until 3+filenameLen)
|
||||
val rawFileUuid = AIRADatabase.storeFile(contacts[sessionId]?.uuid, buffer.sliceArray(3+filenameLen until buffer.size))
|
||||
val smallFile = Protocol.parseSmallFile(buffer)
|
||||
if (smallFile == null) {
|
||||
null
|
||||
} else {
|
||||
val rawFileUuid = AIRADatabase.storeFile(contacts[sessionId]?.uuid, smallFile.fileContent)
|
||||
if (rawFileUuid == null) {
|
||||
null
|
||||
} else {
|
||||
byteArrayOf(Protocol.FILE)+rawFileUuid+filename
|
||||
byteArrayOf(Protocol.FILE)+rawFileUuid+smallFile.rawFileName
|
||||
}
|
||||
}
|
||||
}
|
||||
else -> {
|
||||
|
@ -45,5 +45,30 @@ class Protocol {
|
||||
fun ackChunk(): ByteArray {
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -46,7 +46,7 @@ object FileUtils {
|
||||
}
|
||||
} else {
|
||||
@Suppress("Deprecation")
|
||||
File(Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS), datedFilename).outputStream()
|
||||
File(Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS), File(datedFilename).name).outputStream()
|
||||
}
|
||||
}
|
||||
}
|
@ -1,5 +1,7 @@
|
||||
package sushi.hardcore.aira.utils
|
||||
|
||||
import java.net.InetAddress
|
||||
|
||||
object StringUtils {
|
||||
fun beautifyFingerprint(fingerprint: String): String {
|
||||
val newFingerprint = StringBuilder(fingerprint.length+7)
|
||||
@ -9,4 +11,14 @@ object StringUtils {
|
||||
newFingerprint.append(fingerprint.slice(fingerprint.length-4 until fingerprint.length))
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
@ -33,6 +33,8 @@ class TextAvatar @JvmOverloads constructor(
|
||||
}
|
||||
|
||||
fun setLetterFrom(name: String) {
|
||||
if (name.isNotEmpty()) {
|
||||
view.findViewById<TextView>(R.id.text_letter).text = name[0].toUpperCase().toString()
|
||||
}
|
||||
}
|
||||
}
|
@ -22,7 +22,7 @@
|
||||
<ListView
|
||||
android:id="@+id/online_sessions"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_height="0dp"
|
||||
app:layout_constraintTop_toBottomOf="@id/text_online_sessions"/>
|
||||
|
||||
<TextView
|
||||
@ -36,19 +36,46 @@
|
||||
<ListView
|
||||
android:id="@+id/offline_sessions"
|
||||
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"
|
||||
app:layout_constraintTop_toBottomOf="@id/text_offline_sessions"/>
|
||||
android:orientation="horizontal"
|
||||
android:layout_marginHorizontal="20dp"
|
||||
app:layout_constraintBottom_toBottomOf="parent">
|
||||
|
||||
<EditText
|
||||
android:id="@+id/edit_peer_ip"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:inputType="text"
|
||||
android:maxLines="1"
|
||||
android:imeOptions="actionGo"
|
||||
android:hint="@string/add_peer_ip"
|
||||
android:layout_margin="30dp"
|
||||
app:layout_constraintBottom_toBottomOf="parent"/>
|
||||
android:layout_marginEnd="10dp"
|
||||
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.swiperefreshlayout.widget.SwipeRefreshLayout>
|
@ -1,9 +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"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
android:orientation="vertical"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
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
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
|
18
app/src/main/res/layout/dialog_ip_addresses.xml
Normal file
18
app/src/main/res/layout/dialog_ip_addresses.xml
Normal 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>
|
@ -70,4 +70,5 @@
|
||||
<string name="add_contact">Add contact</string>
|
||||
<string name="remove_contact">Remove contact</string>
|
||||
<string name="details">Details</string>
|
||||
<string name="your_addresses">Your IP addresses:</string>
|
||||
</resources>
|
||||
|
@ -1,6 +1,6 @@
|
||||
// Top-level build file where you can add configuration options common to all sub-projects/modules.
|
||||
buildscript {
|
||||
ext.kotlin_version = "1.4.21"
|
||||
ext.kotlin_version = "1.4.32"
|
||||
repositories {
|
||||
google()
|
||||
jcenter()
|
||||
|
Loading…
Reference in New Issue
Block a user