android/app/src/main/java/io/annaclemens/xivchat/MainActivity.kt

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)
}
}
}
}
}