feat: 网络

This commit is contained in:
yangxisong 2025-11-12 18:20:57 +08:00
parent 22ff4384ca
commit 570de3c4e7
24 changed files with 600 additions and 11 deletions

View File

@ -45,8 +45,11 @@ dependencies {
implementation(libs.androidx.activity) implementation(libs.androidx.activity)
implementation(libs.androidx.constraintlayout) implementation(libs.androidx.constraintlayout)
implementation(libs.logger.timber) implementation(libs.logger.timber)
implementation(libs.okhttp)
implementation(libs.okhttp.logging.interceptor) implementation(libs.okhttp.logging.interceptor)
implementation(libs.androidx.work.ktx)
implementation(libs.retrofit)
implementation(libs.retrofit.converter.gson)
// implementation(libs.gson)
testImplementation(libs.junit) testImplementation(libs.junit)
androidTestImplementation(libs.androidx.junit) androidTestImplementation(libs.androidx.junit)
androidTestImplementation(libs.androidx.espresso.core) androidTestImplementation(libs.androidx.espresso.core)

View File

@ -2,6 +2,12 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android" <manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"> xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_REMOTE_MESSAGING" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
<application <application
android:name=".RokidApplication" android:name=".RokidApplication"
android:allowBackup="true" android:allowBackup="true"
@ -11,6 +17,7 @@
android:label="@string/app_name" android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round" android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true" android:supportsRtl="true"
android:networkSecurityConfig="@xml/network_security_config"
android:theme="@style/Theme.Rokid"> android:theme="@style/Theme.Rokid">
<activity <activity
android:name=".MainActivity" android:name=".MainActivity"
@ -21,6 +28,7 @@
<category android:name="android.intent.category.LAUNCHER" /> <category android:name="android.intent.category.LAUNCHER" />
</intent-filter> </intent-filter>
</activity> </activity>
<service android:name="com.yuanxuan.rokid.keeplive.KeepLiveService" />
</application> </application>
</manifest> </manifest>

View File

@ -7,9 +7,20 @@ import androidx.appcompat.app.AppCompatActivity
import androidx.core.view.ViewCompat import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat import androidx.core.view.WindowInsetsCompat
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import com.yuanxuan.rokid.dependencies.AppDependencies import com.google.gson.Gson
import com.google.gson.reflect.TypeToken
import com.yuanxuan.rokid.network.http.ApiResponse
import com.yuanxuan.rokid.network.http.Test
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient
import okhttp3.Request
import timber.log.Timber
import java.lang.reflect.Type
class MainActivity : AppCompatActivity() { class MainActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
@ -21,9 +32,26 @@ class MainActivity : AppCompatActivity() {
v.setPadding(systemBars.left, systemBars.top, systemBars.right, systemBars.bottom) v.setPadding(systemBars.left, systemBars.top, systemBars.right, systemBars.bottom)
insets insets
} }
AppDependencies.deviceServiceManager.playTTS("测试一下") val json = "{\n" +
" \"code\": 0,\n" +
" \"msg\": \"success\",\n" +
" \"data\": {\n" +
" \"userId\": \"123\",\n" +
" \"name\": \"Alice\"\n" +
" }\n" +
"}"
onBackPressedDispatcher.addCallback { val x = test<Test>(json)
// onBackPressedDispatcher.addCallback {
// }
} }
private inline fun <reified T> test(responseBody: String): T {
val typeToken = object : TypeToken<ApiResponse<T>>() {}
val typeOfT: Type = typeToken.type
val apiResponse: ApiResponse<T> = Gson().fromJson(responseBody, typeOfT)
return apiResponse.data
} }
} }

View File

@ -18,13 +18,18 @@ class RokidApplication : Application() {
override fun onCreate() { override fun onCreate() {
super.onCreate() super.onCreate()
Timber.plant(Timber.DebugTree()) Timber.plant(Timber.DebugTree())
AppDependencies.init(this, ApplicationDependencyProvider(this)) AppDependencies.init(this, ApplicationDependencyProvider(this, applicationScope))
/** /**
* 启动APP必须先获取到SN 后面网络依赖 * 启动APP必须先获取到SN 后面网络依赖
*/ */
applicationScope.launch { applicationScope.launch {
val sn = AppDependencies.deviceServiceManager.getSn() runCatching {
Timber.d("sn = $sn") AppDependencies.deviceServiceManager.getSn()
}.onSuccess {
AppDependencies.webSocketManager.connect()
}.onFailure {
Timber.e(it)
}
} }
/** /**
* 这个服务保证心跳包准时发 * 这个服务保证心跳包准时发

View File

@ -2,6 +2,12 @@ package com.yuanxuan.rokid.dependencies
import android.app.Application import android.app.Application
import com.yuanxuan.rokid.device.DeviceServiceManager import com.yuanxuan.rokid.device.DeviceServiceManager
import com.yuanxuan.rokid.network.http.ApiRepository
import com.yuanxuan.rokid.network.http.ApiService
import com.yuanxuan.rokid.network.http.OkHttpManager
import com.yuanxuan.rokid.network.http.RequestApi
import com.yuanxuan.rokid.network.http.RetrofitClient
import com.yuanxuan.rokid.network.websocket.WebSocketManager
/** /**
* 项目的服务管理器所有依赖集中管理 * 项目的服务管理器所有依赖集中管理
@ -11,6 +17,9 @@ object AppDependencies {
private lateinit var _application: Application private lateinit var _application: Application
private lateinit var provider: Provider private lateinit var provider: Provider
val application: Application
get() = _application
fun init(application: Application, provider: Provider) { fun init(application: Application, provider: Provider) {
if (this::_application.isInitialized || this::provider.isInitialized) { if (this::_application.isInitialized || this::provider.isInitialized) {
return return
@ -24,8 +33,20 @@ object AppDependencies {
provider.provideDeviceServiceManager() provider.provideDeviceServiceManager()
} }
val webSocketManager by lazy {
provider.provideWebSocketManager()
}
val requestApi by lazy {
provider.provideApiRepository(
apiService = RetrofitClient.apiService
)
}
interface Provider { interface Provider {
fun provideDeviceServiceManager(): DeviceServiceManager fun provideDeviceServiceManager(): DeviceServiceManager
fun provideWebSocketManager(): WebSocketManager
fun provideApiRepository(apiService: ApiService): ApiRepository
} }
} }

View File

@ -2,9 +2,30 @@ package com.yuanxuan.rokid.dependencies
import android.app.Application import android.app.Application
import com.yuanxuan.rokid.device.DeviceServiceManager import com.yuanxuan.rokid.device.DeviceServiceManager
import com.yuanxuan.rokid.network.http.ApiRepository
import com.yuanxuan.rokid.network.http.ApiService
import com.yuanxuan.rokid.network.http.OkHttpManager
import com.yuanxuan.rokid.network.http.RequestApi
import com.yuanxuan.rokid.network.websocket.WebSocketManager
import kotlinx.coroutines.CoroutineScope
class ApplicationDependencyProvider(val context: Application) : AppDependencies.Provider { class ApplicationDependencyProvider(val context: Application, val scope: CoroutineScope) :
AppDependencies.Provider {
override fun provideDeviceServiceManager(): DeviceServiceManager { override fun provideDeviceServiceManager(): DeviceServiceManager {
return DeviceServiceManager(context) return DeviceServiceManager(context)
} }
override fun provideWebSocketManager(): WebSocketManager {
return WebSocketManager(
context = context,
scope = scope
)
}
override fun provideApiRepository(apiService: ApiService): ApiRepository {
return ApiRepository(
apiService = apiService
)
}
} }

View File

@ -25,6 +25,7 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.suspendCancellableCoroutine import kotlinx.coroutines.suspendCancellableCoroutine
import timber.log.Timber import timber.log.Timber
import kotlin.coroutines.resume import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException
class DeviceServiceManager(val context: Application) { class DeviceServiceManager(val context: Application) {
@ -157,7 +158,12 @@ class DeviceServiceManager(val context: Application) {
} }
suspend fun getSn() = suspendCancellableCoroutine { continuation -> suspend fun getSn() = suspendCancellableCoroutine { continuation ->
systemFuncServiceTodo { service -> ServiceManager.getSystemFuncService(context) { service ->
if (service == null) {
continuation.resumeWithException(Throwable("没有拿到SN 要重启APP"))
return@getSystemFuncService
}
systemFuncService = service
sn = service.sn sn = service.sn
continuation.resume(sn) continuation.resume(sn)
} }
@ -187,7 +193,6 @@ class DeviceServiceManager(val context: Application) {
} }
systemFuncService = service systemFuncService = service
todo.invoke(service) todo.invoke(service)
Timber.d(" ${service.sn}")
} }
} else { } else {
todo.invoke(systemFuncService!!) todo.invoke(systemFuncService!!)

View File

@ -0,0 +1,38 @@
package com.yuanxuan.rokid.network
import timber.log.Timber
import java.net.Inet4Address
import java.net.NetworkInterface
object NetUtils {
fun getBaseUrl() = "http://${getLocalIPV4address()}.83:8765"
/**
* 获取局域网IP
*/
fun getLocalIPV4address(): String? {
try {
val networkInterfaces = NetworkInterface.getNetworkInterfaces()
while (networkInterfaces.hasMoreElements()) {
val networkInterface = networkInterfaces.nextElement()
val inetAddresses = networkInterface.inetAddresses
while (inetAddresses.hasMoreElements()) {
val inetAddress = inetAddresses.nextElement()
if (!inetAddress.isLoopbackAddress && inetAddress is Inet4Address) {
val fullIp = inetAddress.hostAddress
if (fullIp.isNullOrEmpty())
return null
val lastDotIndex = fullIp.lastIndexOf(".")
// 返回第一个找到的IPv4地址
return fullIp.take(lastDotIndex)
}
}
}
} catch (e: Exception) {
Timber.e(e, "获取局域网IP地址时发生异常")
}
return null
}
}

View File

@ -0,0 +1,10 @@
package com.yuanxuan.rokid.network.http
class ApiRepository(private val apiService: ApiService) {
suspend fun test(): Test? {
apiService.test()
return null
}
}

View File

@ -0,0 +1,14 @@
package com.yuanxuan.rokid.network.http
data class ApiResponse<T>(
val code: String,
val message: String,
val data: T
) {
fun isSuccess() = code == "200"
}
data class Test(
val userId: String,
val name: String
)

View File

@ -0,0 +1,12 @@
package com.yuanxuan.rokid.network.http
import com.yuanxuan.rokid.network.NetUtils
import retrofit2.http.GET
import retrofit2.http.Url
interface ApiService {
@GET
suspend fun test(@Url url: String = "${NetUtils.getBaseUrl()}/test"): ApiResponse<Test>
}

View File

@ -0,0 +1,5 @@
package com.yuanxuan.rokid.network.http
import java.io.IOException
class NetworkException(message: String, cause: Throwable? = null) : IOException(message, cause)

View File

@ -0,0 +1,135 @@
package com.yuanxuan.rokid.network.http
import com.google.gson.Gson
import com.google.gson.reflect.TypeToken
import com.yuanxuan.rokid.network.NetUtils
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.isActive
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.withContext
import okhttp3.Call
import okhttp3.Callback
import okhttp3.MediaType.Companion.toMediaTypeOrNull
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody
import okhttp3.RequestBody.Companion.toRequestBody
import okhttp3.Response
import okhttp3.logging.HttpLoggingInterceptor
import timber.log.Timber
import java.io.IOException
import java.lang.Exception
import java.lang.reflect.Type
import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException
import kotlin.time.Duration.Companion.milliseconds
class OkHttpManager {
private val soTimeoutMillis = 30.milliseconds
private val gson by lazy {
Gson()
}
private val okhttpClient by lazy {
val httpLoggingInterceptor by lazy {
HttpLoggingInterceptor { message ->
Timber.tag("OkHttp").d(message)
}.apply {
level = HttpLoggingInterceptor.Level.BODY
}
}
OkHttpClient.Builder()
.connectTimeout(soTimeoutMillis)
.readTimeout(soTimeoutMillis)
.addInterceptor(httpLoggingInterceptor)
.build()
}
suspend fun testApi(): Test {
return makeRequest<Test>(
path = "",
method = "GET",
)
}
private suspend inline fun <reified T> makeRequest(
path: String,
method: String,
jsonBody: String? = null,
headers: Map<String, String> = emptyMap(),
): T = withContext(Dispatchers.IO) {
val requestBody = jsonBody?.toRequestBody("application/json".toMediaTypeOrNull())
val request = buildRequest(
path = path,
method = method,
body = requestBody,
headers = headers
)
try {
val response = okhttpClient.newCall(request).executeAwait()
val responseBody = response.body.string()
//过滤http错误
if (response.isSuccessful.not()) {
throw NetworkException(
message = "Http error: ${response.code} ${response.message}"
)
}
val typeToken = object : TypeToken<ApiResponse<T>>() {}
val typeOfT: Type = typeToken.type
val apiResponse: ApiResponse<Test> = gson.fromJson(responseBody, typeOfT)
if (apiResponse.isSuccess().not()) {
throw NetworkException(
message = "Business error: ${apiResponse.code} ${apiResponse.message}"
)
}
return@withContext apiResponse.data as T
} catch (e: Exception) {
throw NetworkException("Http error: ${e.message}")
}
}
private fun buildRequest(
path: String,
method: String,
body: RequestBody?,
headers: Map<String, String>
): Request {
val request = Request.Builder()
.url(NetUtils.getBaseUrl() + path)
.method(method, body)
headers.forEach { (key, value) ->
request.addHeader(key, value)
}
return request.build()
}
private suspend fun Call.executeAwait(): Response =
suspendCancellableCoroutine { continuation ->
enqueue(object : Callback {
override fun onFailure(call: Call, e: IOException) {
if (continuation.context.isActive) {
continuation.resumeWithException(e)
}
}
override fun onResponse(call: Call, response: Response) {
//协程被取消忽略请求结果
if (continuation.context.isActive) {
continuation.resume(response)
}
}
})
continuation.invokeOnCancellation {
try {
//协程被取消 终端网络请求
cancel()
} catch (_: Throwable) {
// Ignore
}
}
}
}

View File

@ -0,0 +1,13 @@
package com.yuanxuan.rokid.network.http
class RequestApi(
private val okHttp: OkHttpManager
) {
suspend fun testApi(): Result<Test> {
return runCatching {
okHttp.testApi()
}
}
}

View File

@ -0,0 +1,37 @@
package com.yuanxuan.rokid.network.http
import com.google.gson.GsonBuilder
import okhttp3.OkHttpClient
import okhttp3.logging.HttpLoggingInterceptor
import retrofit2.Retrofit
import retrofit2.converter.gson.GsonConverterFactory
import timber.log.Timber
import kotlin.time.Duration.Companion.seconds
object RetrofitClient {
private val okHttpClient: OkHttpClient by lazy {
val httpLoggingInterceptor by lazy {
HttpLoggingInterceptor { message ->
Timber.tag("OkHttp").d(message)
}.apply {
level = HttpLoggingInterceptor.Level.BODY
}
}
OkHttpClient.Builder()
.connectTimeout(15.seconds)
.readTimeout(30.seconds)
.writeTimeout(30.seconds)
.addInterceptor(httpLoggingInterceptor)
.build()
}
private val retrofit: Retrofit by lazy {
Retrofit.Builder()
.client(okHttpClient)
.addConverterFactory(GsonConverterFactory.create(GsonBuilder().create()))
.build()
}
val apiService: ApiService by lazy {
retrofit.create(ApiService::class.java)
}
}

View File

@ -0,0 +1,72 @@
package com.yuanxuan.rokid.network.websocket
import com.yuanxuan.rokid.network.NetUtils
import com.yuanxuan.rokid.network.websocket.WebSocketConnection.Companion.DEFAULT_SEND_TIMEOUT
import com.yuanxuan.rokid.network.websocket.WebSocketConnection.Companion.PING_INTERVAL_TIME
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.Response
import okhttp3.WebSocket
import okhttp3.WebSocketListener
import okhttp3.logging.HttpLoggingInterceptor
import timber.log.Timber
import java.net.Inet4Address
import java.net.NetworkInterface
class OkHttpWebSocketConnection() : WebSocketListener(), WebSocketConnection {
private val _webSocketConnectionStateFlow: MutableStateFlow<WebSocketConnectionState> =
MutableStateFlow(WebSocketConnectionState.DISCONNECTED)
val webSocketConnectionStateFlow = _webSocketConnectionStateFlow.asStateFlow()
private var client: WebSocket? = null
private val httpLoggingInterceptor by lazy {
HttpLoggingInterceptor { message ->
Timber.tag("OkHttp").d(message)
}.apply {
level = HttpLoggingInterceptor.Level.BODY
}
}
@Synchronized
override fun connect() {
val okHttpClient = OkHttpClient.Builder()
.readTimeout(DEFAULT_SEND_TIMEOUT)
.pingInterval(PING_INTERVAL_TIME)
.addInterceptor(httpLoggingInterceptor)
.build()
val request = Request.Builder().url(NetUtils.getBaseUrl()).build()
_webSocketConnectionStateFlow.update {
WebSocketConnectionState.CONNECTING
}
client = okHttpClient.newWebSocket(request, this)
}
override fun isDead(): Boolean {
TODO("Not yet implemented")
}
override fun disconnect() {
TODO("Not yet implemented")
}
override fun sendRequest(
request: WebSocketRequestMessage,
timeoutSeconds: Long
) {
client?.send(request.requestId)
}
override fun onMessage(webSocket: WebSocket, text: String) {
}
override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) {
Timber.e("websocket断开连接 ${t}")
_webSocketConnectionStateFlow.update { WebSocketConnectionState.FAILED }
}
}

View File

@ -0,0 +1,28 @@
package com.yuanxuan.rokid.network.websocket
import kotlinx.coroutines.flow.MutableStateFlow
import kotlin.time.Duration.Companion.seconds
interface WebSocketConnection {
companion object {
val DEFAULT_SEND_TIMEOUT = 10.seconds
val PING_INTERVAL_TIME = 30.seconds
}
fun connect()
fun isDead(): Boolean
fun disconnect()
fun sendRequest(request: WebSocketRequestMessage) {
return sendRequest(request, DEFAULT_SEND_TIMEOUT.inWholeSeconds)
}
fun sendRequest(
request: WebSocketRequestMessage,
timeoutSeconds: Long
)
}

View File

@ -0,0 +1,15 @@
package com.yuanxuan.rokid.network.websocket;
public enum WebSocketConnectionState {
DISCONNECTED,
CONNECTING,
CONNECTED,
DISCONNECTING,
AUTHENTICATION_FAILED,
REMOTE_DEPRECATED,
FAILED;
public boolean isFailure() {
return this == AUTHENTICATION_FAILED || this == REMOTE_DEPRECATED || this == FAILED;
}
}

View File

@ -0,0 +1,51 @@
package com.yuanxuan.rokid.network.websocket
import android.content.Context
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
import java.util.Date
import kotlin.time.Duration.Companion.seconds
class WebSocketManager(context: Context, scope: CoroutineScope) {
private val webSocketConnection = OkHttpWebSocketConnection()
init {
scope.launch {
webSocketConnection.webSocketConnectionStateFlow.collectLatest { state ->
when (state) {
WebSocketConnectionState.DISCONNECTED,
WebSocketConnectionState.CONNECTING,
WebSocketConnectionState.CONNECTED,
WebSocketConnectionState.DISCONNECTING,
WebSocketConnectionState.AUTHENTICATION_FAILED,
WebSocketConnectionState.REMOTE_DEPRECATED -> {
}
WebSocketConnectionState.FAILED -> {
WebSocketReconnectWorker.schedule(context)
}
}
}
}
scope.launch {
while (true) {
delay(5.seconds.inWholeMilliseconds)
webSocketConnection.sendRequest(
WebSocketRequestMessage(
requestId = "测试数据 ${Date().time}"
)
)
}
}
}
fun connect() {
webSocketConnection.connect()
}
}

View File

@ -0,0 +1,44 @@
package com.yuanxuan.rokid.network.websocket
import android.content.Context
import androidx.work.Constraints
import androidx.work.ExistingWorkPolicy
import androidx.work.NetworkType
import androidx.work.OneTimeWorkRequestBuilder
import androidx.work.OutOfQuotaPolicy
import androidx.work.WorkManager
import androidx.work.Worker
import androidx.work.WorkerParameters
import com.yuanxuan.rokid.dependencies.AppDependencies
import timber.log.Timber
class WebSocketReconnectWorker(context: Context, workerParams: WorkerParameters) : Worker(
context,
workerParams
) {
override fun doWork(): Result {
Timber.d("尝试开始重连")
AppDependencies.deviceServiceManager.playTTS("开始重连")
AppDependencies.webSocketManager.connect()
return Result.success()
}
companion object {
const val WORE_NAME = "WebSocketReconnectWorker"
fun schedule(context: Context) {
val constraints = Constraints.Builder()
.setRequiredNetworkType(NetworkType.CONNECTED)
.build()
val request = OneTimeWorkRequestBuilder<WebSocketReconnectWorker>()
.setConstraints(constraints)
.setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST)
.build()
WorkManager.getInstance(context).enqueueUniqueWork(
uniqueWorkName = WORE_NAME,
existingWorkPolicy = ExistingWorkPolicy.REPLACE,
request = request
)
}
}
}

View File

@ -0,0 +1,5 @@
package com.yuanxuan.rokid.network.websocket
data class WebSocketRequestMessage(
val requestId: String
)

View File

@ -0,0 +1,5 @@
package com.yuanxuan.rokid.network.websocket
data class WebSocketResponseMessage(
val requestId: String
)

View File

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<network-security-config>
<domain-config cleartextTrafficPermitted="true">
<domain includeSubdomains="true">192.168.2.83</domain>
</domain-config>
</network-security-config>

View File

@ -11,6 +11,9 @@ activity = "1.8.0"
constraintlayout = "2.1.4" constraintlayout = "2.1.4"
timber = "5.0.1" timber = "5.0.1"
okhttp = "5.3.0" okhttp = "5.3.0"
worker = "2.11.0"
retrofit = "3.0.0"
gson = "2.13.2"
[libraries] [libraries]
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
@ -24,6 +27,10 @@ androidx-constraintlayout = { group = "androidx.constraintlayout", name = "const
logger-timber = { group = "com.jakewharton.timber", name = "timber", version.ref = "timber" } logger-timber = { group = "com.jakewharton.timber", name = "timber", version.ref = "timber" }
okhttp = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okhttp" } okhttp = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okhttp" }
okhttp-logging-interceptor = { group = "com.squareup.okhttp3", name = "logging-interceptor", version.ref = "okhttp" } okhttp-logging-interceptor = { group = "com.squareup.okhttp3", name = "logging-interceptor", version.ref = "okhttp" }
androidx-work-ktx = { group = "androidx.work", name = "work-runtime-ktx", version.ref = "worker" }
retrofit = { group = "com.squareup.retrofit2", name = "retrofit", version.ref = "retrofit" }
retrofit-converter-gson = { group = "com.squareup.retrofit2", name = "converter-gson", version.ref = "retrofit" }
gson = { group = "com.google.code.gson", name = "gson", version.ref = "gson" }
[plugins] [plugins]