mirror of
https://github.com/InsanusMokrassar/MicroUtils.git
synced 2026-10-15 16:40:26 +00:00
Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 721056d550 | |||
| 52a801096c | |||
| 08400381a1 |
12
CHANGELOG.md
12
CHANGELOG.md
@@ -1,5 +1,17 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## 0.32.1
|
||||||
|
|
||||||
|
* `Coroutines`:
|
||||||
|
* `SmartSemaphore`:
|
||||||
|
* Optimize permit checks, acquisition, uncontended releases and already satisfied waits to reduce allocations while preserving cancellation handling.
|
||||||
|
* `SmartMutex`:
|
||||||
|
* Optimize waits for unlocked mutexes and uncontended unlocks to reduce allocations while preserving cancellation handling.
|
||||||
|
* `SmartRWLocker`:
|
||||||
|
* Remove redundant coroutine contexts during read releases, write unlocks and cancellation cleanup.
|
||||||
|
* `SmartKeyRWLocker`:
|
||||||
|
* Reduce allocations during keyed operations by inlining the locker allocation helper and using a hash map without insertion order tracking.
|
||||||
|
|
||||||
## 0.32.0
|
## 0.32.0
|
||||||
|
|
||||||
* `Versions`:
|
* `Versions`:
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ class SmartKeyRWLocker<T>(
|
|||||||
readPermits = globalLockerReadPermits,
|
readPermits = globalLockerReadPermits,
|
||||||
writeIsLocked = globalLockerWriteIsLocked
|
writeIsLocked = globalLockerWriteIsLocked
|
||||||
)
|
)
|
||||||
private val lockers = mutableMapOf<T, SmartRWLocker>()
|
private val lockers = hashMapOf<T, SmartRWLocker>()
|
||||||
private val lockersMutex = Mutex()
|
private val lockersMutex = Mutex()
|
||||||
private val lockersWritingLocker = SmartSemaphore.Mutable(Int.MAX_VALUE)
|
private val lockersWritingLocker = SmartSemaphore.Mutable(Int.MAX_VALUE)
|
||||||
private val globalWritingLocker = SmartSemaphore.Mutable(Int.MAX_VALUE)
|
private val globalWritingLocker = SmartSemaphore.Mutable(Int.MAX_VALUE)
|
||||||
@@ -45,7 +45,7 @@ class SmartKeyRWLocker<T>(
|
|||||||
private fun allocateLockerWithoutLock(key: T) = lockers.getOrPut(key) {
|
private fun allocateLockerWithoutLock(key: T) = lockers.getOrPut(key) {
|
||||||
SmartRWLocker(perKeyReadPermits)
|
SmartRWLocker(perKeyReadPermits)
|
||||||
}
|
}
|
||||||
private suspend fun allocateLocker(key: T) = lockersMutex.withLock {
|
private suspend inline fun allocateLocker(key: T) = lockersMutex.withLock {
|
||||||
lockers.getOrPut(key) {
|
lockers.getOrPut(key) {
|
||||||
SmartRWLocker(perKeyReadPermits)
|
SmartRWLocker(perKeyReadPermits)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package dev.inmo.micro_utils.coroutines
|
|||||||
|
|
||||||
import kotlinx.coroutines.NonCancellable
|
import kotlinx.coroutines.NonCancellable
|
||||||
import kotlinx.coroutines.currentCoroutineContext
|
import kotlinx.coroutines.currentCoroutineContext
|
||||||
|
import kotlinx.coroutines.ensureActive
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
import kotlinx.coroutines.flow.first
|
import kotlinx.coroutines.flow.first
|
||||||
@@ -94,19 +95,32 @@ sealed interface SmartMutex {
|
|||||||
* If [isLocked] == true - will change it to false and return true. If current call will not unlock this
|
* If [isLocked] == true - will change it to false and return true. If current call will not unlock this
|
||||||
* [SmartMutex] - false
|
* [SmartMutex] - false
|
||||||
*/
|
*/
|
||||||
suspend fun unlock(): Boolean = withContext(NonCancellable) {
|
suspend fun unlock(): Boolean {
|
||||||
if (_lockStateFlow.value) {
|
if (!_lockStateFlow.value) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
// The uncontended path cannot suspend, including during cancelled cleanup.
|
||||||
|
if (internalChangesMutex.tryLock()) {
|
||||||
|
try {
|
||||||
|
return unlockState()
|
||||||
|
} finally {
|
||||||
|
internalChangesMutex.unlock()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return withContext(NonCancellable) {
|
||||||
internalChangesMutex.withLock {
|
internalChangesMutex.withLock {
|
||||||
if (_lockStateFlow.value) {
|
unlockState()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The caller must hold internalChangesMutex while updating the state.
|
||||||
|
private fun unlockState(): Boolean {
|
||||||
|
if (!_lockStateFlow.value) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
_lockStateFlow.value = false
|
_lockStateFlow.value = false
|
||||||
true
|
return true
|
||||||
} else {
|
|
||||||
false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
false
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -134,4 +148,11 @@ suspend inline fun <T> SmartMutex.Mutable.withLock(action: () -> T): T {
|
|||||||
* Anyway, after the end of this block there are no any guaranties that [SmartMutex.isLocked] == false due to the fact
|
* Anyway, after the end of this block there are no any guaranties that [SmartMutex.isLocked] == false due to the fact
|
||||||
* that some other parties may lock it again
|
* that some other parties may lock it again
|
||||||
*/
|
*/
|
||||||
suspend fun SmartMutex.waitUnlock() = lockStateFlow.first { !it }
|
suspend fun SmartMutex.waitUnlock(): Boolean {
|
||||||
|
currentCoroutineContext().ensureActive()
|
||||||
|
return if (!lockStateFlow.value) {
|
||||||
|
false
|
||||||
|
} else {
|
||||||
|
lockStateFlow.first { !it }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
package dev.inmo.micro_utils.coroutines
|
package dev.inmo.micro_utils.coroutines
|
||||||
|
|
||||||
import kotlinx.coroutines.CancellationException
|
import kotlinx.coroutines.CancellationException
|
||||||
import kotlinx.coroutines.NonCancellable
|
|
||||||
import kotlinx.coroutines.withContext
|
|
||||||
import kotlin.contracts.ExperimentalContracts
|
import kotlin.contracts.ExperimentalContracts
|
||||||
import kotlin.contracts.InvocationKind
|
import kotlin.contracts.InvocationKind
|
||||||
import kotlin.contracts.contract
|
import kotlin.contracts.contract
|
||||||
@@ -39,9 +37,7 @@ class SmartRWLocker(private val readPermits: Int = Int.MAX_VALUE, writeIsLocked:
|
|||||||
/**
|
/**
|
||||||
* Release one read permit in [readSemaphore]
|
* Release one read permit in [readSemaphore]
|
||||||
*/
|
*/
|
||||||
suspend fun releaseRead(): Boolean = withContext(NonCancellable) {
|
suspend fun releaseRead(): Boolean = _readSemaphore.release()
|
||||||
_readSemaphore.release()
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Locking [writeMutex] and wait while all [readSemaphore] permits will be freed
|
* Locking [writeMutex] and wait while all [readSemaphore] permits will be freed
|
||||||
@@ -51,9 +47,7 @@ class SmartRWLocker(private val readPermits: Int = Int.MAX_VALUE, writeIsLocked:
|
|||||||
try {
|
try {
|
||||||
_readSemaphore.acquire(readPermits)
|
_readSemaphore.acquire(readPermits)
|
||||||
} catch (e: CancellationException) {
|
} catch (e: CancellationException) {
|
||||||
withContext(NonCancellable) {
|
|
||||||
_writeMutex.unlock()
|
_writeMutex.unlock()
|
||||||
}
|
|
||||||
throw e
|
throw e
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -61,14 +55,13 @@ class SmartRWLocker(private val readPermits: Int = Int.MAX_VALUE, writeIsLocked:
|
|||||||
/**
|
/**
|
||||||
* Unlock [writeMutex]
|
* Unlock [writeMutex]
|
||||||
*/
|
*/
|
||||||
suspend fun unlockWrite(): Boolean = withContext(NonCancellable) {
|
suspend fun unlockWrite(): Boolean =
|
||||||
_writeMutex.unlock().also { unlocked ->
|
_writeMutex.unlock().also { unlocked ->
|
||||||
if (unlocked) {
|
if (unlocked) {
|
||||||
_readSemaphore.release(readPermits)
|
_readSemaphore.release(readPermits)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Will call [SmartSemaphore.Mutable.lock], then execute [action] and return the result after [SmartSemaphore.Mutable.unlock]
|
* Will call [SmartSemaphore.Mutable.lock], then execute [action] and return the result after [SmartSemaphore.Mutable.unlock]
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package dev.inmo.micro_utils.coroutines
|
|||||||
|
|
||||||
import kotlinx.coroutines.NonCancellable
|
import kotlinx.coroutines.NonCancellable
|
||||||
import kotlinx.coroutines.currentCoroutineContext
|
import kotlinx.coroutines.currentCoroutineContext
|
||||||
|
import kotlinx.coroutines.ensureActive
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
import kotlinx.coroutines.flow.first
|
import kotlinx.coroutines.flow.first
|
||||||
@@ -63,7 +64,7 @@ sealed interface SmartSemaphore {
|
|||||||
|
|
||||||
fun immutable() = Immutable(permitsStateFlow, maxPermits)
|
fun immutable() = Immutable(permitsStateFlow, maxPermits)
|
||||||
|
|
||||||
private fun checkedPermits(permits: Int) = permits.coerceIn(1 .. this.maxPermits)
|
private fun checkedPermits(permits: Int) = permits.coerceIn(1, maxPermits)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Holds call until this [SmartSemaphore] will be re-locked. That means that current method will
|
* Holds call until this [SmartSemaphore] will be re-locked. That means that current method will
|
||||||
@@ -75,7 +76,8 @@ sealed interface SmartSemaphore {
|
|||||||
do {
|
do {
|
||||||
val shouldContinue = internalChangesMutex.withLock {
|
val shouldContinue = internalChangesMutex.withLock {
|
||||||
val requiredPermits = checkedPermits - acquiredPermits
|
val requiredPermits = checkedPermits - acquiredPermits
|
||||||
val acquiring = minOf(freePermits, requiredPermits).takeIf { it > 0 } ?: return@withLock true
|
val acquiring = minOf(freePermits, requiredPermits)
|
||||||
|
if (acquiring <= 0) return@withLock true
|
||||||
acquiredPermits += acquiring
|
acquiredPermits += acquiring
|
||||||
_freePermitsStateFlow.value -= acquiring
|
_freePermitsStateFlow.value -= acquiring
|
||||||
|
|
||||||
@@ -137,20 +139,31 @@ sealed interface SmartSemaphore {
|
|||||||
* If [freePermits] == true - will change it to false and return true. If current call will not unlock this
|
* If [freePermits] == true - will change it to false and return true. If current call will not unlock this
|
||||||
* [SmartSemaphore] - false
|
* [SmartSemaphore] - false
|
||||||
*/
|
*/
|
||||||
suspend fun release(permits: Int = 1): Boolean = withContext(NonCancellable) {
|
suspend fun release(permits: Int = 1): Boolean {
|
||||||
val checkedPermits = checkedPermits(permits)
|
val checkedPermits = checkedPermits(permits)
|
||||||
if (_freePermitsStateFlow.value < maxPermits) {
|
if (_freePermitsStateFlow.value >= maxPermits) return false
|
||||||
|
|
||||||
|
// Updating permits without suspension remains safe in a cancelled coroutine.
|
||||||
|
if (internalChangesMutex.tryLock()) {
|
||||||
|
try {
|
||||||
|
return releaseLocked(checkedPermits)
|
||||||
|
} finally {
|
||||||
|
internalChangesMutex.unlock()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return withContext(NonCancellable) {
|
||||||
internalChangesMutex.withLock {
|
internalChangesMutex.withLock {
|
||||||
if (_freePermitsStateFlow.value < maxPermits) {
|
releaseLocked(checkedPermits)
|
||||||
_freePermitsStateFlow.value = minOf(_freePermitsStateFlow.value + checkedPermits, maxPermits)
|
|
||||||
true
|
|
||||||
} else {
|
|
||||||
false
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
false
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun releaseLocked(permits: Int): Boolean {
|
||||||
|
val freePermits = _freePermitsStateFlow.value
|
||||||
|
if (freePermits >= maxPermits) return false
|
||||||
|
_freePermitsStateFlow.value = minOf(freePermits + permits, maxPermits)
|
||||||
|
return true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -178,5 +191,14 @@ suspend inline fun <T> SmartSemaphore.Mutable.withAcquire(permits: Int = 1, acti
|
|||||||
* Anyway, after the end of this block there are no any guaranties that [SmartSemaphore.freePermits] >= [permits] due to
|
* Anyway, after the end of this block there are no any guaranties that [SmartSemaphore.freePermits] >= [permits] due to
|
||||||
* the fact that some other parties may lock it again
|
* the fact that some other parties may lock it again
|
||||||
*/
|
*/
|
||||||
suspend fun SmartSemaphore.waitRelease(permits: Int = 1) = permitsStateFlow.first { it >= permits }
|
suspend fun SmartSemaphore.waitRelease(permits: Int = 1): Int {
|
||||||
suspend fun SmartSemaphore.waitReleaseAll() = permitsStateFlow.first { it == maxPermits }
|
currentCoroutineContext().ensureActive()
|
||||||
|
val availablePermits = freePermits
|
||||||
|
return if (availablePermits >= permits) availablePermits else permitsStateFlow.first { it >= permits }
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun SmartSemaphore.waitReleaseAll(): Int {
|
||||||
|
currentCoroutineContext().ensureActive()
|
||||||
|
val availablePermits = freePermits
|
||||||
|
return if (availablePermits == maxPermits) availablePermits else permitsStateFlow.first { it == maxPermits }
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import dev.inmo.micro_utils.coroutines.SmartSemaphore
|
||||||
|
import kotlinx.coroutines.CoroutineStart
|
||||||
|
import kotlinx.coroutines.Deferred
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.async
|
||||||
|
import kotlinx.coroutines.awaitAll
|
||||||
|
import kotlinx.coroutines.coroutineScope
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.test.runTest
|
||||||
|
import kotlin.test.Test
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
import kotlin.test.assertFalse
|
||||||
|
import kotlin.test.assertTrue
|
||||||
|
import kotlin.time.Duration.Companion.seconds
|
||||||
|
|
||||||
|
class SmartSemaphoreConcurrentReleaseTests {
|
||||||
|
@Test
|
||||||
|
fun queuedReleasesRestoreEveryAcquiredPermit() = runTest(timeout = 5.seconds) {
|
||||||
|
val semaphore = SmartSemaphore.Mutable(permits = 4, acquiredPermits = 4)
|
||||||
|
|
||||||
|
val results = releaseWithQueuedContenders(semaphore, contenderCount = 3)
|
||||||
|
|
||||||
|
assertEquals(listOf(true, true, true), results)
|
||||||
|
assertEquals(4, semaphore.freePermits)
|
||||||
|
assertTrue(semaphore.tryAcquire(4))
|
||||||
|
assertFalse(semaphore.tryAcquire())
|
||||||
|
assertEquals(0, semaphore.freePermits)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun queuedReleasesRecheckCapacityAfterAcquiringTheMutex() = runTest(timeout = 5.seconds) {
|
||||||
|
val semaphore = SmartSemaphore.Mutable(permits = 2, acquiredPermits = 2)
|
||||||
|
|
||||||
|
val results = releaseWithQueuedContenders(semaphore, contenderCount = 3)
|
||||||
|
|
||||||
|
assertEquals(1, results.count { released -> released })
|
||||||
|
assertEquals(2, results.count { released -> !released })
|
||||||
|
assertEquals(2, semaphore.freePermits)
|
||||||
|
assertFalse(semaphore.release())
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun releaseWithQueuedContenders(
|
||||||
|
semaphore: SmartSemaphore.Mutable,
|
||||||
|
contenderCount: Int
|
||||||
|
): List<Boolean> = coroutineScope {
|
||||||
|
val contenders = mutableListOf<Deferred<Boolean>>()
|
||||||
|
val observer = launch(Dispatchers.Unconfined) {
|
||||||
|
semaphore.permitsStateFlow.first { freePermits ->
|
||||||
|
if (freePermits == 1) {
|
||||||
|
// The first release still holds the changes mutex while publishing.
|
||||||
|
// Every contender observes spare capacity before waiting for the mutex.
|
||||||
|
repeat(contenderCount) {
|
||||||
|
val contender = async(Dispatchers.Unconfined, start = CoroutineStart.UNDISPATCHED) {
|
||||||
|
semaphore.release()
|
||||||
|
}
|
||||||
|
contenders.add(contender)
|
||||||
|
assertFalse(contender.isCompleted, "Competing releases must wait for the publishing release")
|
||||||
|
}
|
||||||
|
true
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
assertTrue(semaphore.release())
|
||||||
|
observer.join()
|
||||||
|
assertEquals(contenderCount, contenders.size)
|
||||||
|
contenders.awaitAll()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
package dev.inmo.micro_utils.coroutines
|
||||||
|
|
||||||
|
import java.util.concurrent.CyclicBarrier
|
||||||
|
import java.util.concurrent.Executors
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
import kotlinx.coroutines.asCoroutineDispatcher
|
||||||
|
import kotlinx.coroutines.coroutineScope
|
||||||
|
import kotlinx.coroutines.joinAll
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.test.runTest
|
||||||
|
import kotlin.test.Test
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
import kotlin.test.assertFalse
|
||||||
|
import kotlin.test.assertTrue
|
||||||
|
import kotlin.time.Duration.Companion.seconds
|
||||||
|
|
||||||
|
class SmartSemaphoreParallelReleaseTests {
|
||||||
|
@Test
|
||||||
|
fun parallelSinglePermitReleasesRestoreEveryPermit() = runTest(timeout = 30.seconds) {
|
||||||
|
assertParallelReleases(permits = 8, releasePermits = List(8) { 1 }, expectedSuccessfulReleases = 8)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun parallelBulkReleasesRestoreEveryPermit() = runTest(timeout = 30.seconds) {
|
||||||
|
val releasePermits = (1 .. 8).toList()
|
||||||
|
assertParallelReleases(
|
||||||
|
permits = releasePermits.sum(),
|
||||||
|
releasePermits = releasePermits,
|
||||||
|
expectedSuccessfulReleases = releasePermits.size
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun parallelExcessReleasesStopAtCapacity() = runTest(timeout = 30.seconds) {
|
||||||
|
assertParallelReleases(permits = 3, releasePermits = List(8) { 1 }, expectedSuccessfulReleases = 3)
|
||||||
|
assertParallelReleases(permits = 5, releasePermits = List(8) { 3 }, expectedSuccessfulReleases = 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun assertParallelReleases(
|
||||||
|
permits: Int,
|
||||||
|
releasePermits: List<Int>,
|
||||||
|
expectedSuccessfulReleases: Int
|
||||||
|
) = coroutineScope {
|
||||||
|
val semaphore = SmartSemaphore.Mutable(permits = permits, acquiredPermits = permits)
|
||||||
|
val barrier = CyclicBarrier(releasePermits.size)
|
||||||
|
val results = BooleanArray(releasePermits.size)
|
||||||
|
val rounds = 100
|
||||||
|
|
||||||
|
Executors.newFixedThreadPool(releasePermits.size).asCoroutineDispatcher().use { dispatcher ->
|
||||||
|
releasePermits.mapIndexed { worker, count ->
|
||||||
|
launch(dispatcher) {
|
||||||
|
repeat(rounds) { round ->
|
||||||
|
// Blocking barriers on dedicated JVM threads produce actual parallel releases.
|
||||||
|
barrier.await(5, TimeUnit.SECONDS)
|
||||||
|
results[worker] = semaphore.release(count)
|
||||||
|
barrier.await(5, TimeUnit.SECONDS)
|
||||||
|
|
||||||
|
if (worker == 0) {
|
||||||
|
assertEquals(permits, semaphore.freePermits, "Round $round must preserve released permits")
|
||||||
|
assertEquals(expectedSuccessfulReleases, results.count { released -> released })
|
||||||
|
assertFalse(semaphore.release(), "A full semaphore must reject another release")
|
||||||
|
assertEquals(permits, semaphore.freePermits)
|
||||||
|
if (round < rounds - 1) {
|
||||||
|
assertTrue(semaphore.tryAcquire(permits))
|
||||||
|
assertEquals(0, semaphore.freePermits)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Finish validation and reset before starting the next release wave.
|
||||||
|
barrier.await(5, TimeUnit.SECONDS)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}.joinAll()
|
||||||
|
}
|
||||||
|
|
||||||
|
assertEquals(permits, semaphore.freePermits)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -16,5 +16,5 @@ crypto_js_version=4.2.0
|
|||||||
# Project data
|
# Project data
|
||||||
|
|
||||||
group=dev.inmo
|
group=dev.inmo
|
||||||
version=0.32.0
|
version=0.32.1
|
||||||
android_code_version=319
|
android_code_version=320
|
||||||
|
|||||||
Reference in New Issue
Block a user