430 lines
17 KiB
Kotlin
430 lines
17 KiB
Kotlin
package io.annaclemens.xivchat
|
|
|
|
import android.annotation.SuppressLint
|
|
import android.app.AlertDialog
|
|
import android.content.Context
|
|
import android.content.Intent
|
|
import android.content.SharedPreferences
|
|
import android.net.Uri
|
|
import android.os.Bundle
|
|
import android.view.View
|
|
import android.widget.EditText
|
|
import androidx.activity.viewModels
|
|
import androidx.appcompat.app.AppCompatActivity
|
|
import androidx.appcompat.widget.Toolbar
|
|
import androidx.drawerlayout.widget.DrawerLayout
|
|
import androidx.fragment.app.DialogFragment
|
|
import androidx.lifecycle.lifecycleScope
|
|
import androidx.navigation.findNavController
|
|
import androidx.navigation.fragment.NavHostFragment
|
|
import androidx.navigation.ui.AppBarConfiguration
|
|
import androidx.navigation.ui.navigateUp
|
|
import androidx.navigation.ui.setupActionBarWithNavController
|
|
import androidx.navigation.ui.setupWithNavController
|
|
import androidx.preference.PreferenceManager
|
|
import androidx.recyclerview.widget.RecyclerView
|
|
import com.google.android.material.navigation.NavigationView
|
|
import com.goterl.lazycode.lazysodium.LazySodiumAndroid
|
|
import com.goterl.lazycode.lazysodium.SodiumAndroid
|
|
import com.goterl.lazycode.lazysodium.interfaces.KeyExchange
|
|
import com.goterl.lazycode.lazysodium.utils.Key
|
|
import com.goterl.lazycode.lazysodium.utils.KeyPair
|
|
import io.annaclemens.xivchat.databinding.ActivityMainBinding
|
|
import io.annaclemens.xivchat.databinding.NavHeaderMainBinding
|
|
import io.annaclemens.xivchat.model.Tab
|
|
import io.annaclemens.xivchat.model.message.InputChannel
|
|
import io.annaclemens.xivchat.ui.FilterSelection
|
|
import io.annaclemens.xivchat.ui.messages.MessagesFragment
|
|
import io.annaclemens.xivchat.ui.messages.MessagesTabsFragment
|
|
import io.annaclemens.xivchat.ui.messages.MessagesViewModel
|
|
import io.annaclemens.xivchat.ui.servers.NewServerFragment
|
|
import io.annaclemens.xivchat.ui.servers.ServersFragment
|
|
import io.annaclemens.xivchat.ui.servers.ServersViewModel
|
|
import io.annaclemens.xivchat.util.AlertDialogFragment
|
|
import io.annaclemens.xivchat.util.ConnectionServiceConnection
|
|
import io.annaclemens.xivchat.util.EditTextDialog
|
|
import io.annaclemens.xivchat.util.hexStringToByteArray
|
|
import io.ktor.client.*
|
|
import io.ktor.client.engine.android.*
|
|
import io.ktor.client.request.*
|
|
import kotlinx.coroutines.CoroutineScope
|
|
import kotlinx.coroutines.Dispatchers
|
|
import kotlinx.coroutines.channels.Channel
|
|
import kotlinx.coroutines.launch
|
|
import net.swiftzer.semver.SemVer
|
|
|
|
// TODO: better searching (by sender, message type, content, etc.)
|
|
// TODO: linkshell list, cross-world linkshell list
|
|
// TODO: notification for custom keywords
|
|
// TODO: see about wrapping options menu when font is scaled up
|
|
// TODO: auto-reconnect and show reconnect button when unexpectedly disconnected
|
|
// TODO: app doesn't disconnect when server crashes
|
|
|
|
class MainActivity : AppCompatActivity(), AlertDialogFragment.DialogHost, EditTextDialog.DialogHost {
|
|
companion object {
|
|
const val CONFIG_MARKDOWN_MODE = "markdownMode"
|
|
const val CONFIG_CHECK_FOR_UPDATES = "checkForUpdates"
|
|
const val EXTRA_DESTINATION = "io.annaclemens.xivchat.EXTRA_DESTINATION"
|
|
private const val DIALOG_UPDATE = "update"
|
|
}
|
|
|
|
val preferences: SharedPreferences
|
|
get() = PreferenceManager.getDefaultSharedPreferences(this)
|
|
private lateinit var appBarConfiguration: AppBarConfiguration
|
|
lateinit var sodium: LazySodiumAndroid
|
|
lateinit var keyPair: KeyPair
|
|
private var registered: Boolean = false
|
|
private val dialogs = mutableListOf<Pair<DialogFragment, String>>()
|
|
private var paused = false
|
|
|
|
var markdownMode: Boolean = false
|
|
get() = this.preferences.getBoolean(Companion.CONFIG_MARKDOWN_MODE, false)
|
|
set(value) {
|
|
this.preferences.edit().putBoolean(Companion.CONFIG_MARKDOWN_MODE, value).apply()
|
|
field = value
|
|
}
|
|
|
|
private val serviceConnection = ConnectionServiceConnection(this)
|
|
|
|
fun regenerateKeyPair() {
|
|
val keyPair = (this@MainActivity.sodium as KeyExchange.Lazy).cryptoKxKeypair()
|
|
this.preferences
|
|
.edit()
|
|
.putString("keyPairPublic", keyPair.publicKey.asHexString)
|
|
.putString("keyPairSecret", keyPair.secretKey.asHexString)
|
|
.apply()
|
|
this.keyPair = keyPair
|
|
}
|
|
|
|
override fun onNewIntent(intent: Intent?) {
|
|
super.onNewIntent(intent)
|
|
|
|
val dest = intent?.getIntExtra(Companion.EXTRA_DESTINATION, 0) ?: 0
|
|
if (dest != 0) {
|
|
this.findNavController(R.id.nav_host_fragment).navigate(dest)
|
|
}
|
|
}
|
|
|
|
override fun onCreate(savedInstanceState: Bundle?) {
|
|
super.onCreate(savedInstanceState)
|
|
|
|
val binding = ActivityMainBinding.inflate(this.layoutInflater)
|
|
|
|
if (savedInstanceState == null && this.preferences.getBoolean(Companion.CONFIG_CHECK_FOR_UPDATES, true)) {
|
|
this.lifecycleScope.launchWhenCreated {
|
|
val version = this@MainActivity.downloadLatestVersion() ?: return@launchWhenCreated
|
|
if (SemVer.parse(BuildConfig.VERSION_NAME) >= version) {
|
|
return@launchWhenCreated
|
|
}
|
|
|
|
val dialog = AlertDialogFragment.newInstance(Companion.DIALOG_UPDATE, Bundle().apply {
|
|
this.putString("newVersion", version.toString())
|
|
})
|
|
|
|
this@MainActivity.queueDialog(dialog, "newVersion")
|
|
}
|
|
}
|
|
|
|
// set up libsodium first
|
|
this.sodium = LazySodiumAndroid(SodiumAndroid())
|
|
this.keyPair = this.preferences.run {
|
|
val public = this.getString("keyPairPublic", null)?.hexStringToByteArray()
|
|
val secret = this.getString("keyPairSecret", null)?.hexStringToByteArray()
|
|
|
|
val keyPair: KeyPair
|
|
|
|
if (public == null || secret == null) {
|
|
keyPair = (this@MainActivity.sodium as KeyExchange.Lazy).cryptoKxKeypair()
|
|
this.edit()
|
|
.putString("keyPairPublic", keyPair.publicKey.asHexString)
|
|
.putString("keyPairSecret", keyPair.secretKey.asHexString)
|
|
.apply()
|
|
} else {
|
|
keyPair = KeyPair(Key.fromBytes(public), Key.fromBytes(secret))
|
|
}
|
|
|
|
keyPair
|
|
}
|
|
|
|
// add default tabs if necessary
|
|
val messagesVm: MessagesViewModel by this.viewModels()
|
|
messagesVm.addDefaultTabs()
|
|
|
|
// re-bind to service if necessary
|
|
this.bindConnectionService()
|
|
|
|
val serversVm: ServersViewModel by this.viewModels()
|
|
serversVm.connectionService.observe(this) {
|
|
if (it == null) {
|
|
this@MainActivity.bindConnectionService()
|
|
}
|
|
}
|
|
serversVm.playerData.observe(this) {
|
|
val header = binding.navView.getHeaderView(0)
|
|
val headerBinding = NavHeaderMainBinding.bind(header)
|
|
|
|
if (it == null) {
|
|
headerBinding.characterName.setText(R.string.menu_not_logged_in)
|
|
headerBinding.characterLocation.text = ""
|
|
return@observe
|
|
}
|
|
|
|
@SuppressLint("SetTextI18n")
|
|
headerBinding.characterName.text = "${it.name} (${it.homeWorld})"
|
|
@SuppressLint("SetTextI18n")
|
|
headerBinding.characterLocation.text = "${it.location} (${it.currentWorld})"
|
|
}
|
|
serversVm.playerPortrait.observe(this) {
|
|
val header = binding.navView.getHeaderView(0)
|
|
val headerBinding = NavHeaderMainBinding.bind(header)
|
|
if (it == null) {
|
|
headerBinding.characterPortrait.visibility = View.GONE
|
|
headerBinding.defaultCharacterPortrait.visibility = View.VISIBLE
|
|
} else {
|
|
headerBinding.characterPortrait.setImageBitmap(it)
|
|
headerBinding.defaultCharacterPortrait.visibility = View.GONE
|
|
headerBinding.characterPortrait.visibility = View.VISIBLE
|
|
}
|
|
}
|
|
|
|
// register receiver
|
|
this.setContentView(binding.root)
|
|
val toolbar: Toolbar = this.findViewById(R.id.toolbar)
|
|
this.setSupportActionBar(toolbar)
|
|
|
|
val drawerLayout: DrawerLayout = this.findViewById(R.id.drawer_layout)
|
|
val navView: NavigationView = this.findViewById(R.id.nav_view)
|
|
val navHostFragment = this.supportFragmentManager.findFragmentById(R.id.nav_host_fragment) as NavHostFragment
|
|
val navController = navHostFragment.navController
|
|
|
|
// Passing each menu ID as a set of Ids because each
|
|
// menu should be considered as top level destinations.
|
|
this.appBarConfiguration = AppBarConfiguration(
|
|
setOf(
|
|
R.id.nav_servers,
|
|
R.id.nav_messages_tabs,
|
|
R.id.nav_friend_list,
|
|
R.id.nav_targeting_list,
|
|
R.id.nav_settings,
|
|
R.id.nav_about,
|
|
),
|
|
drawerLayout,
|
|
)
|
|
this.setupActionBarWithNavController(navController, this.appBarConfiguration)
|
|
navView.setupWithNavController(navController)
|
|
}
|
|
|
|
override fun onSupportNavigateUp(): Boolean {
|
|
val navController = this.findNavController(R.id.nav_host_fragment)
|
|
return navController.navigateUp(this.appBarConfiguration) || super.onSupportNavigateUp()
|
|
}
|
|
|
|
override fun onDestroy() {
|
|
super.onDestroy()
|
|
|
|
if (this.isFinishing) {
|
|
this.stopConnectionService()
|
|
} else {
|
|
this.unbindConnectionService()
|
|
}
|
|
}
|
|
|
|
private suspend fun downloadLatestVersion(): SemVer? {
|
|
val client = HttpClient(Android)
|
|
|
|
val result = Channel<String?>(1)
|
|
|
|
CoroutineScope(Dispatchers.IO).launch {
|
|
try {
|
|
val version: String = client.get("https://git.sr.ht/~jkcclemens/xivchat-android/blob/master/VERSION")
|
|
result.send(version)
|
|
} catch (ex: Exception) {
|
|
ex.printStackTrace()
|
|
result.send(null)
|
|
} finally {
|
|
client.close()
|
|
}
|
|
}
|
|
|
|
val versionString = result.receive() ?: return null
|
|
|
|
return try {
|
|
SemVer.parse(versionString.trim())
|
|
} catch (ex: IllegalArgumentException) {
|
|
null
|
|
}
|
|
}
|
|
|
|
private fun bindConnectionService(): Boolean {
|
|
if (this.registered) {
|
|
return false
|
|
}
|
|
|
|
this.registered = true
|
|
this.startService(Intent(this, ConnectionService::class.java))
|
|
return this.bindService(Intent(this, ConnectionService::class.java), this.serviceConnection, Context.BIND_AUTO_CREATE)
|
|
}
|
|
|
|
private fun unbindConnectionService() {
|
|
if (!this.registered) {
|
|
return
|
|
}
|
|
|
|
try {
|
|
this.unbindService(this.serviceConnection)
|
|
} catch (ex: IllegalArgumentException) {
|
|
ex.printStackTrace()
|
|
// ignore
|
|
}
|
|
this.registered = false
|
|
}
|
|
|
|
fun stopConnectionService() {
|
|
// stop and unbind
|
|
this.stopService(Intent(this, ConnectionService::class.java))
|
|
this.unbindConnectionService()
|
|
}
|
|
|
|
fun queueDialog(dialogFragment: DialogFragment, tag: String) {
|
|
if (!this.paused) {
|
|
dialogFragment.show(this.supportFragmentManager, tag)
|
|
return
|
|
}
|
|
this.dialogs.add(dialogFragment to tag)
|
|
}
|
|
|
|
override fun onResume() {
|
|
super.onResume()
|
|
this.paused = false
|
|
for (entry in this.dialogs.reversed()) {
|
|
entry.first.show(this.supportFragmentManager, entry.second)
|
|
}
|
|
this.dialogs.clear()
|
|
}
|
|
|
|
override fun onPause() {
|
|
super.onPause()
|
|
this.paused = true
|
|
}
|
|
|
|
override fun onDialogCreate(type: String, args: Bundle, builder: AlertDialog.Builder) {
|
|
when (type) {
|
|
Companion.DIALOG_UPDATE -> builder
|
|
.setTitle(R.string.update_title)
|
|
.setMessage(String.format(
|
|
this@MainActivity.getString(R.string.update_desc),
|
|
BuildConfig.VERSION_NAME,
|
|
args.getString("newVersion"),
|
|
))
|
|
.setNeutralButton(android.R.string.ok, null)
|
|
ConnectionService.ConnectionRunner.DIALOG_COULD_NOT_CONNECT -> builder
|
|
.setTitle(R.string.connect_failure)
|
|
.setMessage(args.getString("message"))
|
|
.setNeutralButton(android.R.string.ok, null)
|
|
ConnectionService.ConnectionRunner.DIALOG_DISCONNECTED -> builder
|
|
.setTitle(R.string.unexpected_disconnect)
|
|
.setMessage(R.string.unexpected_disconnect_desc)
|
|
.setNeutralButton(android.R.string.ok, null)
|
|
MessagesTabsFragment.DIALOG_DELETE_TAB -> {
|
|
val vm: MessagesViewModel by this.viewModels()
|
|
builder
|
|
.setTitle(R.string.delete_tab_title)
|
|
.setMessage(R.string.delete_tab_desc)
|
|
.setNegativeButton(R.string.no, null)
|
|
.setPositiveButton(R.string.yes) { _, _ ->
|
|
vm.deleteTab(args.getInt("tabId"))
|
|
vm.addDefaultTabs()
|
|
}
|
|
}
|
|
MessagesFragment.DIALOG_PHISHING -> {
|
|
builder
|
|
.setTitle(R.string.phishing_warning)
|
|
.setMessage(args.getString("message"))
|
|
.setNegativeButton(R.string.no, null)
|
|
.setPositiveButton(R.string.yes) { _, _ ->
|
|
val i = Intent(Intent.ACTION_VIEW).apply {
|
|
this.data = Uri.parse(args.getString("url"))
|
|
}
|
|
this.startActivity(i)
|
|
}
|
|
}
|
|
MessagesFragment.DIALOG_CHANNEL -> {
|
|
builder
|
|
.setTitle(R.string.messages_choose_channel)
|
|
.setItems(R.array.channels) { _, i ->
|
|
val inputChannelRaw = this.resources.getIntArray(R.array.inputChannels)[i]
|
|
val inputChannel = InputChannel.fromCode(inputChannelRaw.toByte())
|
|
|
|
if (inputChannel != null) {
|
|
val serversVm: ServersViewModel by this.viewModels()
|
|
this.lifecycleScope.launch {
|
|
serversVm.connectionService.value?.requestChannelChange(inputChannel)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
ServersFragment.DIALOG_DELETE_CONFIRM -> {
|
|
val vm: ServersViewModel by this.viewModels()
|
|
builder
|
|
.setTitle(R.string.server_delete)
|
|
.setMessage(R.string.server_delete_desc)
|
|
.setPositiveButton(R.string.yes) { _, _ ->
|
|
vm.delete(args.getInt("serverId"))
|
|
}
|
|
.setNegativeButton(R.string.no) { _, _ ->
|
|
val serverList = this.findViewById<RecyclerView>(R.id.serverList)
|
|
serverList.adapter?.notifyDataSetChanged()
|
|
}
|
|
}
|
|
NewServerFragment.DIALOG_MISSING_FIELDS -> {
|
|
builder
|
|
.setTitle(R.string.add_server_missing_fields)
|
|
.setMessage(R.string.add_server_missing_fields_desc)
|
|
.setNeutralButton(android.R.string.ok, null)
|
|
}
|
|
}
|
|
}
|
|
|
|
override fun getEditContext(): Context = this
|
|
|
|
override fun onCreateTextView(type: String, args: Bundle, edit: EditText) {
|
|
when (type) {
|
|
MessagesTabsFragment.DIALOG_ADD_TAB -> edit.setHint(R.string.add_tab_hint)
|
|
MessagesTabsFragment.DIALOG_EDIT_TAB -> {
|
|
val currentName = args.getString("currentName")!!
|
|
edit.hint = this.getString(R.string.edit_tab_name_hint)
|
|
edit.setText(currentName)
|
|
edit.requestFocus()
|
|
edit.setSelection(currentName.length)
|
|
}
|
|
}
|
|
}
|
|
|
|
override fun onCreateEditTextDialog(type: String, args: Bundle, builder: AlertDialog.Builder, edit: EditText) {
|
|
when (type) {
|
|
MessagesTabsFragment.DIALOG_ADD_TAB -> builder
|
|
.setTitle(R.string.add_tab_title)
|
|
.setNeutralButton(android.R.string.ok) { _, _ ->
|
|
if (edit.text.isNotEmpty()) {
|
|
FilterSelection.openNew(this.supportFragmentManager, edit.text.toString())
|
|
}
|
|
}
|
|
MessagesTabsFragment.DIALOG_EDIT_TAB -> {
|
|
val vm: MessagesViewModel by this.viewModels()
|
|
val filters = args.getString("filters")!!
|
|
val id = args.getInt("id")
|
|
builder
|
|
.setTitle(R.string.edit_tab_name_title)
|
|
.setNeutralButton(android.R.string.ok) { _, _ ->
|
|
val newTab = Tab(
|
|
id,
|
|
edit.text.toString(),
|
|
filters,
|
|
)
|
|
vm.updateTab(newTab)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|