mirror of
https://github.com/yggdrasil-network/yggdrasil-android.git
synced 2025-04-28 22:25:09 +03:00
Initial commit
This commit is contained in:
commit
20ff7378e9
52 changed files with 2155 additions and 0 deletions
|
@ -0,0 +1,47 @@
|
|||
package eu.neilalexander.yggdrasil
|
||||
|
||||
import android.content.Context
|
||||
import mobile.Mobile
|
||||
import org.json.JSONObject
|
||||
import java.io.File
|
||||
|
||||
object ConfigurationProxy {
|
||||
private lateinit var json: JSONObject
|
||||
private lateinit var file: File
|
||||
|
||||
operator fun invoke(applicationContext: Context): ConfigurationProxy {
|
||||
file = File(applicationContext.filesDir, "yggdrasil.conf")
|
||||
if (!file.exists()) {
|
||||
val conf = Mobile.generateConfigJSON()
|
||||
if (file.createNewFile()) {
|
||||
file.writeBytes(conf)
|
||||
}
|
||||
}
|
||||
fix()
|
||||
return this
|
||||
}
|
||||
|
||||
fun updateJSON(fn: (JSONObject) -> Unit) {
|
||||
json = JSONObject(file.readText(Charsets.UTF_8))
|
||||
fn(json)
|
||||
val str = json.toString()
|
||||
file.writeText(str, Charsets.UTF_8)
|
||||
}
|
||||
|
||||
private fun fix() {
|
||||
updateJSON { json ->
|
||||
json.put("AdminListen", "none")
|
||||
json.put("IfName", "none")
|
||||
json.put("IfMTU", 65535)
|
||||
}
|
||||
}
|
||||
|
||||
fun getJSON(): JSONObject {
|
||||
fix()
|
||||
return json
|
||||
}
|
||||
|
||||
fun getJSONByteArray(): ByteArray {
|
||||
return json.toString().toByteArray(Charsets.UTF_8)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,25 @@
|
|||
package eu.neilalexander.yggdrasil
|
||||
|
||||
import android.app.Application
|
||||
import android.content.IntentFilter
|
||||
import androidx.localbroadcastmanager.content.LocalBroadcastManager
|
||||
|
||||
class GlobalApplication: Application() {
|
||||
private var state = PacketTunnelState
|
||||
private lateinit var config: ConfigurationProxy
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
config = ConfigurationProxy(applicationContext)
|
||||
|
||||
LocalBroadcastManager.getInstance(this).registerReceiver(
|
||||
state, IntentFilter(PacketTunnelProvider.RECEIVER_INTENT)
|
||||
)
|
||||
}
|
||||
|
||||
override fun onTerminate() {
|
||||
super.onTerminate()
|
||||
|
||||
LocalBroadcastManager.getInstance(this).unregisterReceiver(state)
|
||||
}
|
||||
}
|
132
app/src/main/java/eu/neilalexander/yggdrasil/MainActivity.kt
Normal file
132
app/src/main/java/eu/neilalexander/yggdrasil/MainActivity.kt
Normal file
|
@ -0,0 +1,132 @@
|
|||
package eu.neilalexander.yggdrasil
|
||||
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.IntentFilter
|
||||
import android.graphics.Color
|
||||
import android.net.VpnService
|
||||
import android.os.Bundle
|
||||
import android.widget.Switch
|
||||
import android.widget.TableRow
|
||||
import android.widget.TextView
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.localbroadcastmanager.content.LocalBroadcastManager
|
||||
import mobile.Mobile
|
||||
import org.json.JSONArray
|
||||
|
||||
|
||||
class MainActivity : AppCompatActivity() {
|
||||
private var state = PacketTunnelState
|
||||
|
||||
private lateinit var enabledSwitch: Switch
|
||||
private lateinit var enabledLabel: TextView
|
||||
private lateinit var ipAddressLabel: TextView
|
||||
private lateinit var subnetLabel: TextView
|
||||
private lateinit var coordinatesLabel: TextView
|
||||
private lateinit var peersLabel: TextView
|
||||
private lateinit var peersRow: TableRow
|
||||
private lateinit var settingsRow: TableRow
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
setContentView(R.layout.activity_main)
|
||||
|
||||
findViewById<TextView>(R.id.versionValue).text = Mobile.getVersion()
|
||||
|
||||
enabledSwitch = findViewById(R.id.enableMulticastSwitch)
|
||||
enabledLabel = findViewById(R.id.yggdrasilStatusLabel)
|
||||
ipAddressLabel = findViewById(R.id.ipAddressValue)
|
||||
subnetLabel = findViewById(R.id.subnetValue)
|
||||
coordinatesLabel = findViewById(R.id.coordinatesValue)
|
||||
peersLabel = findViewById(R.id.peersValue)
|
||||
peersRow = findViewById(R.id.peersTableRow)
|
||||
settingsRow = findViewById(R.id.settingsTableRow)
|
||||
|
||||
enabledLabel.setTextColor(Color.GRAY)
|
||||
|
||||
VpnService.prepare(this)
|
||||
|
||||
enabledSwitch.setOnCheckedChangeListener { _, isChecked ->
|
||||
when (isChecked) {
|
||||
true -> {
|
||||
val vpnintent = VpnService.prepare(this)
|
||||
if (vpnintent != null) {
|
||||
startActivityForResult(vpnintent, 0)
|
||||
} else {
|
||||
onActivityResult(0, RESULT_OK, vpnintent)
|
||||
}
|
||||
}
|
||||
false -> {
|
||||
val intent = Intent(this, PacketTunnelProvider::class.java)
|
||||
intent.action = PacketTunnelProvider.ACTION_STOP
|
||||
startService(intent)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
peersRow.isClickable = true
|
||||
peersRow.setOnClickListener {
|
||||
val intent = Intent(this, PeersActivity::class.java)
|
||||
startActivity(intent)
|
||||
}
|
||||
|
||||
settingsRow.isClickable = true
|
||||
settingsRow.setOnClickListener {
|
||||
val intent = Intent(this, SettingsActivity::class.java)
|
||||
startActivity(intent)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
LocalBroadcastManager.getInstance(this).registerReceiver(
|
||||
receiver, IntentFilter(PacketTunnelState.RECEIVER_INTENT)
|
||||
)
|
||||
}
|
||||
|
||||
private val receiver: BroadcastReceiver = object : BroadcastReceiver() {
|
||||
override fun onReceive(context: Context?, intent: Intent) {
|
||||
when (intent.getStringExtra("type")) {
|
||||
"state" -> {
|
||||
enabledLabel.text = if (intent.getBooleanExtra("started", false)) {
|
||||
if (state.dhtCount() == 0) {
|
||||
enabledLabel.setTextColor(Color.RED)
|
||||
"No connectivity"
|
||||
} else {
|
||||
enabledLabel.setTextColor(Color.GREEN)
|
||||
"Enabled"
|
||||
}
|
||||
} else {
|
||||
enabledLabel.setTextColor(Color.GRAY)
|
||||
"Not enabled"
|
||||
}
|
||||
ipAddressLabel.text = intent.getStringExtra("ip") ?: "N/A"
|
||||
subnetLabel.text = intent.getStringExtra("subnet") ?: "N/A"
|
||||
coordinatesLabel.text = intent.getStringExtra("coords") ?: "[]"
|
||||
peersLabel.text = when (val count = state.peerCount()) {
|
||||
0 -> "No peers"
|
||||
1 -> "1 peer"
|
||||
else -> "$count peers"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onPause() {
|
||||
super.onPause()
|
||||
LocalBroadcastManager.getInstance(this).unregisterReceiver(receiver)
|
||||
}
|
||||
|
||||
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
|
||||
super.onActivityResult(requestCode, resultCode, data)
|
||||
when (resultCode) {
|
||||
RESULT_OK -> {
|
||||
val intent = Intent(this, PacketTunnelProvider::class.java)
|
||||
intent.action = PacketTunnelProvider.ACTION_START
|
||||
startService(intent)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,181 @@
|
|||
package eu.neilalexander.yggdrasil
|
||||
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.net.VpnService
|
||||
import android.os.Handler
|
||||
import android.os.Message
|
||||
import android.os.ParcelFileDescriptor
|
||||
import android.util.Log
|
||||
import androidx.localbroadcastmanager.content.LocalBroadcastManager
|
||||
import mobile.Yggdrasil
|
||||
import org.json.JSONArray
|
||||
import java.io.FileInputStream
|
||||
import java.io.FileOutputStream
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import kotlin.concurrent.thread
|
||||
|
||||
|
||||
class PacketTunnelProvider: VpnService() {
|
||||
companion object {
|
||||
const val RECEIVER_INTENT = "eu.neilalexander.yggdrasil.PacketTunnelProvider.MESSAGE"
|
||||
|
||||
const val ACTION_START = "eu.neilalexander.yggdrasil.PacketTunnelProvider.START"
|
||||
const val ACTION_STOP = "eu.neilalexander.yggdrasil.PacketTunnelProvider.STOP"
|
||||
}
|
||||
|
||||
private var yggdrasil = Yggdrasil()
|
||||
private var started = AtomicBoolean()
|
||||
|
||||
private lateinit var config: ConfigurationProxy
|
||||
private lateinit var parcel: ParcelFileDescriptor
|
||||
|
||||
private lateinit var readerThread: Thread
|
||||
private lateinit var writerThread: Thread
|
||||
private lateinit var updateThread: Thread
|
||||
|
||||
private lateinit var readerStream: FileInputStream
|
||||
private lateinit var writerStream: FileOutputStream
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
config = ConfigurationProxy(applicationContext)
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
super.onDestroy()
|
||||
stop()
|
||||
}
|
||||
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
if (intent == null) {
|
||||
return START_NOT_STICKY
|
||||
}
|
||||
return when (intent.action ?: ACTION_STOP) {
|
||||
ACTION_START -> {
|
||||
start(); START_STICKY
|
||||
}
|
||||
ACTION_STOP -> {
|
||||
stop(); START_NOT_STICKY
|
||||
}
|
||||
else -> {
|
||||
stop(); START_NOT_STICKY
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun start() {
|
||||
if (!started.compareAndSet(false, true)) {
|
||||
return
|
||||
}
|
||||
|
||||
Log.d("PacketTunnelProvider", config.getJSON().toString())
|
||||
yggdrasil.startJSON(config.getJSONByteArray())
|
||||
|
||||
val address = yggdrasil.addressString
|
||||
var builder = Builder()
|
||||
.addAddress(address, 7)
|
||||
.addRoute("200::", 7)
|
||||
.setBlocking(true)
|
||||
.setMtu(yggdrasil.mtu.toInt())
|
||||
.setSession("Yggdrasil")
|
||||
|
||||
parcel = builder.establish()
|
||||
if (parcel == null || !parcel.fileDescriptor.valid()) {
|
||||
stop()
|
||||
return
|
||||
}
|
||||
|
||||
readerStream = FileInputStream(parcel.fileDescriptor)
|
||||
writerStream = FileOutputStream(parcel.fileDescriptor)
|
||||
|
||||
readerThread = thread {
|
||||
reader()
|
||||
}
|
||||
writerThread = thread {
|
||||
writer()
|
||||
}
|
||||
updateThread = thread {
|
||||
updater()
|
||||
}
|
||||
|
||||
val intent = Intent(RECEIVER_INTENT)
|
||||
intent.putExtra("type", "state")
|
||||
intent.putExtra("started", true)
|
||||
intent.putExtra("ip", yggdrasil.addressString)
|
||||
intent.putExtra("subnet", yggdrasil.subnetString)
|
||||
intent.putExtra("coords", yggdrasil.coordsString)
|
||||
intent.putExtra("peers", JSONArray(yggdrasil.peersJSON).length())
|
||||
LocalBroadcastManager.getInstance(this).sendBroadcast(intent)
|
||||
}
|
||||
|
||||
private fun stop() {
|
||||
if (!started.compareAndSet(true, false)) {
|
||||
return
|
||||
}
|
||||
if (readerThread != null) {
|
||||
readerStream.close()
|
||||
readerThread.interrupt()
|
||||
}
|
||||
if (writerThread != null) {
|
||||
writerStream.close()
|
||||
writerThread.interrupt()
|
||||
}
|
||||
if (updateThread != null) {
|
||||
updateThread.interrupt()
|
||||
}
|
||||
parcel.close()
|
||||
yggdrasil.stop()
|
||||
stopSelf()
|
||||
|
||||
val intent = Intent(RECEIVER_INTENT)
|
||||
intent.putExtra("type", "state")
|
||||
intent.putExtra("started", false)
|
||||
LocalBroadcastManager.getInstance(this).sendBroadcast(intent)
|
||||
}
|
||||
|
||||
private fun updater() {
|
||||
updates@ while (!updateThread.isInterrupted) {
|
||||
val intent = Intent(RECEIVER_INTENT)
|
||||
intent.putExtra("type", "state")
|
||||
intent.putExtra("started", true)
|
||||
intent.putExtra("ip", yggdrasil.addressString)
|
||||
intent.putExtra("subnet", yggdrasil.subnetString)
|
||||
intent.putExtra("coords", yggdrasil.coordsString)
|
||||
intent.putExtra("peers", yggdrasil.peersJSON)
|
||||
intent.putExtra("dht", yggdrasil.dhtjson)
|
||||
LocalBroadcastManager.getInstance(this).sendBroadcast(intent)
|
||||
|
||||
try {
|
||||
Thread.sleep(2000)
|
||||
} catch (e: java.lang.InterruptedException) {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun writer() {
|
||||
writes@ while (!writerThread.isInterrupted && writerStream.fd.valid()) {
|
||||
try {
|
||||
val b = yggdrasil.recv()
|
||||
writerStream.write(b)
|
||||
} catch (e: Exception) {
|
||||
break@writes
|
||||
}
|
||||
}
|
||||
stop()
|
||||
}
|
||||
|
||||
private fun reader() {
|
||||
var b = ByteArray(65535)
|
||||
reads@ while (!readerThread.isInterrupted && readerStream.fd.valid()) {
|
||||
try {
|
||||
val n = readerStream.read(b)
|
||||
yggdrasil.send(b.sliceArray(0..n))
|
||||
} catch (e: Exception) {
|
||||
break@reads
|
||||
}
|
||||
}
|
||||
stop()
|
||||
}
|
||||
}
|
|
@ -0,0 +1,53 @@
|
|||
package eu.neilalexander.yggdrasil
|
||||
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import androidx.localbroadcastmanager.content.LocalBroadcastManager
|
||||
import org.json.JSONArray
|
||||
|
||||
object PacketTunnelState: BroadcastReceiver() {
|
||||
var dhtState: JSONArray? = null
|
||||
private set
|
||||
|
||||
var peersState: JSONArray? = null
|
||||
private set
|
||||
|
||||
const val RECEIVER_INTENT = "eu.neilalexander.yggdrasil.PacketTunnelState.MESSAGE"
|
||||
|
||||
fun peerCount(): Int {
|
||||
if (peersState == null) {
|
||||
return 0
|
||||
}
|
||||
return peersState!!.length()
|
||||
}
|
||||
|
||||
fun dhtCount(): Int {
|
||||
if (dhtState == null) {
|
||||
return 0
|
||||
}
|
||||
return dhtState!!.length()
|
||||
}
|
||||
|
||||
override fun onReceive(context: Context?, intent: Intent) {
|
||||
when (intent.getStringExtra("type")) {
|
||||
"state" -> {
|
||||
var dht = intent.getStringExtra("dht")
|
||||
var peers = intent.getStringExtra("peers")
|
||||
|
||||
if (dht == null || dht == "null") {
|
||||
dht = "[]"
|
||||
}
|
||||
if (peers == null || peers == "null") {
|
||||
peers = "[]"
|
||||
}
|
||||
|
||||
peersState = JSONArray(peers)
|
||||
dhtState = JSONArray(dht)
|
||||
|
||||
intent.action = RECEIVER_INTENT
|
||||
LocalBroadcastManager.getInstance(context!!).sendBroadcast(intent)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
158
app/src/main/java/eu/neilalexander/yggdrasil/PeersActivity.kt
Normal file
158
app/src/main/java/eu/neilalexander/yggdrasil/PeersActivity.kt
Normal file
|
@ -0,0 +1,158 @@
|
|||
package eu.neilalexander.yggdrasil
|
||||
|
||||
import android.app.AlertDialog
|
||||
import android.content.Context
|
||||
import android.content.DialogInterface
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import android.os.Bundle
|
||||
import android.text.Layout
|
||||
import android.util.AttributeSet
|
||||
import android.util.Log
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.widget.*
|
||||
import com.google.android.material.textfield.TextInputEditText
|
||||
import org.json.JSONArray
|
||||
|
||||
class PeersActivity : AppCompatActivity() {
|
||||
private var state = PacketTunnelState
|
||||
private lateinit var config: ConfigurationProxy
|
||||
private lateinit var inflater: LayoutInflater
|
||||
|
||||
private lateinit var connectedTableLayout: TableLayout
|
||||
private lateinit var connectedTableLabel: TextView
|
||||
private lateinit var configuredTableLayout: TableLayout
|
||||
private lateinit var configuredTableLabel: TextView
|
||||
private lateinit var multicastSwitch: Switch
|
||||
private lateinit var addPeerButton: ImageButton
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
setContentView(R.layout.activity_peers)
|
||||
|
||||
config = ConfigurationProxy(applicationContext)
|
||||
inflater = LayoutInflater.from(this)
|
||||
|
||||
connectedTableLayout = findViewById(R.id.connectedPeersTableLayout)
|
||||
connectedTableLabel = findViewById(R.id.connectedPeersLabel)
|
||||
|
||||
configuredTableLayout = findViewById(R.id.configuredPeersTableLayout)
|
||||
configuredTableLabel = findViewById(R.id.configuredPeersLabel)
|
||||
|
||||
multicastSwitch = findViewById(R.id.enableMulticastSwitch)
|
||||
multicastSwitch.setOnCheckedChangeListener { button, _ ->
|
||||
when (button.isChecked) {
|
||||
true -> {
|
||||
config.updateJSON { json ->
|
||||
json.put("MulticastInterfaces", JSONArray("[\"lo\", \".*\"]"))
|
||||
}
|
||||
}
|
||||
false -> {
|
||||
config.updateJSON { json ->
|
||||
json.put("MulticastInterfaces", JSONArray("[\"lo\"]"))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
var multicastInterfaceFound = false
|
||||
val multicastInterfaces = config.getJSON().getJSONArray("MulticastInterfaces")
|
||||
(0 until multicastInterfaces.length()).forEach {
|
||||
if (multicastInterfaces[it] == ".*") {
|
||||
multicastInterfaceFound = true
|
||||
}
|
||||
}
|
||||
multicastSwitch.isChecked = multicastInterfaceFound
|
||||
|
||||
addPeerButton = findViewById(R.id.addPeerButton)
|
||||
addPeerButton.setOnClickListener {
|
||||
var view = inflater.inflate(R.layout.dialog_addpeer, null)
|
||||
var input = view.findViewById<TextInputEditText>(R.id.addPeerInput)
|
||||
val builder: AlertDialog.Builder = AlertDialog.Builder(this)
|
||||
builder.setTitle("Add Configured Peer")
|
||||
builder.setView(view)
|
||||
builder.setPositiveButton("Add") { dialog, _ ->
|
||||
config.updateJSON { json ->
|
||||
json.getJSONArray("Peers").put(input.text)
|
||||
}
|
||||
dialog.dismiss()
|
||||
updateConfiguredPeers()
|
||||
}
|
||||
builder.setNegativeButton("Cancel") { dialog, _ ->
|
||||
dialog.cancel()
|
||||
}
|
||||
builder.show()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
|
||||
updateConfiguredPeers()
|
||||
updateConnectedPeers()
|
||||
}
|
||||
|
||||
private fun updateConfiguredPeers() {
|
||||
val peers = config.getJSON().getJSONArray("Peers")
|
||||
|
||||
when (peers.length()) {
|
||||
0 -> {
|
||||
configuredTableLayout.visibility = View.GONE
|
||||
configuredTableLabel.text = "No peers currently configured"
|
||||
}
|
||||
else -> {
|
||||
configuredTableLayout.visibility = View.VISIBLE
|
||||
configuredTableLabel.text = "Configured Peers"
|
||||
|
||||
configuredTableLayout.removeAllViewsInLayout()
|
||||
for (i in 0 until peers.length()) {
|
||||
val peer = peers[i].toString()
|
||||
var view = inflater.inflate(R.layout.peers_configured, null)
|
||||
view.findViewById<TextView>(R.id.addressValue).text = peer
|
||||
view.findViewById<ImageButton>(R.id.deletePeerButton).tag = i
|
||||
|
||||
view.findViewById<ImageButton>(R.id.deletePeerButton).setOnClickListener { button ->
|
||||
val builder: AlertDialog.Builder = AlertDialog.Builder(this)
|
||||
builder.setTitle("Remove ${peer}?")
|
||||
builder.setPositiveButton("Remove") { dialog, _ ->
|
||||
config.updateJSON { json ->
|
||||
json.getJSONArray("Peers").remove(button.tag as Int)
|
||||
}
|
||||
dialog.dismiss()
|
||||
updateConfiguredPeers()
|
||||
}
|
||||
builder.setNegativeButton("Cancel") { dialog, _ ->
|
||||
dialog.cancel()
|
||||
}
|
||||
builder.show()
|
||||
}
|
||||
configuredTableLayout.addView(view)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateConnectedPeers() {
|
||||
val peers = state.peersState ?: JSONArray("[]")
|
||||
|
||||
when (peers.length()) {
|
||||
0 -> {
|
||||
connectedTableLayout.visibility = View.GONE
|
||||
connectedTableLabel.text = "No peers currently connected"
|
||||
}
|
||||
else -> {
|
||||
connectedTableLayout.visibility = View.VISIBLE
|
||||
connectedTableLabel.text = "Connected Peers"
|
||||
|
||||
connectedTableLayout.removeAllViewsInLayout()
|
||||
for (i in 0 until peers.length()) {
|
||||
val peer = peers.getJSONObject(i)
|
||||
var view = inflater.inflate(R.layout.peers_connected, null)
|
||||
val ip = peer.getString("IP")
|
||||
view.findViewById<TextView>(R.id.addressLabel).text = ip
|
||||
view.findViewById<TextView>(R.id.detailsLabel).text = peer.getString("Remote")
|
||||
connectedTableLayout.addView(view)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,11 @@
|
|||
package eu.neilalexander.yggdrasil
|
||||
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import android.os.Bundle
|
||||
|
||||
class SettingsActivity : AppCompatActivity() {
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
setContentView(R.layout.activity_settings)
|
||||
}
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue