mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-04-20 22:23:37 +00:00
message status updates are coded but not yet tested.
This commit is contained in:
parent
b2d8b30d5b
commit
7506d712ff
9 changed files with 157 additions and 130 deletions
|
|
@ -24,11 +24,33 @@ data class DataPacket(
|
|||
val bytes: ByteArray?,
|
||||
val dataType: Int, // A value such as MeshProtos.Data.Type.OPAQUE_VALUE
|
||||
var from: String? = ID_LOCAL, // a nodeID string, or ID_LOCAL for localhost
|
||||
var rxTime: Long = System.currentTimeMillis(), // msecs since 1970
|
||||
var time: Long = System.currentTimeMillis(), // msecs since 1970
|
||||
var id: Int = 0, // 0 means unassigned
|
||||
var status: MessageStatus? = MessageStatus.UNKNOWN
|
||||
) : Parcelable {
|
||||
|
||||
/**
|
||||
* If there was an error with this message, this string describes what was wrong.
|
||||
*/
|
||||
var errorMessage: String? = null
|
||||
|
||||
/**
|
||||
* Syntactic sugar to make it easy to create text messages
|
||||
*/
|
||||
constructor(to: String? = ID_BROADCAST, text: String) : this(
|
||||
to, text.toByteArray(utf8),
|
||||
MeshProtos.Data.Type.CLEAR_TEXT_VALUE
|
||||
)
|
||||
|
||||
/**
|
||||
* If this is a text message, return the string, otherwise null
|
||||
*/
|
||||
val text: String?
|
||||
get() = if (dataType == MeshProtos.Data.Type.CLEAR_TEXT_VALUE)
|
||||
bytes?.toString(utf8)
|
||||
else
|
||||
null
|
||||
|
||||
// Autogenerated comparision, because we have a byte array
|
||||
|
||||
constructor(parcel: Parcel) : this(
|
||||
|
|
@ -50,7 +72,7 @@ data class DataPacket(
|
|||
|
||||
if (from != other.from) return false
|
||||
if (to != other.to) return false
|
||||
if (rxTime != other.rxTime) return false
|
||||
if (time != other.time) return false
|
||||
if (id != other.id) return false
|
||||
if (dataType != other.dataType) return false
|
||||
if (!bytes!!.contentEquals(other.bytes!!)) return false
|
||||
|
|
@ -62,7 +84,7 @@ data class DataPacket(
|
|||
override fun hashCode(): Int {
|
||||
var result = from.hashCode()
|
||||
result = 31 * result + to.hashCode()
|
||||
result = 31 * result + rxTime.hashCode()
|
||||
result = 31 * result + time.hashCode()
|
||||
result = 31 * result + id
|
||||
result = 31 * result + dataType
|
||||
result = 31 * result + bytes!!.contentHashCode()
|
||||
|
|
@ -75,7 +97,7 @@ data class DataPacket(
|
|||
parcel.writeByteArray(bytes)
|
||||
parcel.writeInt(dataType)
|
||||
parcel.writeString(from)
|
||||
parcel.writeLong(rxTime)
|
||||
parcel.writeLong(time)
|
||||
parcel.writeInt(id)
|
||||
parcel.writeParcelable(status, flags)
|
||||
}
|
||||
|
|
@ -90,7 +112,7 @@ data class DataPacket(
|
|||
parcel.createByteArray()
|
||||
parcel.readInt()
|
||||
from = parcel.readString()
|
||||
rxTime = parcel.readLong()
|
||||
time = parcel.readLong()
|
||||
id = parcel.readInt()
|
||||
status = parcel.readParcelable(MessageStatus::class.java.classLoader)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -31,7 +31,6 @@ import com.geeksville.android.GeeksvilleApplication
|
|||
import com.geeksville.android.Logging
|
||||
import com.geeksville.android.ServiceClient
|
||||
import com.geeksville.mesh.model.Channel
|
||||
import com.geeksville.mesh.model.TextMessage
|
||||
import com.geeksville.mesh.model.UIViewModel
|
||||
import com.geeksville.mesh.service.*
|
||||
import com.geeksville.mesh.ui.*
|
||||
|
|
@ -398,7 +397,7 @@ class MainActivity : AppCompatActivity(), Logging,
|
|||
else -> R.drawable.cloud_off
|
||||
}
|
||||
|
||||
connectStatusImage.setImageDrawable(getDrawable(image))
|
||||
connectStatusImage.setImageResource(image)
|
||||
})
|
||||
|
||||
askToRate()
|
||||
|
|
@ -623,6 +622,15 @@ class MainActivity : AppCompatActivity(), Logging,
|
|||
errormsg("Unhandled dataType ${payload.dataType}")
|
||||
}
|
||||
}
|
||||
|
||||
MeshService.ACTION_MESSAGE_STATUS -> {
|
||||
debug("received message status from service")
|
||||
val id = intent.getIntExtra(EXTRA_PACKET_ID, 0)
|
||||
val status = intent.getParcelableExtra<MessageStatus>(EXTRA_STATUS)
|
||||
|
||||
model.messagesState.updateStatus(id, status)
|
||||
}
|
||||
|
||||
MeshService.ACTION_MESH_CONNECTED -> {
|
||||
val connected =
|
||||
MeshService.ConnectionState.valueOf(
|
||||
|
|
@ -650,10 +658,7 @@ class MainActivity : AppCompatActivity(), Logging,
|
|||
registerMeshReceiver()
|
||||
|
||||
// Init our messages table with the service's record of past text messages
|
||||
model.messagesState.messages.value = service.oldMessages.map {
|
||||
TextMessage(it)
|
||||
}
|
||||
|
||||
model.messagesState.messages.value = service.oldMessages
|
||||
val connectionState =
|
||||
MeshService.ConnectionState.valueOf(service.connectionState())
|
||||
|
||||
|
|
|
|||
|
|
@ -16,7 +16,8 @@ data class MyNodeInfo(
|
|||
val shouldUpdate: Boolean, // this device has old firmware
|
||||
val currentPacketId: Long,
|
||||
val nodeNumBits: Int,
|
||||
val packetIdBits: Int
|
||||
val packetIdBits: Int,
|
||||
val messageTimeoutMsec: Int
|
||||
) : Parcelable {
|
||||
/** A human readable description of the software/hardware version */
|
||||
val firmwareString: String get() = "$model $region/$firmwareVersion"
|
||||
|
|
@ -31,6 +32,7 @@ data class MyNodeInfo(
|
|||
parcel.readByte() != 0.toByte(),
|
||||
parcel.readLong(),
|
||||
parcel.readInt(),
|
||||
parcel.readInt(),
|
||||
parcel.readInt()
|
||||
) {
|
||||
}
|
||||
|
|
@ -46,6 +48,7 @@ data class MyNodeInfo(
|
|||
parcel.writeLong(currentPacketId)
|
||||
parcel.writeInt(nodeNumBits)
|
||||
parcel.writeInt(packetIdBits)
|
||||
parcel.writeInt(messageTimeoutMsec)
|
||||
}
|
||||
|
||||
override fun describeContents(): Int {
|
||||
|
|
@ -61,4 +64,6 @@ data class MyNodeInfo(
|
|||
return arrayOfNulls(size)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
|
@ -6,36 +6,17 @@ import com.geeksville.android.BuildUtils.isEmulator
|
|||
import com.geeksville.android.Logging
|
||||
import com.geeksville.mesh.DataPacket
|
||||
import com.geeksville.mesh.MeshProtos
|
||||
import com.geeksville.mesh.MessageStatus
|
||||
import com.geeksville.mesh.utf8
|
||||
import java.util.*
|
||||
|
||||
/**
|
||||
* the model object for a text message
|
||||
*
|
||||
* if errorMessage is set then we had a problem sending this message
|
||||
*/
|
||||
data class TextMessage(
|
||||
val from: String,
|
||||
val text: String,
|
||||
val date: Date = Date(),
|
||||
val errorMessage: String? = null
|
||||
) {
|
||||
/// We can auto init from data packets
|
||||
constructor(payload: DataPacket) : this(
|
||||
payload.from!!,
|
||||
payload.bytes!!.toString(utf8),
|
||||
date = Date(payload.rxTime)
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
class MessagesState(private val ui: UIViewModel) : Logging {
|
||||
private val testTexts = listOf(
|
||||
TextMessage(
|
||||
DataPacket(
|
||||
"+16508765310",
|
||||
"I found the cache"
|
||||
),
|
||||
TextMessage(
|
||||
DataPacket(
|
||||
"+16508765311",
|
||||
"Help! I've fallen and I can't get up."
|
||||
)
|
||||
|
|
@ -44,44 +25,47 @@ class MessagesState(private val ui: UIViewModel) : Logging {
|
|||
// If the following (unused otherwise) line is commented out, the IDE preview window works.
|
||||
// if left in the preview always renders as empty.
|
||||
val messages =
|
||||
object : MutableLiveData<List<TextMessage>>(if (isEmulator) testTexts else listOf()) {
|
||||
object : MutableLiveData<List<DataPacket>>(if (isEmulator) testTexts else listOf()) {
|
||||
|
||||
}
|
||||
|
||||
/// add a message our GUI list of past msgs
|
||||
private fun addMessage(m: TextMessage) {
|
||||
fun addMessage(m: DataPacket) {
|
||||
// FIXME - don't just slam in a new list each time, it probably causes extra drawing.
|
||||
messages.value = messages.value!! + m
|
||||
}
|
||||
|
||||
/// Add a message that was encapsulated in a data packet
|
||||
fun addMessage(payload: DataPacket) = addMessage(TextMessage(payload))
|
||||
fun updateStatus(id: Int, status: MessageStatus) {
|
||||
// Super inefficent but this is rare
|
||||
val msgs = messages.value!!
|
||||
|
||||
msgs.find { it.id == id }?.let { p ->
|
||||
if (p.status != status) {
|
||||
p.status = status
|
||||
// Trigger an expensive complete redraw FIXME
|
||||
messages.value = msgs
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Send a message and added it to our GUI log
|
||||
fun sendMessage(str: String, dest: String = DataPacket.ID_BROADCAST) {
|
||||
var error: String? = null
|
||||
|
||||
val service = ui.meshService
|
||||
val p = DataPacket(
|
||||
dest,
|
||||
str.toByteArray(utf8),
|
||||
MeshProtos.Data.Type.CLEAR_TEXT_VALUE
|
||||
)
|
||||
if (service != null)
|
||||
try {
|
||||
service.send(
|
||||
DataPacket(
|
||||
dest,
|
||||
str.toByteArray(utf8),
|
||||
MeshProtos.Data.Type.CLEAR_TEXT_VALUE
|
||||
)
|
||||
)
|
||||
service.send(p)
|
||||
} catch (ex: RemoteException) {
|
||||
error = "Error: ${ex.message}"
|
||||
p.errorMessage = "Error: ${ex.message}"
|
||||
}
|
||||
else
|
||||
error = "Error: No Mesh service"
|
||||
p.errorMessage = "Error: No Mesh service"
|
||||
|
||||
addMessage(
|
||||
TextMessage(
|
||||
ui.nodeDB.myId.value!!,
|
||||
str,
|
||||
errorMessage = error
|
||||
)
|
||||
)
|
||||
addMessage(p)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,3 +15,5 @@ const val EXTRA_PERMANENT = "$prefix.Permanent"
|
|||
|
||||
const val EXTRA_PAYLOAD = "$prefix.Payload"
|
||||
const val EXTRA_NODEINFO = "$prefix.NodeInfo"
|
||||
const val EXTRA_PACKET_ID = "$prefix.PacketId"
|
||||
const val EXTRA_STATUS = "$prefix.Status"
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import android.content.IntentFilter
|
|||
import android.graphics.Color
|
||||
import android.os.Build
|
||||
import android.os.IBinder
|
||||
import android.os.Parcelable
|
||||
import android.os.RemoteException
|
||||
import android.widget.Toast
|
||||
import androidx.annotation.RequiresApi
|
||||
|
|
@ -52,6 +53,7 @@ class MeshService : Service(), Logging {
|
|||
const val ACTION_RECEIVED_DATA = "$prefix.RECEIVED_DATA"
|
||||
const val ACTION_NODE_CHANGE = "$prefix.NODE_CHANGE"
|
||||
const val ACTION_MESH_CONNECTED = "$prefix.MESH_CONNECTED"
|
||||
const val ACTION_MESSAGE_STATUS = "$prefix.MESSAGE_STATUS"
|
||||
|
||||
class IdNotFoundException(id: String) : Exception("ID not found $id")
|
||||
class NodeNumNotFoundException(id: Int) : Exception("NodeNum not found $id")
|
||||
|
|
@ -292,6 +294,19 @@ class MeshService : Service(), Logging {
|
|||
explicitBroadcast(intent)
|
||||
}
|
||||
|
||||
private fun broadcastMessageStatus(p: DataPacket) {
|
||||
if (p.id == 0) {
|
||||
debug("Ignoring anonymous packet status")
|
||||
} else {
|
||||
debug("Broadcasting message status $p")
|
||||
val intent = Intent(ACTION_MESSAGE_STATUS)
|
||||
|
||||
intent.putExtra(EXTRA_PACKET_ID, p.id)
|
||||
intent.putExtra(EXTRA_STATUS, p.status as Parcelable)
|
||||
explicitBroadcast(intent)
|
||||
}
|
||||
}
|
||||
|
||||
/// Safely access the radio service, if not connected an exception will be thrown
|
||||
private val connectedRadio: IRadioInterfaceService
|
||||
get() = (if (connectionState == ConnectionState.CONNECTED) radio.serviceP else null)
|
||||
|
|
@ -703,7 +718,7 @@ class MeshService : Service(), Logging {
|
|||
DataPacket(
|
||||
from = fromId,
|
||||
to = toId,
|
||||
rxTime = rxTime * 1000L,
|
||||
time = rxTime * 1000L,
|
||||
id = packet.id,
|
||||
dataType = data.typValue,
|
||||
bytes = bytes
|
||||
|
|
@ -800,6 +815,9 @@ class MeshService : Service(), Logging {
|
|||
/// If apps try to send packets when our radio is sleeping, we queue them here instead
|
||||
private val offlineSentPackets = mutableListOf<DataPacket>()
|
||||
|
||||
/** Keep a record of recently sent packets, so we can properly handle ack/nak */
|
||||
private val sentPackets = mutableMapOf<Int, DataPacket>()
|
||||
|
||||
/// Update our model and resend as needed for a MeshPacket we just received from the radio
|
||||
private fun handleReceivedMeshPacket(packet: MeshPacket) {
|
||||
if (haveNodeDB) {
|
||||
|
|
@ -820,12 +838,25 @@ class MeshService : Service(), Logging {
|
|||
// encapsulate our payload in the proper protobufs and fire it off
|
||||
val packet = toMeshPacket(p)
|
||||
p.status = MessageStatus.ENROUTE
|
||||
p.time =
|
||||
System.currentTimeMillis() // update time to the actual time we started sending
|
||||
sendToRadio(packet)
|
||||
broadcastMessageStatus(p)
|
||||
}
|
||||
offlineSentPackets.clear()
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Handle an ack/nak packet by updating sent message status
|
||||
*/
|
||||
private fun handleAckNak(isAck: Boolean, id: Int) {
|
||||
sentPackets.remove(id)?.let { p ->
|
||||
p.status = if (isAck) MessageStatus.DELIVERED else MessageStatus.ERROR
|
||||
broadcastMessageStatus(p)
|
||||
}
|
||||
}
|
||||
|
||||
/// Update our model and resend as needed for a MeshPacket we just received from the radio
|
||||
private fun processReceivedMeshPacket(packet: MeshPacket) {
|
||||
val fromNum = packet.from
|
||||
|
|
@ -859,6 +890,12 @@ class MeshService : Service(), Logging {
|
|||
|
||||
if (p.hasUser())
|
||||
handleReceivedUser(fromNum, p.user)
|
||||
|
||||
if (p.successId != 0)
|
||||
handleAckNak(true, p.successId)
|
||||
|
||||
if (p.failId != 0)
|
||||
handleAckNak(false, p.failId)
|
||||
}
|
||||
|
||||
private fun currentSecond() = (System.currentTimeMillis() / 1000).toInt()
|
||||
|
|
@ -1169,8 +1206,9 @@ class MeshService : Service(), Logging {
|
|||
firmwareUpdateFilename != null,
|
||||
SoftwareUpdateService.shouldUpdate(this@MeshService, firmwareVersion),
|
||||
currentPacketId.toLong() and 0xffffffffL,
|
||||
nodeNumBits,
|
||||
packetIdBits
|
||||
if (nodeNumBits == 0) 8 else nodeNumBits,
|
||||
if (packetIdBits == 0) 8 else packetIdBits,
|
||||
if (messageTimeoutMsec == 0) 5 * 60 * 1000 else messageTimeoutMsec // constants from current device code
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -1377,7 +1415,22 @@ class MeshService : Service(), Logging {
|
|||
}
|
||||
}
|
||||
|
||||
private
|
||||
/**
|
||||
* Remove any sent packets that have been sitting around too long
|
||||
*
|
||||
* Note: we give each message what the timeout the device code is using, though in the normal
|
||||
* case the device will fail after 3 retries much sooner than that (and it will provide a nak to us)
|
||||
*/
|
||||
private fun deleteOldPackets() {
|
||||
myNodeInfo?.apply {
|
||||
val now = System.currentTimeMillis()
|
||||
sentPackets.values.forEach { p ->
|
||||
if (p.status == MessageStatus.ENROUTE && p.time + messageTimeoutMsec < now)
|
||||
handleAckNak(false, p.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val binder = object : IMeshService.Stub() {
|
||||
|
||||
override fun setDeviceAddress(deviceAddr: String?) = toRemoteExceptions {
|
||||
|
|
@ -1416,8 +1469,6 @@ class MeshService : Service(), Logging {
|
|||
p: DataPacket
|
||||
) {
|
||||
toRemoteExceptions {
|
||||
info("sendData dest=${p.to} <- ${p.bytes!!.size} bytes (connectionState=$connectionState)")
|
||||
|
||||
// Init from and id
|
||||
myNodeID?.let { myId ->
|
||||
if (p.from == DataPacket.ID_LOCAL)
|
||||
|
|
@ -1427,10 +1478,16 @@ class MeshService : Service(), Logging {
|
|||
p.id = generatePacketId()
|
||||
}
|
||||
|
||||
info("sendData dest=${p.to}, id=${p.id} <- ${p.bytes!!.size} bytes (connectionState=$connectionState)")
|
||||
|
||||
// Keep a record of datapackets, so GUIs can show proper chat history
|
||||
rememberDataPacket(p)
|
||||
|
||||
if (p.id != 0) { // If we have an ID we can wait for an ack or nak
|
||||
deleteOldPackets()
|
||||
sentPackets[p.id] = p
|
||||
}
|
||||
|
||||
// If radio is sleeping, queue the packet
|
||||
when (connectionState) {
|
||||
ConnectionState.DEVICE_SLEEP -> {
|
||||
|
|
|
|||
|
|
@ -4,67 +4,3 @@ import com.geeksville.android.Logging
|
|||
|
||||
|
||||
object UILog : Logging
|
||||
/*
|
||||
val palette = lightColorPalette() // darkColorPalette()
|
||||
|
||||
|
||||
@Composable
|
||||
fun MeshApp() {
|
||||
val (drawerState, onDrawerStateChange) = state { DrawerState.Closed }
|
||||
|
||||
MaterialTheme(colors = palette) {
|
||||
ModalDrawerLayout(
|
||||
drawerState = drawerState,
|
||||
onStateChange = onDrawerStateChange,
|
||||
gesturesEnabled = drawerState == DrawerState.Opened,
|
||||
drawerContent = {
|
||||
|
||||
AppDrawer(
|
||||
currentScreen = AppStatus.currentScreen,
|
||||
closeDrawer = { onDrawerStateChange(DrawerState.Closed) }
|
||||
)
|
||||
|
||||
}, bodyContent = { AppContent { onDrawerStateChange(DrawerState.Opened) } })
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun previewView() {
|
||||
// It seems modaldrawerlayout not yet supported in preview
|
||||
MaterialTheme(colors = palette) {
|
||||
UsersContent()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@Composable
|
||||
private fun AppContent(openDrawer: () -> Unit) {
|
||||
// crossfade breaks onCommit behavior because it keeps old views around
|
||||
//Crossfade(AppStatus.currentScreen) { screen ->
|
||||
//Surface(color = (MaterialTheme.colors()).background) {
|
||||
|
||||
Scaffold(topAppBar = {
|
||||
TopAppBar(
|
||||
title = { Text(text = "Meshtastic") },
|
||||
navigationIcon = {
|
||||
//Container(LayoutSize(40.dp, 40.dp)) {
|
||||
VectorImageButton(R.drawable.ic_launcher_new_foreground) {
|
||||
openDrawer()
|
||||
}
|
||||
//}
|
||||
}
|
||||
)
|
||||
}) {
|
||||
when (AppStatus.currentScreen) {
|
||||
Screen.messages -> MessagesContent()
|
||||
Screen.settings -> SettingsContent()
|
||||
Screen.users -> UsersContent()
|
||||
Screen.channel -> ChannelContent(UIState.getChannel())
|
||||
Screen.map -> MapContent()
|
||||
else -> TODO()
|
||||
}
|
||||
}
|
||||
//}
|
||||
}
|
||||
*/
|
||||
|
|
@ -13,14 +13,16 @@ import androidx.lifecycle.Observer
|
|||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.geeksville.android.Logging
|
||||
import com.geeksville.mesh.DataPacket
|
||||
import com.geeksville.mesh.MessageStatus
|
||||
import com.geeksville.mesh.R
|
||||
import com.geeksville.mesh.model.TextMessage
|
||||
import com.geeksville.mesh.model.UIViewModel
|
||||
import com.geeksville.mesh.service.MeshService
|
||||
import com.google.android.material.chip.Chip
|
||||
import kotlinx.android.synthetic.main.adapter_message_layout.view.*
|
||||
import kotlinx.android.synthetic.main.messages_fragment.*
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.*
|
||||
|
||||
// Allows usage like email.on(EditorInfo.IME_ACTION_NEXT, { confirm() })
|
||||
fun EditText.on(actionId: Int, func: () -> Unit) {
|
||||
|
|
@ -129,13 +131,27 @@ class MessagesFragment : ScreenFragment("Messages"), Logging {
|
|||
holder.messageText.text = msg.text
|
||||
}
|
||||
|
||||
holder.messageTime.text = dateFormat.format(msg.date)
|
||||
holder.messageTime.text = dateFormat.format(Date(msg.time))
|
||||
|
||||
val icon = when (msg.status) {
|
||||
MessageStatus.QUEUED -> R.drawable.ic_twotone_cloud_upload_24
|
||||
MessageStatus.DELIVERED -> R.drawable.cloud_on
|
||||
MessageStatus.ENROUTE -> R.drawable.ic_twotone_cloud_24
|
||||
MessageStatus.ERROR -> R.drawable.cloud_off
|
||||
else -> null
|
||||
}
|
||||
|
||||
if (icon != null) {
|
||||
holder.messageStatusIcon.setImageResource(icon)
|
||||
holder.messageStatusIcon.visibility = View.VISIBLE
|
||||
} else
|
||||
holder.messageStatusIcon.visibility = View.INVISIBLE
|
||||
}
|
||||
|
||||
private var messages = arrayOf<TextMessage>()
|
||||
private var messages = arrayOf<DataPacket>()
|
||||
|
||||
/// Called when our node DB changes
|
||||
fun onMessagesChanged(nodesIn: Collection<TextMessage>) {
|
||||
fun onMessagesChanged(nodesIn: Collection<DataPacket>) {
|
||||
messages = nodesIn.toTypedArray()
|
||||
notifyDataSetChanged() // FIXME, this is super expensive and redraws all messages
|
||||
|
||||
|
|
|
|||
|
|
@ -1 +1 @@
|
|||
Subproject commit d624f8cb17a2f723a51649d442791b195a18604a
|
||||
Subproject commit e9c7f9b95d490aea3f0f213d4666d2dbf7e2111c
|
||||
Loading…
Add table
Add a link
Reference in a new issue