diff --git a/network/README.md b/network/README.md new file mode 100644 index 00000000..323ac55b --- /dev/null +++ b/network/README.md @@ -0,0 +1,2 @@ +network +==== diff --git a/network/build.gradle b/network/build.gradle new file mode 100644 index 00000000..d451b123 --- /dev/null +++ b/network/build.gradle @@ -0,0 +1,48 @@ +apply from: "../android-configs/lib-config.gradle" + +dependencies { + def okhttpVersion = "3.14.1" + def retrofitVersion = "2.8.1" + def junitVersion = '4.13.2' + def mockitoVersion = "4.4.0" + def coroutineVersion = "1.4.0" + + implementation("com.squareup.okhttp3:okhttp") + implementation("com.squareup.retrofit2:retrofit") + + testImplementation("junit:junit") + testImplementation("org.mockito:mockito-core") + testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-core") + + constraints { + implementation("com.squareup.okhttp3:okhttp") { + version { + require(okhttpVersion) + } + } + + implementation("com.squareup.retrofit2:retrofit") { + version { + require(retrofitVersion) + } + } + + implementation("junit:junit") { + version { + require(junitVersion) + } + } + + testImplementation("org.mockito:mockito-core") { + version { + require(mockitoVersion) + } + } + + testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-core") { + version { + require(coroutineVersion) + } + } + } +} diff --git a/network/src/main/AndroidManifest.xml b/network/src/main/AndroidManifest.xml new file mode 100644 index 00000000..c53946bb --- /dev/null +++ b/network/src/main/AndroidManifest.xml @@ -0,0 +1 @@ + diff --git a/network/src/main/java/ru/touchin/network/blocking/BlockingCall.kt b/network/src/main/java/ru/touchin/network/blocking/BlockingCall.kt new file mode 100644 index 00000000..fb9b1ef2 --- /dev/null +++ b/network/src/main/java/ru/touchin/network/blocking/BlockingCall.kt @@ -0,0 +1,55 @@ +package ru.touchin.network.blocking + +import retrofit2.Call +import retrofit2.Callback +import retrofit2.Response +import ru.touchin.network.utils.getAnnotation + +/** + * Custom [Call] implementation for handling blocking and pending requests. + * @param callDelegate is delegate of default Call implementation + */ +class BlockingCall( + private val callDelegate: Call +) : Call by callDelegate { + + override fun enqueue(callback: Callback) { + val isBlocking = callDelegate.blocking != null + + if (PendingRequestsManager.isPending) { + PendingRequestsManager.addPendingRequest( + call = this, + callback = callback, + isBlocking = isBlocking + ) + return + } + + if (isBlocking) PendingRequestsManager.isPending = true + + callDelegate.enqueue(object: Callback { + override fun onResponse(call: Call, response: Response) { + callback.onResponse(call, response) + + if (call.blocking != null) { + PendingRequestsManager.isPending = false + PendingRequestsManager.executePendingRequests() + } + } + + override fun onFailure(call: Call, t: Throwable) { + PendingRequestsManager.isPending = false + callback.onFailure(call, t) + + val (isBlockingInternal, cancelOnFail) = callDelegate.blocking + .let { (it != null) to (it?.cancelRequestsOnFail == true) } + when { + isBlockingInternal && cancelOnFail -> PendingRequestsManager.dropPendingRequests() + isBlockingInternal -> PendingRequestsManager.executePendingRequests() + } + } + }) + } + + private val Call.blocking get() = request().getAnnotation(BlockingRequest::class.java) +} diff --git a/network/src/main/java/ru/touchin/network/blocking/BlockingRequest.kt b/network/src/main/java/ru/touchin/network/blocking/BlockingRequest.kt new file mode 100644 index 00000000..9f2cf0a5 --- /dev/null +++ b/network/src/main/java/ru/touchin/network/blocking/BlockingRequest.kt @@ -0,0 +1,11 @@ +package ru.touchin.network.blocking + +/** + * Annotation that is used for methods of retrofit services to tag request as blocking one. + * It means that every upcoming request will be pended until this method finished. + * @param cancelRequestsOnFail if true then all pending requests will be canceled + * if blocking request fails + */ +@Retention(AnnotationRetention.RUNTIME) +@Target(AnnotationTarget.FUNCTION) +annotation class BlockingRequest(val cancelRequestsOnFail: Boolean = false) diff --git a/network/src/main/java/ru/touchin/network/blocking/BlockingRequestCallAdapter.kt b/network/src/main/java/ru/touchin/network/blocking/BlockingRequestCallAdapter.kt new file mode 100644 index 00000000..93219fa9 --- /dev/null +++ b/network/src/main/java/ru/touchin/network/blocking/BlockingRequestCallAdapter.kt @@ -0,0 +1,33 @@ +package ru.touchin.network.blocking + +import retrofit2.Call +import retrofit2.CallAdapter +import retrofit2.Retrofit +import java.lang.IllegalStateException +import java.lang.reflect.ParameterizedType +import java.lang.reflect.Type + +/** + * CallAdapter for Retrofit instance used for handling [BlockingRequest] methods + * Returns [BlockingCall] as a custom adaptation of [Call] + */ +class BlockingRequestCallAdapter private constructor( + private val responseType: Type +) : CallAdapter { + + companion object { + fun create() = object : CallAdapter.Factory() { + override fun get(returnType: Type, annotations: Array, retrofit: Retrofit): CallAdapter<*, *>? { + return when { + getRawType(returnType) != Call::class.java -> null + returnType !is ParameterizedType -> throw IllegalStateException("return type must be parametrized") + else -> BlockingRequestCallAdapter(responseType = returnType.actualTypeArguments[0]) + } + } + } + } + + override fun responseType(): Type = responseType + + override fun adapt(call: Call): Any = BlockingCall(call) +} diff --git a/network/src/main/java/ru/touchin/network/blocking/PendingRequestsManager.kt b/network/src/main/java/ru/touchin/network/blocking/PendingRequestsManager.kt new file mode 100644 index 00000000..2d2a9484 --- /dev/null +++ b/network/src/main/java/ru/touchin/network/blocking/PendingRequestsManager.kt @@ -0,0 +1,87 @@ +package ru.touchin.network.blocking + +import retrofit2.Call +import retrofit2.Callback +import java.util.concurrent.atomic.AtomicBoolean +import java.util.concurrent.locks.ReentrantLock +import kotlin.concurrent.withLock + +/** + * Manager that holds the list of requests and provides methods to interact with them. + * Provided as a singleton and can be used to prevent sending requests right away + * e.g. via [BlockingRequest]. + */ +object PendingRequestsManager { + + //Using ReentrantLock to avoid concurrency pitfalls when interacting with pendingRequests + private val pendingRequestsLock = ReentrantLock() + + private val pendingRequests = mutableListOf() + + private val internalAtomicPending = AtomicBoolean(false) + + /** + * Flag that show if requests should be stock in [pendingRequests] or be enqueued right away + * Wrapper of atomic [internalAtomicPending] + */ + var isPending: Boolean + get() = internalAtomicPending.get() + set(value) { internalAtomicPending.set(value) } + + /** + * Shows how many requests are stock + */ + fun getPendingRequestsCount() = pendingRequests.count() + + /** + * Used for adding requests to stock + * @param call is retrofit method + * @param callback used to provide actions when requests finished with success or error + */ + fun addPendingRequest(call: Call, callback: Callback, isBlocking: Boolean) { + pendingRequestsLock.withLock { + pendingRequests.add(PendingRequestData(call, callback, isBlocking)) + } + } + + /** + * Used to execute and clear all stocked requests + */ + fun executePendingRequests() { + applyActionToPendingRequests { call.enqueue(callback) } + } + + /** + * Used to cancel and clear all stocked requests + */ + fun dropPendingRequests() { + applyActionToPendingRequests { call.cancel() } + } + + private fun applyActionToPendingRequests(action: PendingRequestData.() -> Unit) { + pendingRequestsLock.withLock { + with (pendingRequests.iterator()) { + while (hasNext()) { + val requestData = next() + remove() + + requestData.action() + if (requestData.isBlocking) break + } + } + } + } + + /** + * Contains data of stocked requests + * @param call is retrofit request we want to stock + * @param callback used to call methods onResponse and onFailure of method + * @param isBlocking shows if request we add to stock is blocking and all following requests must be pended + */ + internal class PendingRequestData( + val call: Call, + val callback: Callback, + val isBlocking: Boolean = false + ) + +} diff --git a/network/src/main/java/ru/touchin/network/utils/Request.kt b/network/src/main/java/ru/touchin/network/utils/Request.kt new file mode 100644 index 00000000..192a22ca --- /dev/null +++ b/network/src/main/java/ru/touchin/network/utils/Request.kt @@ -0,0 +1,7 @@ +package ru.touchin.network.utils + +import okhttp3.Request +import retrofit2.Invocation + +fun Request.getAnnotation(annotation: Class) = + tag(Invocation::class.java)?.method()?.getAnnotation(annotation) diff --git a/network/src/test/java/PendingRequestsManagerTest.kt b/network/src/test/java/PendingRequestsManagerTest.kt new file mode 100644 index 00000000..92664d82 --- /dev/null +++ b/network/src/test/java/PendingRequestsManagerTest.kt @@ -0,0 +1,67 @@ +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.ObsoleteCoroutinesApi +import kotlinx.coroutines.joinAll +import kotlinx.coroutines.launch +import kotlinx.coroutines.newFixedThreadPoolContext +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Test +import org.mockito.Mockito.mock +import retrofit2.Call +import retrofit2.Callback +import ru.touchin.network.blocking.PendingRequestsManager + +@Suppress("UNCHECKED_CAST") +@ObsoleteCoroutinesApi +class PendingRequestsManagerTest { + + @Before + fun `Clear pending requests list`() { + PendingRequestsManager.dropPendingRequests() + } + + @Test + fun `Assert pending requests add synchronization`() = runBlocking { + runOnFixedThreadScope { + 1.rangeTo(1000).map { launch { addRequestsAsync() } }.joinAll() + } + + assertEquals(10000, PendingRequestsManager.getPendingRequestsCount()) + } + + @Test + fun `Assert pending requests synchronization`() = runBlocking { + runOnFixedThreadScope { + repeat(1000) { addRequestsAsync() } + + val executeJob = launch { + PendingRequestsManager.executePendingRequests() + } + + val addJobs2 = 1.rangeTo(1000).map { launch { + addRequestsAsync() + } } + + executeJob.join() + addJobs2.joinAll() + } + + assertEquals(10000, PendingRequestsManager.getPendingRequestsCount()) + } + + private fun addRequestsAsync() { + repeat(10) { + PendingRequestsManager.addPendingRequest( + call = mock(Call::class.java) as Call, + callback = mock(Callback::class.java) as Callback + ) + } + } + + private suspend fun runOnFixedThreadScope(block: suspend CoroutineScope.() -> Unit) { + CoroutineScope(newFixedThreadPoolContext(5, "synchronizationPool")) + .launch { block() } + .join() + } +} diff --git a/utils/src/test/java/DateFormatUtilsTest.kt b/utils/src/test/java/DateFormatUtilsTest.kt index 8036ede3..80c5f40a 100644 --- a/utils/src/test/java/DateFormatUtilsTest.kt +++ b/utils/src/test/java/DateFormatUtilsTest.kt @@ -1,5 +1,5 @@ import org.joda.time.DateTime -import org.junit.Assert +import org.junit.Assert.assertEquals import org.junit.Test import ru.touchin.roboswag.core.utils.DateFormatUtils @@ -11,7 +11,7 @@ class DateFormatUtilsTest { value = "2015-04-29", format = DateFormatUtils.Format.DATE_FORMAT ) - Assert.assertEquals(DateTime(2015, 4, 29, 0, 0, 0), dateTime) + assertEquals(DateTime(2015, 4, 29, 0, 0, 0), dateTime) } @Test @@ -23,6 +23,6 @@ class DateFormatUtilsTest { format = DateFormatUtils.Format.DATE_TIME_FORMAT, defaultValue = currentDateTime ) - Assert.assertEquals(currentDateTime, dateTime) + assertEquals(currentDateTime, dateTime) } }