kopia lustrzana https://github.com/meshtastic/Meshtastic-Android
backend updated to reneable firmware update
rodzic
a031a551c5
commit
6576f5eab5
|
@ -4,6 +4,7 @@ package com.geeksville.mesh;
|
|||
// Declare any non-default types here with import statements
|
||||
parcelable DataPacket;
|
||||
parcelable NodeInfo;
|
||||
parcelable MyNodeInfo;
|
||||
|
||||
/**
|
||||
* Note - these calls might throw RemoteException to indicate mesh error states
|
||||
|
@ -62,6 +63,17 @@ interface IMeshService {
|
|||
/// Users should not call this directly, only used internally by the MeshUtil activity
|
||||
void setDeviceAddress(String deviceAddr);
|
||||
|
||||
/// Get basic device hardware info about our connected radio
|
||||
MyNodeInfo getMyNodeInfo();
|
||||
|
||||
/// Start updating the radios firmware
|
||||
void startFirmwareUpdate();
|
||||
|
||||
/**
|
||||
Return a number 0-100 for progress. -1 for completed and success, -2 for failure
|
||||
*/
|
||||
int getUpdateStatus();
|
||||
|
||||
// see com.geeksville.com.geeksville.mesh broadcast intents
|
||||
// RECEIVED_OPAQUE for data received from other nodes. payload will contain a DataPacket
|
||||
// NODE_CHANGE for new IDs appearing or disappearing
|
||||
|
|
|
@ -0,0 +1,52 @@
|
|||
package com.geeksville.mesh
|
||||
|
||||
import android.os.Parcel
|
||||
import android.os.Parcelable
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
// MyNodeInfo sent via special protobuf from radio
|
||||
@Serializable
|
||||
data class MyNodeInfo(
|
||||
val myNodeNum: Int,
|
||||
val hasGPS: Boolean,
|
||||
val region: String?,
|
||||
val model: String?,
|
||||
val firmwareVersion: String?,
|
||||
val couldUpdate: Boolean, // this application contains a software load we _could_ install if you want
|
||||
val shouldUpdate: Boolean // this device has old firmware
|
||||
) : Parcelable {
|
||||
constructor(parcel: Parcel) : this(
|
||||
parcel.readInt(),
|
||||
parcel.readByte() != 0.toByte(),
|
||||
parcel.readString(),
|
||||
parcel.readString(),
|
||||
parcel.readString(),
|
||||
parcel.readByte() != 0.toByte(),
|
||||
parcel.readByte() != 0.toByte()
|
||||
) {
|
||||
}
|
||||
|
||||
override fun writeToParcel(parcel: Parcel, flags: Int) {
|
||||
parcel.writeInt(myNodeNum)
|
||||
parcel.writeByte(if (hasGPS) 1 else 0)
|
||||
parcel.writeString(region)
|
||||
parcel.writeString(model)
|
||||
parcel.writeString(firmwareVersion)
|
||||
parcel.writeByte(if (couldUpdate) 1 else 0)
|
||||
parcel.writeByte(if (shouldUpdate) 1 else 0)
|
||||
}
|
||||
|
||||
override fun describeContents(): Int {
|
||||
return 0
|
||||
}
|
||||
|
||||
companion object CREATOR : Parcelable.Creator<MyNodeInfo> {
|
||||
override fun createFromParcel(parcel: Parcel): MyNodeInfo {
|
||||
return MyNodeInfo(parcel)
|
||||
}
|
||||
|
||||
override fun newArray(size: Int): Array<MyNodeInfo?> {
|
||||
return arrayOfNulls(size)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -474,16 +474,6 @@ class MeshService : Service(), Logging {
|
|||
/// special broadcast address
|
||||
val NODENUM_BROADCAST = 255
|
||||
|
||||
// MyNodeInfo sent via special protobuf from radio
|
||||
@Serializable
|
||||
data class MyNodeInfo(
|
||||
val myNodeNum: Int,
|
||||
val hasGPS: Boolean,
|
||||
val region: String,
|
||||
val model: String,
|
||||
val firmwareVersion: String
|
||||
)
|
||||
|
||||
/// Our saved preferences as stored on disk
|
||||
@Serializable
|
||||
private data class SavedSettings(
|
||||
|
@ -862,35 +852,10 @@ class MeshService : Service(), Logging {
|
|||
connectedRadio.readMyNode()
|
||||
)
|
||||
|
||||
val mi = with(myInfo) {
|
||||
MyNodeInfo(myNodeNum, hasGps, region, hwModel, firmwareVersion)
|
||||
}
|
||||
|
||||
myNodeInfo = mi
|
||||
handleMyInfo(myInfo)
|
||||
|
||||
radioConfig = MeshProtos.RadioConfig.parseFrom(connectedRadio.readRadioConfig())
|
||||
|
||||
/// Track types of devices and firmware versions in use
|
||||
GeeksvilleApplication.analytics.setUserInfo(
|
||||
DataPair("region", mi.region),
|
||||
DataPair("firmware", mi.firmwareVersion),
|
||||
DataPair("has_gps", mi.hasGPS),
|
||||
DataPair("hw_model", mi.model),
|
||||
DataPair("dev_error_count", myInfo.errorCount)
|
||||
)
|
||||
|
||||
if (myInfo.errorCode != 0) {
|
||||
GeeksvilleApplication.analytics.track(
|
||||
"dev_error",
|
||||
DataPair("code", myInfo.errorCode),
|
||||
DataPair("address", myInfo.errorAddress),
|
||||
|
||||
// We also include this info, because it is required to correctly decode address from the map file
|
||||
DataPair("firmware", mi.firmwareVersion),
|
||||
DataPair("hw_model", mi.model),
|
||||
DataPair("region", mi.region)
|
||||
)
|
||||
}
|
||||
|
||||
// Ask for the current node DB
|
||||
connectedRadio.restartNodeInfo()
|
||||
|
@ -1168,7 +1133,15 @@ class MeshService : Service(), Logging {
|
|||
|
||||
private fun handleMyInfo(myInfo: MeshProtos.MyNodeInfo) {
|
||||
val mi = with(myInfo) {
|
||||
MyNodeInfo(myNodeNum, hasGps, region, hwModel, firmwareVersion)
|
||||
MyNodeInfo(
|
||||
myNodeNum,
|
||||
hasGps,
|
||||
region,
|
||||
hwModel,
|
||||
firmwareVersion,
|
||||
false,
|
||||
false
|
||||
)
|
||||
}
|
||||
|
||||
newMyNodeInfo = mi
|
||||
|
@ -1311,7 +1284,41 @@ class MeshService : Service(), Logging {
|
|||
|
||||
}
|
||||
|
||||
private val binder = object : IMeshService.Stub() {
|
||||
/***
|
||||
* Return the filename we will install on the device
|
||||
*/
|
||||
val firmwareUpdateFilename: String?
|
||||
get() =
|
||||
try {
|
||||
myNodeInfo?.let {
|
||||
if (it.region != null && it.firmwareVersion != null && it.model != null)
|
||||
SoftwareUpdateService.getUpdateFilename(
|
||||
this,
|
||||
it.region,
|
||||
it.firmwareVersion,
|
||||
it.model
|
||||
)
|
||||
else
|
||||
null
|
||||
}
|
||||
} catch (ex: Exception) {
|
||||
errormsg("Unable to update", ex)
|
||||
null
|
||||
}
|
||||
|
||||
private fun doFirmwareUpdate() {
|
||||
// Run in the IO thread
|
||||
val filename = firmwareUpdateFilename ?: throw Exception("No update filename")
|
||||
val safe =
|
||||
RadioInterfaceService.safe ?: throw Exception("Can't update - no bluetooth connected")
|
||||
|
||||
serviceScope.handledLaunch {
|
||||
SoftwareUpdateService.doUpdate(this@MeshService, safe, filename)
|
||||
}
|
||||
}
|
||||
|
||||
private
|
||||
val binder = object : IMeshService.Stub() {
|
||||
|
||||
override fun setDeviceAddress(deviceAddr: String?) = toRemoteExceptions {
|
||||
debug("Passing through device change to radio service: $deviceAddr")
|
||||
|
@ -1330,6 +1337,14 @@ class MeshService : Service(), Logging {
|
|||
return recentDataPackets
|
||||
}
|
||||
|
||||
override fun getUpdateStatus(): Int = SoftwareUpdateService.progress
|
||||
|
||||
override fun startFirmwareUpdate() = toRemoteExceptions {
|
||||
doFirmwareUpdate()
|
||||
}
|
||||
|
||||
override fun getMyNodeInfo(): MyNodeInfo? = this@MeshService.myNodeInfo
|
||||
|
||||
override fun getMyId() = toRemoteExceptions { myNodeID }
|
||||
|
||||
override fun setOwner(myId: String?, longName: String, shortName: String) =
|
||||
|
@ -1337,7 +1352,11 @@ class MeshService : Service(), Logging {
|
|||
this@MeshService.setOwner(myId, longName, shortName)
|
||||
}
|
||||
|
||||
override fun sendData(destId: String?, payloadIn: ByteArray, typ: Int): Boolean =
|
||||
override fun sendData(
|
||||
destId: String?,
|
||||
payloadIn: ByteArray,
|
||||
typ: Int
|
||||
): Boolean =
|
||||
toRemoteExceptions {
|
||||
info("sendData dest=$destId <- ${payloadIn.size} bytes (connectionState=$connectionState)")
|
||||
|
||||
|
@ -1376,7 +1395,8 @@ class MeshService : Service(), Logging {
|
|||
}
|
||||
|
||||
override fun getRadioConfig(): ByteArray = toRemoteExceptions {
|
||||
this@MeshService.radioConfig?.toByteArray() ?: throw RadioNotConnectedException()
|
||||
this@MeshService.radioConfig?.toByteArray()
|
||||
?: throw RadioNotConnectedException()
|
||||
}
|
||||
|
||||
override fun setRadioConfig(payload: ByteArray) = toRemoteExceptions {
|
||||
|
|
|
@ -208,12 +208,15 @@ class RadioInterfaceService : Service(), Logging {
|
|||
warn("CompanionDevice API not available, falling back to classic scan")
|
||||
false
|
||||
} */
|
||||
|
||||
/**
|
||||
* this is created in onCreate()
|
||||
* We do an ugly hack of keeping it in the singleton so we can share it for the rare software update case
|
||||
*/
|
||||
var safe: SafeBluetooth? = null
|
||||
}
|
||||
|
||||
|
||||
// Both of these are created in onCreate()
|
||||
private var safe: SafeBluetooth? = null
|
||||
|
||||
/// Our BLE device
|
||||
val device get() = safe!!.gatt!!
|
||||
|
||||
|
|
|
@ -27,13 +27,14 @@ import java.util.zip.CRC32
|
|||
*/
|
||||
class SoftwareUpdateService : JobIntentService(), Logging {
|
||||
|
||||
|
||||
private val bluetoothAdapter: BluetoothAdapter by lazy(LazyThreadSafetyMode.NONE) {
|
||||
val bluetoothManager = getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager
|
||||
bluetoothManager.adapter!!
|
||||
}
|
||||
|
||||
|
||||
fun startUpdate(macaddr: String) {
|
||||
private fun startUpdate(macaddr: String) {
|
||||
info("starting update to $macaddr")
|
||||
|
||||
val device = bluetoothAdapter.getRemoteDevice(macaddr)
|
||||
|
@ -51,121 +52,17 @@ class SoftwareUpdateService : JobIntentService(), Logging {
|
|||
|
||||
sync.discoverServices() // Get our services
|
||||
|
||||
val service = sync.gatt!!.services.find { it.uuid == SW_UPDATE_UUID }!!
|
||||
|
||||
fun doFirmwareUpdate(assetName: String) {
|
||||
|
||||
info("Starting firmware update for $assetName")
|
||||
|
||||
val totalSizeDesc = service.getCharacteristic(SW_UPDATE_TOTALSIZE_CHARACTER)
|
||||
val dataDesc = service.getCharacteristic(SW_UPDATE_DATA_CHARACTER)
|
||||
val crc32Desc = service.getCharacteristic(SW_UPDATE_CRC32_CHARACTER)
|
||||
val updateResultDesc = service.getCharacteristic(SW_UPDATE_RESULT_CHARACTER)
|
||||
|
||||
assets.open(assetName).use { firmwareStream ->
|
||||
val firmwareCrc = CRC32()
|
||||
var firmwareNumSent = 0
|
||||
val firmwareSize = firmwareStream.available()
|
||||
|
||||
// Start the update by writing the # of bytes in the image
|
||||
logAssert(
|
||||
totalSizeDesc.setValue(
|
||||
firmwareSize,
|
||||
BluetoothGattCharacteristic.FORMAT_UINT32,
|
||||
0
|
||||
)
|
||||
)
|
||||
sync.writeCharacteristic(totalSizeDesc)
|
||||
|
||||
// Our write completed, queue up a readback
|
||||
val totalSizeReadback = sync.readCharacteristic(totalSizeDesc)
|
||||
.getIntValue(BluetoothGattCharacteristic.FORMAT_UINT32, 0)
|
||||
if (totalSizeReadback == 0) // FIXME - handle this case
|
||||
throw Exception("Device rejected file size")
|
||||
|
||||
// Send all the blocks
|
||||
while (firmwareNumSent < firmwareSize) {
|
||||
debug("sending block ${firmwareNumSent * 100 / firmwareSize}%")
|
||||
var blockSize = 512 - 3 // Max size MTU excluding framing
|
||||
|
||||
if (blockSize > firmwareStream.available())
|
||||
blockSize = firmwareStream.available()
|
||||
val buffer = ByteArray(blockSize)
|
||||
|
||||
// slightly expensive to keep reallocing this buffer, but whatever
|
||||
logAssert(firmwareStream.read(buffer) == blockSize)
|
||||
firmwareCrc.update(buffer)
|
||||
|
||||
// updateGatt.beginReliableWrite()
|
||||
dataDesc.value = buffer
|
||||
sync.writeCharacteristic(dataDesc)
|
||||
firmwareNumSent += blockSize
|
||||
}
|
||||
|
||||
// We have finished sending all our blocks, so post the CRC so our state machine can advance
|
||||
val c = firmwareCrc.value
|
||||
info("Sent all blocks, crc is $c")
|
||||
logAssert(
|
||||
crc32Desc.setValue(
|
||||
c.toInt(),
|
||||
BluetoothGattCharacteristic.FORMAT_UINT32,
|
||||
0
|
||||
)
|
||||
)
|
||||
sync.writeCharacteristic(crc32Desc)
|
||||
|
||||
// we just read the update result if !0 we have an error
|
||||
val updateResult =
|
||||
sync.readCharacteristic(updateResultDesc)
|
||||
.getIntValue(BluetoothGattCharacteristic.FORMAT_UINT8, 0)
|
||||
if (updateResult != 0) // FIXME - handle this case
|
||||
throw Exception("Device update failed, reason=$updateResult")
|
||||
|
||||
// FIXME perhaps ask device to reboot
|
||||
}
|
||||
}
|
||||
|
||||
/// Return the filename this device needs to use as an update (or null if no update needed)
|
||||
fun getUpdateFilename(): String? {
|
||||
val hwVerDesc = service.getCharacteristic(HW_VERSION_CHARACTER)
|
||||
val mfgDesc = service.getCharacteristic(MANUFACTURE_CHARACTER)
|
||||
val swVerDesc = service.getCharacteristic(SW_VERSION_CHARACTER)
|
||||
|
||||
// looks like 1.0-US
|
||||
val hwVer = sync.readCharacteristic(hwVerDesc).getStringValue(0)
|
||||
|
||||
// looks like HELTEC
|
||||
val mfg = sync.readCharacteristic(mfgDesc).getStringValue(0)
|
||||
|
||||
// looks like 0.0.12
|
||||
val swVer = sync.readCharacteristic(swVerDesc).getStringValue(0)
|
||||
|
||||
val curver = getString(R.string.cur_firmware_version)
|
||||
|
||||
// FIXME, instead compare version strings carefully to see if less than
|
||||
val needsUpdate = (curver != swVer)
|
||||
|
||||
return if (!needsUpdate)
|
||||
null
|
||||
else {
|
||||
val regionRegex = Regex(".+-(.+)")
|
||||
val (region) = regionRegex.find(hwVer)?.destructured
|
||||
?: throw Exception("Malformed hw version")
|
||||
|
||||
"firmware/firmware-$mfg-$region-$curver.bin"
|
||||
}
|
||||
}
|
||||
|
||||
val updateFilename = getUpdateFilename()
|
||||
val updateFilename = getUpdateFilename(this, sync)
|
||||
if (updateFilename != null) {
|
||||
doFirmwareUpdate(updateFilename)
|
||||
doUpdate(this, sync, updateFilename)
|
||||
} else
|
||||
warn("Device is already up-to-date no update needed.")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
override fun onHandleWork(intent: Intent) { // We have received work to do. The system or framework is already
|
||||
override fun onHandleWork(intent: Intent) {
|
||||
// We have received work to do. The system or framework is already
|
||||
// holding a wake lock for us at this point, so we can just go.
|
||||
|
||||
// Report failures but do not crash the app
|
||||
|
@ -182,7 +79,7 @@ class SoftwareUpdateService : JobIntentService(), Logging {
|
|||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
companion object : Logging {
|
||||
/**
|
||||
* Unique job ID for this service. Must be the same for all work.
|
||||
*/
|
||||
|
@ -218,6 +115,11 @@ class SoftwareUpdateService : JobIntentService(), Logging {
|
|||
private val MANUFACTURE_CHARACTER = longBLEUUID("2a29")
|
||||
private val HW_VERSION_CHARACTER = longBLEUUID("2a27")
|
||||
|
||||
/**
|
||||
* % progress through the update
|
||||
*/
|
||||
var progress = 0
|
||||
|
||||
/**
|
||||
* Convenience method for enqueuing work in to this service.
|
||||
*/
|
||||
|
@ -228,5 +130,146 @@ class SoftwareUpdateService : JobIntentService(), Logging {
|
|||
JOB_ID, work
|
||||
)
|
||||
}
|
||||
|
||||
/** Return true if we thing the firmwarte shoulde be updated
|
||||
*/
|
||||
fun shouldUpdate(
|
||||
context: Context,
|
||||
swVer: String
|
||||
): Boolean {
|
||||
val curver = context.getString(R.string.cur_firmware_version)
|
||||
|
||||
// If the user is running a development build we never do an automatic update
|
||||
val isDevBuild = swVer.isEmpty() || swVer == "unset"
|
||||
|
||||
// FIXME, instead compare version strings carefully to see if less than
|
||||
val needsUpdate = (curver != swVer)
|
||||
|
||||
return needsUpdate && !isDevBuild
|
||||
}
|
||||
|
||||
/** Return the filename this device needs to use as an update (or null if no update needed)
|
||||
*/
|
||||
fun getUpdateFilename(
|
||||
context: Context,
|
||||
hwVerIn: String,
|
||||
swVer: String,
|
||||
mfg: String
|
||||
): String {
|
||||
val curver = context.getString(R.string.cur_firmware_version)
|
||||
|
||||
val regionRegex = Regex(".+-(.+)")
|
||||
|
||||
val hwVer = if (hwVerIn.isEmpty() || hwVerIn == "unset")
|
||||
"1.0-US" else hwVerIn // lie and claim US, because that's what the device load will be doing without hwVer set
|
||||
|
||||
val (region) = regionRegex.find(hwVer)?.destructured
|
||||
?: throw Exception("Malformed hw version")
|
||||
|
||||
return "firmware/firmware-$mfg-$region-$curver.bin"
|
||||
}
|
||||
|
||||
/** Return the filename this device needs to use as an update (or null if no update needed)
|
||||
*/
|
||||
fun getUpdateFilename(context: Context, sync: SafeBluetooth): String? {
|
||||
val service = sync.gatt!!.services.find { it.uuid == SW_UPDATE_UUID }!!
|
||||
|
||||
val hwVerDesc = service.getCharacteristic(HW_VERSION_CHARACTER)
|
||||
val mfgDesc = service.getCharacteristic(MANUFACTURE_CHARACTER)
|
||||
val swVerDesc = service.getCharacteristic(SW_VERSION_CHARACTER)
|
||||
|
||||
// looks like 1.0-US
|
||||
var hwVer = sync.readCharacteristic(hwVerDesc).getStringValue(0)
|
||||
|
||||
// looks like HELTEC
|
||||
val mfg = sync.readCharacteristic(mfgDesc).getStringValue(0)
|
||||
|
||||
// looks like 0.0.12
|
||||
val swVer = sync.readCharacteristic(swVerDesc).getStringValue(0)
|
||||
|
||||
return getUpdateFilename(context, hwVer, swVer, mfg)
|
||||
}
|
||||
|
||||
/**
|
||||
* A public function so that if you have your own SafeBluetooth connection already open
|
||||
* you can use it for the software update.
|
||||
*/
|
||||
fun doUpdate(context: Context, sync: SafeBluetooth, assetName: String) {
|
||||
val service = sync.gatt!!.services.find { it.uuid == SW_UPDATE_UUID }!!
|
||||
|
||||
info("Starting firmware update for $assetName")
|
||||
|
||||
val totalSizeDesc = service.getCharacteristic(SW_UPDATE_TOTALSIZE_CHARACTER)
|
||||
val dataDesc = service.getCharacteristic(SW_UPDATE_DATA_CHARACTER)
|
||||
val crc32Desc = service.getCharacteristic(SW_UPDATE_CRC32_CHARACTER)
|
||||
val updateResultDesc = service.getCharacteristic(SW_UPDATE_RESULT_CHARACTER)
|
||||
|
||||
context.assets.open(assetName).use { firmwareStream ->
|
||||
val firmwareCrc = CRC32()
|
||||
var firmwareNumSent = 0
|
||||
val firmwareSize = firmwareStream.available()
|
||||
|
||||
// Start the update by writing the # of bytes in the image
|
||||
logAssert(
|
||||
totalSizeDesc.setValue(
|
||||
firmwareSize,
|
||||
BluetoothGattCharacteristic.FORMAT_UINT32,
|
||||
0
|
||||
)
|
||||
)
|
||||
sync.writeCharacteristic(totalSizeDesc)
|
||||
|
||||
// Our write completed, queue up a readback
|
||||
val totalSizeReadback = sync.readCharacteristic(totalSizeDesc)
|
||||
.getIntValue(BluetoothGattCharacteristic.FORMAT_UINT32, 0)
|
||||
if (totalSizeReadback == 0) // FIXME - handle this case
|
||||
throw Exception("Device rejected file size")
|
||||
|
||||
// Send all the blocks
|
||||
while (firmwareNumSent < firmwareSize) {
|
||||
progress = firmwareNumSent * 100 / firmwareSize
|
||||
debug("sending block ${progress}%")
|
||||
var blockSize = 512 - 3 // Max size MTU excluding framing
|
||||
|
||||
if (blockSize > firmwareStream.available())
|
||||
blockSize = firmwareStream.available()
|
||||
val buffer = ByteArray(blockSize)
|
||||
|
||||
// slightly expensive to keep reallocing this buffer, but whatever
|
||||
logAssert(firmwareStream.read(buffer) == blockSize)
|
||||
firmwareCrc.update(buffer)
|
||||
|
||||
// updateGatt.beginReliableWrite()
|
||||
dataDesc.value = buffer
|
||||
sync.writeCharacteristic(dataDesc)
|
||||
firmwareNumSent += blockSize
|
||||
}
|
||||
|
||||
// We have finished sending all our blocks, so post the CRC so our state machine can advance
|
||||
val c = firmwareCrc.value
|
||||
info("Sent all blocks, crc is $c")
|
||||
logAssert(
|
||||
crc32Desc.setValue(
|
||||
c.toInt(),
|
||||
BluetoothGattCharacteristic.FORMAT_UINT32,
|
||||
0
|
||||
)
|
||||
)
|
||||
sync.writeCharacteristic(crc32Desc)
|
||||
|
||||
// we just read the update result if !0 we have an error
|
||||
val updateResult =
|
||||
sync.readCharacteristic(updateResultDesc)
|
||||
.getIntValue(BluetoothGattCharacteristic.FORMAT_UINT8, 0)
|
||||
if (updateResult != 0) {
|
||||
progress = -2
|
||||
throw Exception("Device update failed, reason=$updateResult")
|
||||
}
|
||||
|
||||
// Device will now reboot
|
||||
|
||||
progress = -1 // success
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1 +1 @@
|
|||
Subproject commit c55dcf40797a1fc2d157860849b5cad2595a1940
|
||||
Subproject commit 1c729d801162380f2db9c2a3c681458f4a719bd1
|
Ładowanie…
Reference in New Issue