feat: add targeting list

This commit is contained in:
Anna 2021-07-05 02:13:14 -04:00
parent be86e353d1
commit 2da9616b2b
21 changed files with 287 additions and 20 deletions

View File

@ -155,4 +155,4 @@
<option name="WRAP_ON_TYPING" value="0" />
</codeStyleSettings>
</code_scheme>
</component>
</component>

View File

@ -1,6 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="CompilerConfiguration">
<bytecodeTargetLevel target="1.8" />
<bytecodeTargetLevel target="11" />
</component>
</project>

View File

@ -1,6 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectRootManager" version="2" languageLevel="JDK_1_8" default="true" project-jdk-name="1.8" project-jdk-type="JavaSDK">
<component name="ProjectRootManager" version="2" languageLevel="JDK_11" default="true" project-jdk-name="1.8" project-jdk-type="JavaSDK">
<output url="file://$PROJECT_DIR$/build/classes" />
</component>
<component name="ProjectType">

View File

@ -3,6 +3,7 @@
<component name="RunConfigurationProducerService">
<option name="ignoredProducers">
<set>
<option value="com.android.tools.idea.compose.preview.runconfiguration.ComposePreviewRunConfigurationProducer" />
<option value="org.jetbrains.plugins.gradle.execution.test.runner.AllInPackageGradleConfigurationProducer" />
<option value="org.jetbrains.plugins.gradle.execution.test.runner.TestClassGradleConfigurationProducer" />
<option value="org.jetbrains.plugins.gradle.execution.test.runner.TestMethodGradleConfigurationProducer" />

View File

@ -58,16 +58,16 @@ dependencies {
implementation fileTree(dir: "libs", include: ["*.jar"])
// default kotlin android stuff
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.core:core-ktx:1.6.0'
implementation 'androidx.appcompat:appcompat:1.3.0'
implementation 'androidx.legacy:legacy-support-v4:1.0.0'
implementation 'com.google.android.material:material:1.3.0'
implementation 'com.google.android.material:material:1.4.0'
implementation 'androidx.constraintlayout:constraintlayout:2.0.4'
implementation 'androidx.navigation:navigation-fragment-ktx:2.3.3'
implementation 'androidx.navigation:navigation-ui-ktx:2.3.3'
implementation 'androidx.navigation:navigation-fragment-ktx:2.3.5'
implementation 'androidx.navigation:navigation-ui-ktx:2.3.5'
implementation 'androidx.lifecycle:lifecycle-extensions:2.2.0'
implementation 'androidx.navigation:navigation-fragment-ktx:2.3.3'
implementation 'androidx.navigation:navigation-ui-ktx:2.3.3'
implementation 'androidx.navigation:navigation-fragment-ktx:2.3.5'
implementation 'androidx.navigation:navigation-ui-ktx:2.3.5'
// preference ui objects
implementation "androidx.preference:preference-ktx:1.1.1"
// libsodium for android
@ -100,11 +100,11 @@ dependencies {
implementation 'com.leinardi.android:speed-dial:3.1.1'
// tests
testImplementation 'junit:junit:4.13'
androidTestImplementation 'androidx.test.ext:junit:1.1.2'
androidTestImplementation 'androidx.test.espresso:espresso-core:3.3.0'
androidTestImplementation 'androidx.test.ext:junit:1.1.3'
androidTestImplementation 'androidx.test.espresso:espresso-core:3.4.0'
// desugaring
implementation 'androidx.multidex:multidex:2.0.1'
coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:1.1.1'
coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:1.1.5'
// sentry
implementation 'io.sentry:sentry-android:4.0.0'
}

View File

@ -36,6 +36,8 @@ import io.annaclemens.xivchat.model.message.ClientCatchUp
import io.annaclemens.xivchat.model.message.ClientChannel
import io.annaclemens.xivchat.model.message.ClientMessage
import io.annaclemens.xivchat.model.message.ClientPlayerList
import io.annaclemens.xivchat.model.message.ClientPreference
import io.annaclemens.xivchat.model.message.ClientPreferences
import io.annaclemens.xivchat.model.message.ClientShutdown
import io.annaclemens.xivchat.model.message.InputChannel
import io.annaclemens.xivchat.model.message.Ping
@ -49,6 +51,8 @@ import io.annaclemens.xivchat.model.message.SuccessMessage
import io.annaclemens.xivchat.ui.friends.FriendListViewModel
import io.annaclemens.xivchat.ui.messages.MessagesViewModel
import io.annaclemens.xivchat.ui.servers.ServersViewModel
import io.annaclemens.xivchat.ui.targeting.TargetingListViewModel
import io.annaclemens.xivchat.ui.targeting.TargetingPlayer
import io.annaclemens.xivchat.util.AlertDialogFragment
import io.annaclemens.xivchat.util.AppKeyExchange
import io.annaclemens.xivchat.util.CharacterSearchResults
@ -83,6 +87,8 @@ import kotlinx.coroutines.withTimeout
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.json.Json
import java.nio.ByteBuffer
import java.util.*
import kotlin.collections.HashSet
class ConnectionService : Service() {
companion object {
@ -174,7 +180,7 @@ class ConnectionService : Service() {
.build()
}
override fun onBind(intent: Intent?): IBinder? {
override fun onBind(intent: Intent?): IBinder {
return this.binder
}
@ -317,6 +323,9 @@ class ConnectionService : Service() {
this.service.app.findNavController(R.id.nav_host_fragment).navigate(R.id.nav_messages_tabs)
}
// send protocol preferences
this.sendPreferences(txStream, tx)
// request catch-up or backlog
this.requestBacklog(txStream, tx)
@ -504,6 +513,13 @@ class ConnectionService : Service() {
SecretMessage.writeSecretMessage(this@ConnectionRunner.service.app.sodium, txStream, tx, bytes)
}
private suspend fun sendPreferences(txStream: ByteWriteChannel, tx: Key) {
val prefs = ClientPreferences(mapOf(
ClientPreference.TargetingListSupport to true,
))
SecretMessage.writeSecretMessage(this.service.app.sodium, txStream, tx, prefs.encode())
}
private class EventLoop(private val runner: ConnectionRunner) {
private var loop = true
@ -652,6 +668,34 @@ class ConnectionService : Service() {
vm.friends.value = list.players.toList()
}
}
PlayerListType.Targeting -> {
val vm: TargetingListViewModel by this.runner.service.app.viewModels()
this.runner.service.handler.post {
val now = Date()
val data = list.players
val oldTargeting = vm.targeting.value.orEmpty()
val newTargeting = data
.asSequence()
.filter { new -> oldTargeting.all { current -> current.player.name != new.name && current.player.homeWorld != new.homeWorld } }
.map { TargetingPlayer(it, true, now) }
val finalTargeting = oldTargeting
.asSequence()
.map {
var timestamp = it.timestamp
val current = data.any { dataPlayer -> dataPlayer.name == it.player.name && dataPlayer.homeWorld == it.player.homeWorld }
if (current || it.current) {
timestamp = now
}
TargetingPlayer(it.player, current, timestamp)
}
.plus(newTargeting)
.sortedByDescending { it.current }
.sortedByDescending { it.timestamp }
vm.targeting.value = finalTargeting.toList()
}
}
else -> {
// TODO: other player list types
}

View File

@ -204,6 +204,7 @@ class MainActivity : AppCompatActivity(), AlertDialogFragment.DialogHost, EditTe
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,
),

View File

@ -104,3 +104,33 @@ class ClientChannel(private val channel: InputChannel) {
return buf
}
}
class ClientPreferences(private val preferences: Map<ClientPreference, Any>) {
fun encode(): ByteArray {
val packer = MessagePack.newDefaultBufferPacker()
packer.packArrayHeader(1)
packer.packMapHeader(this.preferences.size)
for (entry in this.preferences) {
packer.packByte(entry.key.code.toByte())
packer.packBoolean(entry.value == true) // TODO: fix
}
packer.close()
val bytes = packer.toByteArray()
val buf = ByteArray(1 + bytes.size)
buf[0] = ClientOperation.Preferences.code
bytes.copyInto(buf, 1)
return buf
}
}
enum class ClientPreference(val code: UByte) {
BacklogNewestMessagesFirst(0u),
TargetingListSupport(1u);
companion object {
private val map = values().associateBy(ClientPreference::code)
fun fromCode(code: UByte) = this.map[code]
}
}

View File

@ -6,7 +6,8 @@ enum class PlayerListType(val code: Byte) {
Party(1),
Friend(2),
Linkshell(3),
CrossLinkshell(4);
CrossLinkshell(4),
Targeting(5);
companion object {
private val map = values().associateBy(PlayerListType::code)

View File

@ -0,0 +1,56 @@
package io.annaclemens.xivchat.ui.targeting
import android.content.Context
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.recyclerview.widget.RecyclerView
import io.annaclemens.xivchat.R
import io.annaclemens.xivchat.databinding.TargetingListElementBinding
import java.text.DateFormat
import java.text.SimpleDateFormat
import java.time.Instant
import java.util.*
class TargetingListAdapter(private val context: Context) : RecyclerView.Adapter<TargetingListAdapter.ViewHolder>() {
inner class ViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
private val binding = TargetingListElementBinding.bind(itemView)
val name = this.binding.targetingName
val timestamp = this.binding.targetingTime
}
private val inflater = LayoutInflater.from(this.context)
private var playerList: List<TargetingPlayer> = emptyList()
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
val itemView = this.inflater.inflate(R.layout.targeting_list_element, parent, false)
return this.ViewHolder(itemView)
}
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
val targeting = this.playerList[position]
// TODO: add send tell context menu?
holder.name.text = targeting.player.name ?: this.context.getString(R.string.friend_list_could_not_retrieve)
holder.timestamp.text = if (targeting.current) {
this.context.getString(R.string.targeting_now)
} else {
if (Instant.now().epochSecond - targeting.timestamp.toInstant().epochSecond > 24 * 60 * 60) {
SimpleDateFormat("dd/MM", Locale.getDefault()).format(targeting.timestamp)
} else {
DateFormat.getTimeInstance(DateFormat.SHORT).format(targeting.timestamp)
}
}
holder.name.isEnabled = targeting.current
holder.timestamp.isEnabled = targeting.current
}
override fun getItemCount() = this.playerList.size
fun setTargetingList(list: List<TargetingPlayer>) {
this.playerList = list
}
}

View File

@ -0,0 +1,46 @@
package io.annaclemens.xivchat.ui.targeting
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.activity.viewModels
import androidx.fragment.app.Fragment
import androidx.recyclerview.widget.LinearLayoutManager
import io.annaclemens.xivchat.databinding.FragmentTargetingBinding
import io.annaclemens.xivchat.util.getApp
class TargetingListFragment : Fragment() {
private lateinit var viewAdapter: TargetingListAdapter
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
val vm: TargetingListViewModel by this.getApp().viewModels()
val binding = FragmentTargetingBinding.inflate(inflater)
this.viewAdapter = TargetingListAdapter(this.requireContext())
binding.targetingList.apply {
this.setHasFixedSize(true)
this.layoutManager = LinearLayoutManager(this.context)
this.adapter = this@TargetingListFragment.viewAdapter
}
this.updateEmptyText(binding, vm.targeting.value.orEmpty().isEmpty())
vm.targeting.observe(this.viewLifecycleOwner) {
this.updateEmptyText(binding, it.orEmpty().isEmpty())
this.viewAdapter.setTargetingList(it.orEmpty())
this.viewAdapter.notifyDataSetChanged()
}
return binding.root
}
private fun updateEmptyText(binding: FragmentTargetingBinding, visible: Boolean) {
binding.textView.visibility = if (visible) {
View.VISIBLE
} else {
View.GONE
}
}
}

View File

@ -0,0 +1,8 @@
package io.annaclemens.xivchat.ui.targeting
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
class TargetingListViewModel : ViewModel() {
val targeting: MutableLiveData<List<TargetingPlayer>?> = MutableLiveData()
}

View File

@ -0,0 +1,10 @@
package io.annaclemens.xivchat.ui.targeting
import io.annaclemens.xivchat.model.message.Player
import java.util.*
data class TargetingPlayer(
val player: Player,
val current: Boolean,
val timestamp: Date,
)

View File

@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#000000"
android:pathData="M12,6c3.79,0 7.17,2.13 8.82,5.5C19.17,14.87 15.79,17 12,17s-7.17,-2.13 -8.82,-5.5C4.83,8.13 8.21,6 12,6m0,-2C7,4 2.73,7.11 1,11.5 2.73,15.89 7,19 12,19s9.27,-3.11 11,-7.5C21.27,7.11 17,4 12,4zM12,9c1.38,0 2.5,1.12 2.5,2.5S13.38,14 12,14s-2.5,-1.12 -2.5,-2.5S10.62,9 12,9m0,-2c-2.48,0 -4.5,2.02 -4.5,4.5S9.52,16 12,16s4.5,-2.02 4.5,-4.5S14.48,7 12,7z" />
</vector>

View File

@ -0,0 +1,19 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent">
<TextView
android:id="@+id/textView"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:text="@string/targeting_empty" />
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/targetingList"
android:layout_width="match_parent"
android:layout_height="match_parent">
</androidx.recyclerview.widget.RecyclerView>
</androidx.coordinatorlayout.widget.CoordinatorLayout>

View File

@ -0,0 +1,28 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center_vertical"
android:layout_marginStart="8dp"
android:layout_marginEnd="8dp">
<TextView
android:id="@+id/targetingName"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_weight="1"
android:ellipsize="end"
android:singleLine="true"
android:textAppearance="@style/TextAppearance.AppCompat.Large"
tools:text="Goat Goatington" />
<TextView
android:id="@+id/targetingTime"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_weight="1"
android:textAlignment="textEnd"
android:textAppearance="@style/TextAppearance.AppCompat.Large"
tools:text="04:45" />
</LinearLayout>

View File

@ -20,6 +20,10 @@
android:id="@+id/nav_friend_list"
android:icon="@drawable/people_black_24dp"
android:title="@string/menu_friend_list" />
<item
android:id="@+id/nav_targeting_list"
android:icon="@drawable/visibility_black_24dp"
android:title="@string/menu_targeting" />
</group>
<group android:id="@+id/menuSettings">
<item

View File

@ -89,6 +89,13 @@
android:name="playerWorld"
app:argType="integer" />
</fragment>
<fragment
android:id="@+id/nav_targeting_list"
android:name="io.annaclemens.xivchat.ui.targeting.TargetingListFragment"
android:label="@string/menu_targeting"
tools:layout="@layout/fragment_targeting" />
<fragment
android:id="@+id/nav_scanned_servers"
android:name="io.annaclemens.xivchat.ui.servers.scan.ScannedServersFragment"

View File

@ -208,4 +208,7 @@
<string name="servers_scan_searching">Searching for servers…</string>
<string name="servers_scan_instructions">Tap on a server below to add it.</string>
<string name="friend_list_could_not_retrieve">(Could not retrieve)</string>
<string name="menu_targeting">Targeting</string>
<string name="targeting_now">Now</string>
<string name="targeting_empty">No one has targeted you since connecting.</string>
</resources>

View File

@ -1,16 +1,16 @@
// Top-level build file where you can add configuration options common to all sub-projects/modules.
buildscript {
ext.kotlin_version = "1.4.30"
ext.kotlin_version = "1.5.20"
ext.ktor_version = '1.4.0'
ext.room_version = '2.2.6'
ext.room_version = '2.3.0'
repositories {
google()
jcenter()
}
dependencies {
classpath 'com.android.tools.build:gradle:4.1.2'
classpath "androidx.navigation:navigation-safe-args-gradle-plugin:2.3.3"
classpath 'com.android.tools.build:gradle:4.2.2'
classpath "androidx.navigation:navigation-safe-args-gradle-plugin:2.3.5"
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
classpath "org.jetbrains.kotlin:kotlin-serialization:$kotlin_version"

View File

@ -3,4 +3,4 @@ distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-6.5-all.zip
distributionUrl=https\://services.gradle.org/distributions/gradle-6.7.1-all.zip