From 19b67d6af8cf57f7184f65609f1abd47fb8708f0 Mon Sep 17 00:00:00 2001 From: wintmain Date: Sun, 1 Jun 2025 20:40:58 +0800 Subject: [PATCH 1/4] [common][feat]Add app unit test templates --- app-catalog/app/build.gradle | 1 + .../wintmain/catalog/app/ExampleUnitTest.kt | 33 +++++++++++++++++++ gradle/libs.versions.toml | 18 ++++++---- 3 files changed, 46 insertions(+), 6 deletions(-) create mode 100644 app-catalog/app/src/test/java/com/wintmain/catalog/app/ExampleUnitTest.kt diff --git a/app-catalog/app/build.gradle b/app-catalog/app/build.gradle index c5574b3..6c8cc48 100644 --- a/app-catalog/app/build.gradle +++ b/app-catalog/app/build.gradle @@ -94,6 +94,7 @@ dependencies { // libs.androidx.testext 必须和上面的 runner 一起;AS自动创建的'androidTest'会用到 androidTestImplementation libs.androidx.test.ext.junit testImplementation libs.androidx.test.ext.junit + testImplementation libs.junit // 暂时禁用 App中集成CodeLocator: https://github.com/bytedance/CodeLocator // debugImplementation libs.codelocator.core diff --git a/app-catalog/app/src/test/java/com/wintmain/catalog/app/ExampleUnitTest.kt b/app-catalog/app/src/test/java/com/wintmain/catalog/app/ExampleUnitTest.kt new file mode 100644 index 0000000..706e2be --- /dev/null +++ b/app-catalog/app/src/test/java/com/wintmain/catalog/app/ExampleUnitTest.kt @@ -0,0 +1,33 @@ +/* + * Copyright 2023-2025 wintmain + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.wintmain.catalog.app + +import org.junit.Test + +import org.junit.Assert.* + +/** + * Example local unit test, which will execute on the development machine (host). + * + * See [testing documentation](http://d.android.com/tools/testing). + */ +class ExampleUnitTest { + @Test + fun addition_isCorrect() { + assertEquals(4, 2 + 2) + } +} \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 8303b8b..72c2943 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -22,9 +22,10 @@ casa = "0.5.1" coil = "2.6.0" compose-bom = "2025.02.00" composeCompiler = "1.5.9" +documentfile = "1.0.1" kotlin = "1.9.22" hilt = "2.48.1" -kotlinxCoroutines = "1.9.0" +kotlinxCoroutines = "1.7.3" ksp = "1.9.22-1.0.17" # Should be updated when kotlin version is updated coreExt = "1.13.1" androidx_activity = "1.9.2" @@ -34,8 +35,10 @@ androidx_window = "1.3.0" lifecycleExtensions = "2.2.0" lifecycleRuntimeKtx = "2.8.7" multidex = "1.0.3" +okhttp = "4.12.0" roomRuntime = "2.6.1" smartrefresh = "1.1.2" +junit = "4.13.2" [libraries] # Core dependencies @@ -60,6 +63,8 @@ androidx-activity-compose = { group = "androidx.activity", name = "activity-comp androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "androidx_appcompat" } androidx-coreExt = { group = "androidx.core", name = "core", version.ref = "coreExt" } androidx-coreExtkt = { group = "androidx.core", name = "core-ktx", version.ref = "coreExt" } +androidx-documentfile = { module = "androidx.documentfile:documentfile", version.ref = "documentfile" } +androidx-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" } compose-bom = { module = "androidx.compose:compose-bom", version.ref = "compose-bom" } compose-material-material3 = { module = "androidx.compose.material3:material3" } @@ -91,7 +96,6 @@ androidx-annotation = "androidx.annotation:annotation:1.8.2" androidx-fragment = "androidx.fragment:fragment-ktx:1.8.3" androidx-exifinterface = "androidx.exifinterface:exifinterface:1.3.7" androidx-transition = "androidx.transition:transition-ktx:1.5.1" -androidx-lifecycle-viewmodel-compose = "androidx.lifecycle:lifecycle-viewmodel-compose:2.8.6" androidx-viewpager2 = "androidx.viewpager2:viewpager2:1.1.0" androidx-work-runtime-ktx = "androidx.work:work-runtime-ktx:2.9.1" androidx-core-remoteviews = "androidx.core:core-remoteviews:1.1.0" @@ -118,14 +122,17 @@ androidx-room-runtime = { module = "androidx.room:room-runtime", version.ref = " codelocator-core = "com.bytedance.tools.codelocator:codelocator-core:2.0.3" google-ksp-plugin = { module = "com.google.devtools.ksp:com.google.devtools.ksp.gradle.plugin", version.ref = "ksp" } google-ksp-api = { module = "com.google.devtools.ksp:symbol-processing-api", version.ref = "ksp" } -kotlinx-coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "kotlinxCoroutines" } -kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "kotlinxCoroutines" } androidx-lifecycle-extensions = { module = "androidx.lifecycle:lifecycle-extensions", version.ref = "lifecycleExtensions" } androidx-lifecycle-livedata-ktx = { module = "androidx.lifecycle:lifecycle-livedata-ktx", version.ref = "lifecycleExtensions" } androidx-lifecycle-viewmodel-ktx = { module = "androidx.lifecycle:lifecycle-viewmodel-ktx", version.ref = "lifecycleExtensions" } +androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycleRuntimeKtx" } +androidx-lifecycle-viewmodel-compose = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "lifecycleRuntimeKtx" } +kotlinx-coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "kotlinxCoroutines" } +kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "kotlinxCoroutines" } material = "com.google.android.material:material:1.12.0" accompanist-permissions = "com.google.accompanist:accompanist-permissions:0.32.0" multidex = { module = "com.android.support:multidex", version.ref = "multidex" } +okhttp = { module = "com.squareup.okhttp3:okhttp", version.ref = "okhttp" } smartrefreshheader = { module = "com.scwang.smartrefresh:SmartRefreshHeader", version.ref = "smartrefresh" } smartrefreshlayout = { module = "com.scwang.smartrefresh:SmartRefreshLayout", version.ref = "smartrefresh" } @@ -144,5 +151,4 @@ androidx-test-ext-junitkt = "androidx.test.ext:junit-ktx:1.2.1" androidx-test-ext-truth = "androidx.test.ext:truth:1.6.0" #To use android test orchestrator androidx-test-orchestrator = "androidx.test:orchestrator:1.5.0" -androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycleRuntimeKtx" } -androidx-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" } +junit = { group = "junit", name = "junit", version.ref = "junit" } From a429866386847a15f84b7d65bb5439cbb36b8d3e Mon Sep 17 00:00:00 2001 From: wintmain Date: Sun, 1 Jun 2025 20:42:09 +0800 Subject: [PATCH 2/4] [wNet][feat]Add lib support --- .../samples/wNet/libwNet/build.gradle.kts | 51 ++ .../wNet/libwNet/src/main/AndroidManifest.xml | 36 + .../src/main/java/lib/wintmain/libwNet/Net.kt | 387 +++++++++ .../java/lib/wintmain/libwNet/NetConfig.kt | 113 +++ .../java/lib/wintmain/libwNet/NetCoroutine.kt | 218 +++++ .../lib/wintmain/libwNet/OkHttpUtils.java | 77 ++ .../main/java/lib/wintmain/libwNet/Scope.kt | 55 ++ .../wintmain/libwNet/body/BodyExtension.kt | 83 ++ .../wintmain/libwNet/body/NetRequestBody.kt | 99 +++ .../wintmain/libwNet/body/NetResponseBody.kt | 87 ++ .../lib/wintmain/libwNet/cache/CacheMode.kt | 32 + .../lib/wintmain/libwNet/cache/ForceCache.kt | 768 ++++++++++++++++++ .../wintmain/libwNet/component/Progress.kt | 157 ++++ .../wintmain/libwNet/convert/JSONConvert.kt | 93 +++ .../wintmain/libwNet/convert/NetConverter.kt | 53 ++ .../libwNet/cookie/PersistentCookieJar.kt | 116 +++ .../libwNet/exception/ConvertException.kt | 32 + .../exception/DownloadFileException.kt | 33 + .../libwNet/exception/HttpFailureException.kt | 31 + .../exception/HttpResponseException.kt | 35 + .../exception/NetCancellationException.kt | 42 + .../libwNet/exception/NetConnectException.kt | 31 + .../libwNet/exception/NetException.kt | 40 + .../exception/NetSocketTimeoutException.kt | 31 + .../exception/NetUnknownHostException.kt | 31 + .../libwNet/exception/NetworkingException.kt | 31 + .../libwNet/exception/NoCacheException.kt | 38 + .../exception/RequestParamsException.kt | 33 + .../libwNet/exception/ResponseException.kt | 35 + .../exception/ServerResponseException.kt | 33 + .../libwNet/exception/URLParseException.kt | 35 + .../interceptor/LogRecordInterceptor.kt | 116 +++ .../interceptor/NetOkHttpInterceptor.kt | 131 +++ .../libwNet/interceptor/RequestInterceptor.kt | 29 + .../libwNet/interceptor/RetryInterceptor.kt | 43 + .../libwNet/interfaces/NetDialogFactory.kt | 40 + .../libwNet/interfaces/NetErrorHandler.kt | 88 ++ .../libwNet/interfaces/ProgressListener.kt | 37 + .../wintmain/libwNet/internal/NetDeferred.kt | 40 + .../libwNet/internal/NetInitializer.kt | 35 + .../lib/wintmain/libwNet/log/LogRecorder.kt | 235 ++++++ .../lib/wintmain/libwNet/log/MessageType.kt | 33 + .../wintmain/libwNet/okhttp/OkHttpBuilder.kt | 136 ++++ .../libwNet/okhttp/OkHttpExtension.kt | 31 + .../lib/wintmain/libwNet/reflect/TypeUtils.kt | 23 + .../wintmain/libwNet/request/BaseRequest.kt | 505 ++++++++++++ .../wintmain/libwNet/request/BodyRequest.kt | 202 +++++ .../wintmain/libwNet/request/MediaConst.kt | 46 ++ .../lib/wintmain/libwNet/request/Method.kt | 22 + .../libwNet/request/RequestBuilder.kt | 154 ++++ .../libwNet/request/RequestExtension.kt | 194 +++++ .../wintmain/libwNet/request/UrlRequest.kt | 44 + .../libwNet/response/ResponseExtension.kt | 190 +++++ .../wintmain/libwNet/scope/AndroidScope.kt | 128 +++ .../libwNet/scope/DialogCoroutineScope.kt | 84 ++ .../libwNet/scope/NetCoroutineScope.kt | 130 +++ .../libwNet/scope/PageCoroutineScope.kt | 78 ++ .../libwNet/scope/StateCoroutineScope.kt | 73 ++ .../libwNet/scope/ViewCoroutineScope.kt | 43 + .../java/lib/wintmain/libwNet/tag/NetTag.kt | 63 ++ .../lib/wintmain/libwNet/time/Interval.kt | 293 +++++++ .../wintmain/libwNet/time/IntervalStatus.kt | 24 + .../libwNet/transform/DeferredTransform.kt | 29 + .../lib/wintmain/libwNet/utils/Fastest.kt | 112 +++ .../lib/wintmain/libwNet/utils/FileUtils.kt | 85 ++ .../lib/wintmain/libwNet/utils/FlowUtils.kt | 88 ++ .../java/lib/wintmain/libwNet/utils/Https.kt | 107 +++ .../lib/wintmain/libwNet/utils/NetUtils.kt | 42 + .../java/lib/wintmain/libwNet/utils/Scope.kt | 240 ++++++ .../lib/wintmain/libwNet/utils/Suspend.kt | 62 ++ .../lib/wintmain/libwNet/utils/TipUtils.kt | 41 + .../java/lib/wintmain/libwNet/utils/Uri.kt | 62 ++ .../src/main/res/drawable/ic_limited_time.xml | 25 + .../src/main/res/drawable/ic_timing.xml | 28 + .../libwNet/src/main/res/values/strings.xml | 39 + .../main/res/xml/network_security_config.xml | 19 + 76 files changed, 7205 insertions(+) create mode 100644 app-catalog/samples/wNet/libwNet/build.gradle.kts create mode 100644 app-catalog/samples/wNet/libwNet/src/main/AndroidManifest.xml create mode 100644 app-catalog/samples/wNet/libwNet/src/main/java/lib/wintmain/libwNet/Net.kt create mode 100644 app-catalog/samples/wNet/libwNet/src/main/java/lib/wintmain/libwNet/NetConfig.kt create mode 100644 app-catalog/samples/wNet/libwNet/src/main/java/lib/wintmain/libwNet/NetCoroutine.kt create mode 100644 app-catalog/samples/wNet/libwNet/src/main/java/lib/wintmain/libwNet/OkHttpUtils.java create mode 100644 app-catalog/samples/wNet/libwNet/src/main/java/lib/wintmain/libwNet/Scope.kt create mode 100644 app-catalog/samples/wNet/libwNet/src/main/java/lib/wintmain/libwNet/body/BodyExtension.kt create mode 100644 app-catalog/samples/wNet/libwNet/src/main/java/lib/wintmain/libwNet/body/NetRequestBody.kt create mode 100644 app-catalog/samples/wNet/libwNet/src/main/java/lib/wintmain/libwNet/body/NetResponseBody.kt create mode 100644 app-catalog/samples/wNet/libwNet/src/main/java/lib/wintmain/libwNet/cache/CacheMode.kt create mode 100644 app-catalog/samples/wNet/libwNet/src/main/java/lib/wintmain/libwNet/cache/ForceCache.kt create mode 100644 app-catalog/samples/wNet/libwNet/src/main/java/lib/wintmain/libwNet/component/Progress.kt create mode 100644 app-catalog/samples/wNet/libwNet/src/main/java/lib/wintmain/libwNet/convert/JSONConvert.kt create mode 100644 app-catalog/samples/wNet/libwNet/src/main/java/lib/wintmain/libwNet/convert/NetConverter.kt create mode 100644 app-catalog/samples/wNet/libwNet/src/main/java/lib/wintmain/libwNet/cookie/PersistentCookieJar.kt create mode 100644 app-catalog/samples/wNet/libwNet/src/main/java/lib/wintmain/libwNet/exception/ConvertException.kt create mode 100644 app-catalog/samples/wNet/libwNet/src/main/java/lib/wintmain/libwNet/exception/DownloadFileException.kt create mode 100644 app-catalog/samples/wNet/libwNet/src/main/java/lib/wintmain/libwNet/exception/HttpFailureException.kt create mode 100644 app-catalog/samples/wNet/libwNet/src/main/java/lib/wintmain/libwNet/exception/HttpResponseException.kt create mode 100644 app-catalog/samples/wNet/libwNet/src/main/java/lib/wintmain/libwNet/exception/NetCancellationException.kt create mode 100644 app-catalog/samples/wNet/libwNet/src/main/java/lib/wintmain/libwNet/exception/NetConnectException.kt create mode 100644 app-catalog/samples/wNet/libwNet/src/main/java/lib/wintmain/libwNet/exception/NetException.kt create mode 100644 app-catalog/samples/wNet/libwNet/src/main/java/lib/wintmain/libwNet/exception/NetSocketTimeoutException.kt create mode 100644 app-catalog/samples/wNet/libwNet/src/main/java/lib/wintmain/libwNet/exception/NetUnknownHostException.kt create mode 100644 app-catalog/samples/wNet/libwNet/src/main/java/lib/wintmain/libwNet/exception/NetworkingException.kt create mode 100644 app-catalog/samples/wNet/libwNet/src/main/java/lib/wintmain/libwNet/exception/NoCacheException.kt create mode 100644 app-catalog/samples/wNet/libwNet/src/main/java/lib/wintmain/libwNet/exception/RequestParamsException.kt create mode 100644 app-catalog/samples/wNet/libwNet/src/main/java/lib/wintmain/libwNet/exception/ResponseException.kt create mode 100644 app-catalog/samples/wNet/libwNet/src/main/java/lib/wintmain/libwNet/exception/ServerResponseException.kt create mode 100644 app-catalog/samples/wNet/libwNet/src/main/java/lib/wintmain/libwNet/exception/URLParseException.kt create mode 100644 app-catalog/samples/wNet/libwNet/src/main/java/lib/wintmain/libwNet/interceptor/LogRecordInterceptor.kt create mode 100644 app-catalog/samples/wNet/libwNet/src/main/java/lib/wintmain/libwNet/interceptor/NetOkHttpInterceptor.kt create mode 100644 app-catalog/samples/wNet/libwNet/src/main/java/lib/wintmain/libwNet/interceptor/RequestInterceptor.kt create mode 100644 app-catalog/samples/wNet/libwNet/src/main/java/lib/wintmain/libwNet/interceptor/RetryInterceptor.kt create mode 100644 app-catalog/samples/wNet/libwNet/src/main/java/lib/wintmain/libwNet/interfaces/NetDialogFactory.kt create mode 100644 app-catalog/samples/wNet/libwNet/src/main/java/lib/wintmain/libwNet/interfaces/NetErrorHandler.kt create mode 100644 app-catalog/samples/wNet/libwNet/src/main/java/lib/wintmain/libwNet/interfaces/ProgressListener.kt create mode 100644 app-catalog/samples/wNet/libwNet/src/main/java/lib/wintmain/libwNet/internal/NetDeferred.kt create mode 100644 app-catalog/samples/wNet/libwNet/src/main/java/lib/wintmain/libwNet/internal/NetInitializer.kt create mode 100644 app-catalog/samples/wNet/libwNet/src/main/java/lib/wintmain/libwNet/log/LogRecorder.kt create mode 100644 app-catalog/samples/wNet/libwNet/src/main/java/lib/wintmain/libwNet/log/MessageType.kt create mode 100644 app-catalog/samples/wNet/libwNet/src/main/java/lib/wintmain/libwNet/okhttp/OkHttpBuilder.kt create mode 100644 app-catalog/samples/wNet/libwNet/src/main/java/lib/wintmain/libwNet/okhttp/OkHttpExtension.kt create mode 100644 app-catalog/samples/wNet/libwNet/src/main/java/lib/wintmain/libwNet/reflect/TypeUtils.kt create mode 100644 app-catalog/samples/wNet/libwNet/src/main/java/lib/wintmain/libwNet/request/BaseRequest.kt create mode 100644 app-catalog/samples/wNet/libwNet/src/main/java/lib/wintmain/libwNet/request/BodyRequest.kt create mode 100644 app-catalog/samples/wNet/libwNet/src/main/java/lib/wintmain/libwNet/request/MediaConst.kt create mode 100644 app-catalog/samples/wNet/libwNet/src/main/java/lib/wintmain/libwNet/request/Method.kt create mode 100644 app-catalog/samples/wNet/libwNet/src/main/java/lib/wintmain/libwNet/request/RequestBuilder.kt create mode 100644 app-catalog/samples/wNet/libwNet/src/main/java/lib/wintmain/libwNet/request/RequestExtension.kt create mode 100644 app-catalog/samples/wNet/libwNet/src/main/java/lib/wintmain/libwNet/request/UrlRequest.kt create mode 100644 app-catalog/samples/wNet/libwNet/src/main/java/lib/wintmain/libwNet/response/ResponseExtension.kt create mode 100644 app-catalog/samples/wNet/libwNet/src/main/java/lib/wintmain/libwNet/scope/AndroidScope.kt create mode 100644 app-catalog/samples/wNet/libwNet/src/main/java/lib/wintmain/libwNet/scope/DialogCoroutineScope.kt create mode 100644 app-catalog/samples/wNet/libwNet/src/main/java/lib/wintmain/libwNet/scope/NetCoroutineScope.kt create mode 100644 app-catalog/samples/wNet/libwNet/src/main/java/lib/wintmain/libwNet/scope/PageCoroutineScope.kt create mode 100644 app-catalog/samples/wNet/libwNet/src/main/java/lib/wintmain/libwNet/scope/StateCoroutineScope.kt create mode 100644 app-catalog/samples/wNet/libwNet/src/main/java/lib/wintmain/libwNet/scope/ViewCoroutineScope.kt create mode 100644 app-catalog/samples/wNet/libwNet/src/main/java/lib/wintmain/libwNet/tag/NetTag.kt create mode 100644 app-catalog/samples/wNet/libwNet/src/main/java/lib/wintmain/libwNet/time/Interval.kt create mode 100644 app-catalog/samples/wNet/libwNet/src/main/java/lib/wintmain/libwNet/time/IntervalStatus.kt create mode 100644 app-catalog/samples/wNet/libwNet/src/main/java/lib/wintmain/libwNet/transform/DeferredTransform.kt create mode 100644 app-catalog/samples/wNet/libwNet/src/main/java/lib/wintmain/libwNet/utils/Fastest.kt create mode 100644 app-catalog/samples/wNet/libwNet/src/main/java/lib/wintmain/libwNet/utils/FileUtils.kt create mode 100644 app-catalog/samples/wNet/libwNet/src/main/java/lib/wintmain/libwNet/utils/FlowUtils.kt create mode 100644 app-catalog/samples/wNet/libwNet/src/main/java/lib/wintmain/libwNet/utils/Https.kt create mode 100644 app-catalog/samples/wNet/libwNet/src/main/java/lib/wintmain/libwNet/utils/NetUtils.kt create mode 100644 app-catalog/samples/wNet/libwNet/src/main/java/lib/wintmain/libwNet/utils/Scope.kt create mode 100644 app-catalog/samples/wNet/libwNet/src/main/java/lib/wintmain/libwNet/utils/Suspend.kt create mode 100644 app-catalog/samples/wNet/libwNet/src/main/java/lib/wintmain/libwNet/utils/TipUtils.kt create mode 100644 app-catalog/samples/wNet/libwNet/src/main/java/lib/wintmain/libwNet/utils/Uri.kt create mode 100644 app-catalog/samples/wNet/libwNet/src/main/res/drawable/ic_limited_time.xml create mode 100644 app-catalog/samples/wNet/libwNet/src/main/res/drawable/ic_timing.xml create mode 100644 app-catalog/samples/wNet/libwNet/src/main/res/values/strings.xml create mode 100644 app-catalog/samples/wNet/libwNet/src/main/res/xml/network_security_config.xml diff --git a/app-catalog/samples/wNet/libwNet/build.gradle.kts b/app-catalog/samples/wNet/libwNet/build.gradle.kts new file mode 100644 index 0000000..85f08b4 --- /dev/null +++ b/app-catalog/samples/wNet/libwNet/build.gradle.kts @@ -0,0 +1,51 @@ +/* + * Copyright 2023-2025 wintmain + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +plugins { + id("com.android.library") + id("org.jetbrains.kotlin.android") +} + +android { + namespace = "lib.wintmain.libwNet" + compileSdk = 35 + + defaultConfig { + minSdk = 26 + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + kotlinOptions { + jvmTarget = "17" + freeCompilerArgs = listOf("-Xinline-classes", "-Xallow-result-return-type") + } +} + +dependencies { + implementation(libs.androidx.appcompat) + implementation(libs.androidx.startup) + implementation(libs.androidx.lifecycle.runtime.ktx) + implementation(libs.androidx.documentfile) + + compileOnly(libs.okhttp) + compileOnly("com.github.liangjingkanji:BRV:1.5.2") + compileOnly(libs.kotlinx.coroutines.core) + compileOnly(libs.kotlinx.coroutines.android) +} \ No newline at end of file diff --git a/app-catalog/samples/wNet/libwNet/src/main/AndroidManifest.xml b/app-catalog/samples/wNet/libwNet/src/main/AndroidManifest.xml new file mode 100644 index 0000000..9f7c384 --- /dev/null +++ b/app-catalog/samples/wNet/libwNet/src/main/AndroidManifest.xml @@ -0,0 +1,36 @@ + + + + + + + + + + + + + \ No newline at end of file diff --git a/app-catalog/samples/wNet/libwNet/src/main/java/lib/wintmain/libwNet/Net.kt b/app-catalog/samples/wNet/libwNet/src/main/java/lib/wintmain/libwNet/Net.kt new file mode 100644 index 0000000..484267a --- /dev/null +++ b/app-catalog/samples/wNet/libwNet/src/main/java/lib/wintmain/libwNet/Net.kt @@ -0,0 +1,387 @@ +/* + * Copyright 2023-2025 wintmain + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +@file:Suppress("unused", "FunctionName") @file:JvmName("NetKt") + +package lib.wintmain.libwNet + +import android.util.Log +import lib.wintmain.libwNet.interfaces.ProgressListener +import lib.wintmain.libwNet.request.BodyRequest +import lib.wintmain.libwNet.request.Method +import lib.wintmain.libwNet.request.UrlRequest +import lib.wintmain.libwNet.request.downloadListeners +import lib.wintmain.libwNet.request.group +import lib.wintmain.libwNet.request.id +import lib.wintmain.libwNet.request.uploadListeners +import okhttp3.Request + +object Net { + + // + /** + * 同步网络请求 + * + * @param path 请求路径, 如果其不包含http/https则会自动拼接[NetConfig.host] + * @param tag 可以传递对象给Request请求, 一般用于在拦截器/转换器中进行针对某个接口行为判断 + * @param block 函数中可以配置请求参数 + */ + @JvmOverloads + @JvmStatic + fun get( + path: String, + tag: Any? = null, + block: (UrlRequest.() -> Unit)? = null + ) = UrlRequest().apply { + setPath(path) + method = Method.GET + tag(tag) + block?.invoke(this) + } + + /** + * 同步网络请求 + * + * @param path 请求路径, 如果其不包含http/https则会自动拼接[NetConfig.host] + * @param tag 可以传递对象给Request请求, 一般用于在拦截器/转换器中进行针对某个接口行为判断 + * @param block 函数中可以配置请求参数 + */ + @JvmOverloads + @JvmStatic + fun post( + path: String, + tag: Any? = null, + block: (BodyRequest.() -> Unit)? = null + ) = BodyRequest().apply { + setPath(path) + method = Method.POST + tag(tag) + block?.invoke(this) + } + + /** + * 同步网络请求 + * + * @param path 请求路径, 如果其不包含http/https则会自动拼接[NetConfig.host] + * @param tag 可以传递对象给Request请求, 一般用于在拦截器/转换器中进行针对某个接口行为判断 + * @param block 函数中可以配置请求参数 + */ + @JvmOverloads + @JvmStatic + fun head( + path: String, + tag: Any? = null, + block: (UrlRequest.() -> Unit)? = null + ) = UrlRequest().apply { + setPath(path) + method = Method.HEAD + tag(tag) + block?.invoke(this) + } + + /** + * 同步网络请求 + * + * @param path 请求路径, 如果其不包含http/https则会自动拼接[NetConfig.host] + * @param tag 可以传递对象给Request请求, 一般用于在拦截器/转换器中进行针对某个接口行为判断 + * @param block 函数中可以配置请求参数 + */ + @JvmOverloads + @JvmStatic + fun options( + path: String, + tag: Any? = null, + block: (UrlRequest.() -> Unit)? = null + ) = UrlRequest().apply { + setPath(path) + method = Method.OPTIONS + tag(tag) + block?.invoke(this) + } + + /** + * 同步网络请求 + * + * @param path 请求路径, 如果其不包含http/https则会自动拼接[NetConfig.host] + * @param tag 可以传递对象给Request请求, 一般用于在拦截器/转换器中进行针对某个接口行为判断 + * @param block 函数中可以配置请求参数 + */ + @JvmOverloads + @JvmStatic + fun trace( + path: String, + tag: Any? = null, + block: (UrlRequest.() -> Unit)? = null + ) = UrlRequest().apply { + setPath(path) + method = Method.TRACE + tag(tag) + block?.invoke(this) + } + + /** + * 同步网络请求 + * + * @param path 请求路径, 如果其不包含http/https则会自动拼接[NetConfig.host] + * @param tag 可以传递对象给Request请求, 一般用于在拦截器/转换器中进行针对某个接口行为判断 + * @param block 函数中可以配置请求参数 + */ + @JvmOverloads + @JvmStatic + fun delete( + path: String, + tag: Any? = null, + block: (BodyRequest.() -> Unit)? = null + ) = BodyRequest().apply { + setPath(path) + method = Method.DELETE + tag(tag) + block?.invoke(this) + } + + /** + * 同步网络请求 + * + * @param path 请求路径, 如果其不包含http/https则会自动拼接[NetConfig.host] + * @param tag 可以传递对象给Request请求, 一般用于在拦截器/转换器中进行针对某个接口行为判断 + * @param block 函数中可以配置请求参数 + */ + @JvmOverloads + @JvmStatic + fun put( + path: String, + tag: Any? = null, + block: (BodyRequest.() -> Unit)? = null + ) = BodyRequest().apply { + setPath(path) + method = Method.PUT + tag(tag) + block?.invoke(this) + } + + /** + * 同步网络请求 + * + * @param path 请求路径, 如果其不包含http/https则会自动拼接[NetConfig.host] + * @param tag 可以传递对象给Request请求, 一般用于在拦截器/转换器中进行针对某个接口行为判断 + * @param block 函数中可以配置请求参数 + */ + @JvmOverloads + @JvmStatic + fun patch( + path: String, + tag: Any? = null, + block: (BodyRequest.() -> Unit)? = null + ) = BodyRequest().apply { + setPath(path) + method = Method.PATCH + tag(tag) + block?.invoke(this) + } + // + + // + /** + * 取消全部网络请求 + */ + @JvmStatic + fun cancelAll() { + NetConfig.okHttpClient.dispatcher.cancelAll() + val iterator = NetConfig.runningCalls.iterator() + while (iterator.hasNext()) { + iterator.next().get()?.cancel() + iterator.remove() + } + } + + /** + * 取消指定的网络请求, Id理论上是唯一的, 所以该函数一次只能取消一个请求 + * @return 如果成功取消返回true + * @see lib.wintmain.libwNet.request.BaseRequest.setId + */ + @JvmStatic + fun cancelId(id: Any?): Boolean { + if (id == null) return false + val iterator = NetConfig.runningCalls.iterator() + while (iterator.hasNext()) { + val call = iterator.next().get() + if (call == null) { + iterator.remove() + continue + } + if (id == call.request().id) { + call.cancel() + iterator.remove() + return true + } + } + return false + } + + /** + * 根据分组取消网络请求 + * @return 如果成功取消返回true, 无论取消个数 + * @see lib.wintmain.libwNet.request.BaseRequest.setGroup + */ + @JvmStatic + fun cancelGroup(group: Any?): Boolean { + if (group == null) return false + val iterator = NetConfig.runningCalls.iterator() + var hasCancel = false + while (iterator.hasNext()) { + val call = iterator.next().get() + if (call == null) { + iterator.remove() + continue + } + if (group == call.request().group) { + call.cancel() + iterator.remove() + hasCancel = true + } + } + return hasCancel + } + // + + // + /** + * 监听正在请求的上传进度 + * @param id 请求的Id + * @see lib.wintmain.libwNet.request.BaseRequest.setId + */ + @JvmStatic + fun addUploadListener(id: Any, progressListener: ProgressListener): Boolean { + getRequestById(id)?.let { request -> + request.uploadListeners().add(progressListener) + return true + } + return false + } + + /** + * 删除正在请求的上传进度监听器 + * @param id 请求的Id + * @see lib.wintmain.libwNet.request.BaseRequest.setId + */ + @JvmStatic + fun removeUploadListener(id: Any, progressListener: ProgressListener): Boolean { + getRequestById(id)?.let { request -> + request.uploadListeners().remove(progressListener) + return true + } + return false + } + + /** + * 监听正在请求的下载进度 + * @param id 请求的Id + * @see lib.wintmain.libwNet.request.BaseRequest.setId + */ + @JvmStatic + fun addDownloadListener(id: Any, progressListener: ProgressListener): Boolean { + getRequestById(id)?.let { request -> + request.downloadListeners().add(progressListener) + return true + } + return false + } + + /** + * 删除正在请求的下载进度监听器 + * + * @param id 请求的Id + * @see lib.wintmain.libwNet.request.BaseRequest.setId + */ + @JvmStatic + fun removeDownloadListener(id: Any, progressListener: ProgressListener): Boolean { + getRequestById(id)?.let { request -> + request.downloadListeners().remove(progressListener) + return true + } + return false + } + + // + + // + + /** + * 根据ID获取请求对象 + * @see lib.wintmain.libwNet.request.BaseRequest.setId + */ + @JvmStatic + fun getRequestById(id: Any): Request? { + val iterator = NetConfig.runningCalls.iterator() + while (iterator.hasNext()) { + val call = iterator.next().get() + if (call == null) { + iterator.remove() + continue + } + val request = call.request() + if (id == request.id) { + return request + } + } + return null + } + + /** + * 根据Group获取请求对象 + * @see lib.wintmain.libwNet.request.BaseRequest.setGroup + */ + @JvmStatic + fun getRequestByGroup(group: Any): MutableList { + val requests = mutableListOf() + val iterator = NetConfig.runningCalls.iterator() + while (iterator.hasNext()) { + val call = iterator.next().get() + if (call == null) { + iterator.remove() + continue + } + val request = call.request() + if (group == request.group) { + requests.add(request) + } + } + return requests + } + // + + // + /** + * 输出异常日志 + * @param message 如果非[Throwable]则会自动追加代码位置(文件:行号) + * @see NetConfig.debug + */ + @JvmStatic + fun debug(message: Any) { + if (NetConfig.debug) { + val adjustMessage = if (message is Throwable) { + message.stackTraceToString() + } else { + val occurred = + Throwable().stackTrace.getOrNull(1)?.run { " (${fileName}:${lineNumber})" } + ?: "" + message.toString() + occurred + } + Log.d(NetConfig.TAG, adjustMessage) + } + } + // +} diff --git a/app-catalog/samples/wNet/libwNet/src/main/java/lib/wintmain/libwNet/NetConfig.kt b/app-catalog/samples/wNet/libwNet/src/main/java/lib/wintmain/libwNet/NetConfig.kt new file mode 100644 index 0000000..43734c6 --- /dev/null +++ b/app-catalog/samples/wNet/libwNet/src/main/java/lib/wintmain/libwNet/NetConfig.kt @@ -0,0 +1,113 @@ +/* + * Copyright 2023-2025 wintmain + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package lib.wintmain.libwNet + +import android.annotation.SuppressLint +import android.content.Context +import lib.wintmain.libwNet.cache.ForceCache +import lib.wintmain.libwNet.convert.NetConverter +import lib.wintmain.libwNet.interceptor.RequestInterceptor +import lib.wintmain.libwNet.interfaces.NetDialogFactory +import lib.wintmain.libwNet.interfaces.NetErrorHandler +import lib.wintmain.libwNet.okhttp.toNetOkhttp +import okhttp3.Call +import okhttp3.OkHttpClient +import java.lang.ref.WeakReference +import java.util.concurrent.ConcurrentLinkedQueue + +/** + * Net的全局配置 + */ +@SuppressLint("StaticFieldLeak") +object NetConfig { + + lateinit var app: Context + + /** 全局域名 */ + var host: String = "" + + /** 全局单例请求客户端 */ + var okHttpClient: OkHttpClient = OkHttpClient.Builder().toNetOkhttp().build() + set(value) { + field = value.toNetOkhttp() + forceCache = field.cache?.let { ForceCache(OkHttpUtils.diskLruCache(it)) } + } + + /** + * 强制缓存配置. 不允许直接设置, 因为整个框架只允许存在一个缓存配置管理, 所以请使用[OkHttpClient.Builder.cache] + * 强制缓存会无视标准Http协议强制缓存任何数据 + * 缓存目录和缓存最大值设置见: [okhttp3.Cache] + */ + internal var forceCache: ForceCache? = null + + /** 是否启用日志 */ + var debug = true + + /** 网络异常日志的标签 */ + var TAG = "NET_LOG" + + /** 运行中的请求 */ + var runningCalls: ConcurrentLinkedQueue> = ConcurrentLinkedQueue() + private set + + /** 请求拦截器 */ + var requestInterceptor: RequestInterceptor? = null + + /** 响应数据转换器 */ + var converter: NetConverter = NetConverter + + /** 错误处理器 */ + var errorHandler: NetErrorHandler = NetErrorHandler + + /** 请求对话框构建工厂 */ + var dialogFactory: NetDialogFactory = NetDialogFactory + + // + /** + * 初始化框架 + * 不初始化也可以使用, 但是App使用多进程情况下要求为[NetConfig.host]或者[context]赋值, 否则会导致无法正常吐司或其他意外问题 + * @param host 请求url的主机名 + * @param context 如果应用存在多进程请指定此参数初始化[NetConfig.app] + * @param config 进行配置网络请求 + */ + fun initialize( + host: String = "", + context: Context? = null, + config: OkHttpClient.Builder.() -> Unit = {} + ) { + NetConfig.host = host + context?.let { app = it } + val builder = OkHttpClient.Builder() + builder.config() + okHttpClient = builder.toNetOkhttp().build() + } + + /** + * 初始化框架 + * 不初始化也可以使用, 但是App使用多进程情况下要求为[NetConfig.host]或者[context]赋值, 否则会导致无法正常吐司或其他意外问题 + * @param host 请求url的主机名, 该参数会在每次请求时自动和请求路径进行拼接(如果路径包含https/http则不会拼接) + * @param context 如果应用存在多进程请指定此参数初始化[NetConfig.app] + * @param config 进行配置网络请求 + */ + fun initialize(host: String = "", context: Context? = null, config: OkHttpClient.Builder) { + NetConfig.host = host + context?.let { app = it } + okHttpClient = config.toNetOkhttp().build() + } + // +} + diff --git a/app-catalog/samples/wNet/libwNet/src/main/java/lib/wintmain/libwNet/NetCoroutine.kt b/app-catalog/samples/wNet/libwNet/src/main/java/lib/wintmain/libwNet/NetCoroutine.kt new file mode 100644 index 0000000..1073fa1 --- /dev/null +++ b/app-catalog/samples/wNet/libwNet/src/main/java/lib/wintmain/libwNet/NetCoroutine.kt @@ -0,0 +1,218 @@ +/* + * Copyright 2023-2025 wintmain + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +@file:Suppress("FunctionName") + +package lib.wintmain.libwNet + +import kotlinx.coroutines.CoroutineExceptionHandler +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Deferred +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.async +import kotlinx.coroutines.ensureActive +import lib.wintmain.libwNet.internal.NetDeferred +import lib.wintmain.libwNet.request.BodyRequest +import lib.wintmain.libwNet.request.Method.DELETE +import lib.wintmain.libwNet.request.Method.GET +import lib.wintmain.libwNet.request.Method.HEAD +import lib.wintmain.libwNet.request.Method.OPTIONS +import lib.wintmain.libwNet.request.Method.PATCH +import lib.wintmain.libwNet.request.Method.POST +import lib.wintmain.libwNet.request.Method.PUT +import lib.wintmain.libwNet.request.Method.TRACE +import lib.wintmain.libwNet.request.UrlRequest + +// + +/** + * 异步网络请求 + * + * @param path 请求路径, 如果其不包含http/https则会自动拼接[NetConfig.host] + * @param tag 可以传递对象给Request, 一般用于在拦截器/转换器中进行针对某个接口行为判断 + * @param block 函数中可以配置请求参数 + */ +inline fun CoroutineScope.Get( + path: String, + tag: Any? = null, + noinline block: (UrlRequest.() -> Unit)? = null +): Deferred = NetDeferred(async(Dispatchers.IO + SupervisorJob()) { + coroutineContext.ensureActive() + UrlRequest().apply { + setPath(path) + method = GET + setGroup(coroutineContext[CoroutineExceptionHandler]) + tag(tag) + block?.invoke(this) + }.execute() +}) + +/** + * 异步网络请求 + * + * @param path 请求路径, 如果其不包含http/https则会自动拼接[NetConfig.host] + * @param tag 可以传递对象给Request, 一般用于在拦截器/转换器中进行针对某个接口行为判断 + * @param block 函数中可以配置请求参数 + */ +inline fun CoroutineScope.Post( + path: String, + tag: Any? = null, + noinline block: (BodyRequest.() -> Unit)? = null +): Deferred = NetDeferred(async(Dispatchers.IO + SupervisorJob()) { + coroutineContext.ensureActive() + BodyRequest().apply { + setPath(path) + method = POST + setGroup(coroutineContext[CoroutineExceptionHandler]) + tag(tag) + block?.invoke(this) + }.execute() +}) + +/** + * 异步网络请求 + * + * @param path 请求路径, 如果其不包含http/https则会自动拼接[NetConfig.host] + * @param tag 可以传递对象给Request, 一般用于在拦截器/转换器中进行针对某个接口行为判断 + * @param block 函数中可以配置请求参数 + */ +inline fun CoroutineScope.Head( + path: String, + tag: Any? = null, + noinline block: (UrlRequest.() -> Unit)? = null +): Deferred = NetDeferred(async(Dispatchers.IO + SupervisorJob()) { + coroutineContext.ensureActive() + UrlRequest().apply { + setPath(path) + method = HEAD + setGroup(coroutineContext[CoroutineExceptionHandler]) + tag(tag) + block?.invoke(this) + }.execute() +}) + +/** + * 异步网络请求 + * + * @param path 请求路径, 如果其不包含http/https则会自动拼接[NetConfig.host] + * @param tag 可以传递对象给Request, 一般用于在拦截器/转换器中进行针对某个接口行为判断 + * @param block 函数中可以配置请求参数 + */ +inline fun CoroutineScope.Options( + path: String, + tag: Any? = null, + noinline block: (UrlRequest.() -> Unit)? = null +): Deferred = NetDeferred(async(Dispatchers.IO + SupervisorJob()) { + coroutineContext.ensureActive() + UrlRequest().apply { + setPath(path) + method = OPTIONS + setGroup(coroutineContext[CoroutineExceptionHandler]) + tag(tag) + block?.invoke(this) + }.execute() +}) + +/** + * 异步网络请求 + * + * @param path 请求路径, 如果其不包含http/https则会自动拼接[NetConfig.host] + * @param tag 可以传递对象给Request, 一般用于在拦截器/转换器中进行针对某个接口行为判断 + * @param block 函数中可以配置请求参数 + */ +inline fun CoroutineScope.Trace( + path: String, + tag: Any? = null, + noinline block: (UrlRequest.() -> Unit)? = null +): Deferred = NetDeferred(async(Dispatchers.IO + SupervisorJob()) { + coroutineContext.ensureActive() + UrlRequest().apply { + setPath(path) + method = TRACE + setGroup(coroutineContext[CoroutineExceptionHandler]) + tag(tag) + block?.invoke(this) + }.execute() +}) + +/** + * 异步网络请求 + * + * @param path 请求路径, 如果其不包含http/https则会自动拼接[NetConfig.host] + * @param tag 可以传递对象给Request, 一般用于在拦截器/转换器中进行针对某个接口行为判断 + * @param block 函数中可以配置请求参数 + */ +inline fun CoroutineScope.Delete( + path: String, + tag: Any? = null, + noinline block: (BodyRequest.() -> Unit)? = null +): Deferred = NetDeferred(async(Dispatchers.IO + SupervisorJob()) { + coroutineContext.ensureActive() + BodyRequest().apply { + setPath(path) + method = DELETE + setGroup(coroutineContext[CoroutineExceptionHandler]) + tag(tag) + block?.invoke(this) + }.execute() +}) + +/** + * 异步网络请求 + * + * @param path 请求路径, 如果其不包含http/https则会自动拼接[NetConfig.host] + * @param tag 可以传递对象给Request, 一般用于在拦截器/转换器中进行针对某个接口行为判断 + * @param block 函数中可以配置请求参数 + */ +inline fun CoroutineScope.Put( + path: String, + tag: Any? = null, + noinline block: (BodyRequest.() -> Unit)? = null +): Deferred = NetDeferred(async(Dispatchers.IO + SupervisorJob()) { + coroutineContext.ensureActive() + BodyRequest().apply { + setPath(path) + method = PUT + setGroup(coroutineContext[CoroutineExceptionHandler]) + tag(tag) + block?.invoke(this) + }.execute() +}) + +/** + * 异步网络请求 + * + * @param path 请求路径, 如果其不包含http/https则会自动拼接[NetConfig.host] + * @param tag 可以传递对象给Request, 一般用于在拦截器/转换器中进行针对某个接口行为判断 + * @param block 函数中可以配置请求参数 + */ +inline fun CoroutineScope.Patch( + path: String, + tag: Any? = null, + noinline block: (BodyRequest.() -> Unit)? = null +): Deferred = NetDeferred(async(Dispatchers.IO + SupervisorJob()) { + coroutineContext.ensureActive() + BodyRequest().apply { + setPath(path) + method = PATCH + setGroup(coroutineContext[CoroutineExceptionHandler]) + tag(tag) + block?.invoke(this) + }.execute() +} +) +// \ No newline at end of file diff --git a/app-catalog/samples/wNet/libwNet/src/main/java/lib/wintmain/libwNet/OkHttpUtils.java b/app-catalog/samples/wNet/libwNet/src/main/java/lib/wintmain/libwNet/OkHttpUtils.java new file mode 100644 index 0000000..d23ded7 --- /dev/null +++ b/app-catalog/samples/wNet/libwNet/src/main/java/lib/wintmain/libwNet/OkHttpUtils.java @@ -0,0 +1,77 @@ +/* + * Copyright 2023-2025 wintmain + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package lib.wintmain.libwNet; + +import kotlin.jvm.JvmStatic; +import okhttp3.Cache; +import okhttp3.Headers; +import okhttp3.Request; +import okhttp3.internal.cache.DiskLruCache; + +import java.lang.reflect.Field; +import java.util.LinkedHashMap; +import java.util.Map; + +@SuppressWarnings("KotlinInternalInJava") +public class OkHttpUtils { + + /** + * 标签集合 + */ + @JvmStatic + public static Map, Object> tags(Request.Builder builder) { + return builder.getTags$okhttp(); + } + + /** + * 通过反射返回Request的标签可变集合 + */ + @JvmStatic + public static Map, Object> tags(Request request) + throws NoSuchFieldException, IllegalAccessException { + Map, Object> tagsOkhttp = request.getTags$okhttp(); + if (tagsOkhttp.isEmpty()) { + Field tagsField = request.getClass().getDeclaredField("tags"); + tagsField.setAccessible(true); + LinkedHashMap, Object> tags = new LinkedHashMap<>(); + tagsField.set(request, tags); + return tags; + } + Field tagsField = tagsOkhttp.getClass().getDeclaredField("m"); + tagsField.setAccessible(true); + Object tags = tagsField.get(tagsOkhttp); + return (Map, Object>) tags; + } + + /** + * 全部的请求头 + */ + @JvmStatic + public static Headers.Builder headers(Request.Builder builder) { + return builder.getHeaders$okhttp(); + } + + @JvmStatic + public static Headers.Builder addLenient(Headers.Builder builder, String line) { + return builder.addLenient$okhttp(line); + } + + @JvmStatic + public static DiskLruCache diskLruCache(Cache cache) { + return cache.getCache$okhttp(); + } +} diff --git a/app-catalog/samples/wNet/libwNet/src/main/java/lib/wintmain/libwNet/Scope.kt b/app-catalog/samples/wNet/libwNet/src/main/java/lib/wintmain/libwNet/Scope.kt new file mode 100644 index 0000000..6d3708e --- /dev/null +++ b/app-catalog/samples/wNet/libwNet/src/main/java/lib/wintmain/libwNet/Scope.kt @@ -0,0 +1,55 @@ +/* + * Copyright 2023-2025 wintmain + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package lib.wintmain.libwNet + +import androidx.lifecycle.ViewModel +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import lib.wintmain.libwNet.scope.AndroidScope +import lib.wintmain.libwNet.scope.NetCoroutineScope +import lib.wintmain.libwNet.time.Interval + +/** + * 在[ViewModel]被销毁时取消协程作用域 + */ +fun ViewModel.scopeLife( + dispatcher: CoroutineDispatcher = Dispatchers.Main, + block: suspend CoroutineScope.() -> Unit +): AndroidScope { + val scope = AndroidScope(dispatcher = dispatcher).launch(block) + addCloseable(scope) + return scope +} + +/** + * 在[ViewModel]被销毁时取消协程作用域以及其中的网络请求 + * 具备网络错误全局处理功能, 其内部的网络请求会跟随其作用域的生命周期 + */ +fun ViewModel.scopeNetLife( + dispatcher: CoroutineDispatcher = Dispatchers.Main, + block: suspend CoroutineScope.() -> Unit +): NetCoroutineScope { + val scope = NetCoroutineScope(dispatcher = dispatcher).launch(block) + addCloseable(scope) + return scope +} + +/** 轮询器根据ViewModel生命周期自动取消 */ +fun Interval.life(viewModel: ViewModel) = apply { + viewModel.addCloseable(this) +} \ No newline at end of file diff --git a/app-catalog/samples/wNet/libwNet/src/main/java/lib/wintmain/libwNet/body/BodyExtension.kt b/app-catalog/samples/wNet/libwNet/src/main/java/lib/wintmain/libwNet/body/BodyExtension.kt new file mode 100644 index 0000000..0bae815 --- /dev/null +++ b/app-catalog/samples/wNet/libwNet/src/main/java/lib/wintmain/libwNet/body/BodyExtension.kt @@ -0,0 +1,83 @@ +/* + * Copyright 2023-2025 wintmain + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package lib.wintmain.libwNet.body + +import lib.wintmain.libwNet.interfaces.ProgressListener +import okhttp3.MultipartBody +import okhttp3.RequestBody +import okhttp3.ResponseBody +import okio.Buffer +import okio.ByteString +import java.util.concurrent.ConcurrentLinkedQueue + +fun RequestBody.toNetRequestBody(listeners: ConcurrentLinkedQueue? = null) = + NetRequestBody(this, listeners) + +fun ResponseBody.toNetResponseBody( + listeners: ConcurrentLinkedQueue? = null, complete: (() -> Unit)? = null +) = NetResponseBody(this, listeners, complete) + +/** + * 复制一段指定长度的字符串内容 + * @param byteCount 复制的字节长度, 允许超过实际长度, 如果-1则返回完整的字符串内容 + */ +fun RequestBody.peekBytes(byteCount: Long = 1024 * 1024): ByteString { + val buffer = Buffer() + writeTo(buffer) + val maxSize = if (byteCount < 0) buffer.size else minOf(buffer.size, byteCount) + return buffer.readByteString(maxSize) +} + +/** + * 复制一段指定长度的字符串内容 + * @param byteCount 复制的字节长度, 允许超过实际长度, 如果-1则返回完整的字符串内容 + */ +fun ResponseBody.peekBytes(byteCount: Long = 1024 * 1024): ByteString { + val peeked = source().peek() + peeked.request(byteCount) + val maxSize = if (byteCount < 0) peeked.buffer.size else minOf(byteCount, peeked.buffer.size) + return peeked.readByteString(maxSize) +} + +/** + * 获取Content-Disposition里面的filename属性值 + * 可以此来判断是否为文件类型 + */ +fun MultipartBody.Part.fileName(): String? { + val contentDisposition = headers?.get("Content-Disposition") ?: return null + val regex = ";\\s${"filename"}=\"(.+?)\"".toRegex() + val matchResult = regex.find(contentDisposition) + return matchResult?.groupValues?.getOrNull(1) +} + +/** + * 获取Content-Disposition里面的字段名称 + */ +fun MultipartBody.Part.name(): String? { + val contentDisposition = headers?.get("Content-Disposition") ?: return null + val regex = ";\\s${"name"}=\"(.+?)\"".toRegex() + val matchResult = regex.find(contentDisposition) + return matchResult?.groupValues?.getOrNull(1) +} + +/** + * 将[MultipartBody.Part.body]作为字符串返回 + * 如果[MultipartBody.Part]有指定fileName那么视为文件类型将返回fileName值而不是文件内容 + */ +fun MultipartBody.Part.value(): String? { + return fileName() ?: body.peekBytes().utf8() +} \ No newline at end of file diff --git a/app-catalog/samples/wNet/libwNet/src/main/java/lib/wintmain/libwNet/body/NetRequestBody.kt b/app-catalog/samples/wNet/libwNet/src/main/java/lib/wintmain/libwNet/body/NetRequestBody.kt new file mode 100644 index 0000000..03c5f3e --- /dev/null +++ b/app-catalog/samples/wNet/libwNet/src/main/java/lib/wintmain/libwNet/body/NetRequestBody.kt @@ -0,0 +1,99 @@ +/* + * Copyright 2023-2025 wintmain + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package lib.wintmain.libwNet.body + +import android.os.SystemClock +import lib.wintmain.libwNet.component.Progress +import lib.wintmain.libwNet.interfaces.ProgressListener +import okhttp3.MediaType +import okhttp3.RequestBody +import okhttp3.internal.closeQuietly +import okio.* +import java.io.IOException +import java.util.concurrent.ConcurrentLinkedQueue + +class NetRequestBody( + private val body: RequestBody, + private val progressListeners: ConcurrentLinkedQueue? = null +) : RequestBody() { + + private val progress = Progress() + private val contentLength by lazy { body.contentLength() } + + override fun contentType(): MediaType? { + return body.contentType() + } + + @Throws(IOException::class) + override fun contentLength(): Long { + return contentLength + } + + @Throws(IOException::class) + override fun writeTo(sink: BufferedSink) { + if (sink is Buffer || + sink.toString() + .contains("com.android.tools.profiler.support.network.HttpTracker\$OutputStreamTracker") + ) { + body.writeTo(sink) + } else { + val bufferedSink: BufferedSink = sink.toProgress().buffer() + body.writeTo(bufferedSink) + bufferedSink.closeQuietly() + if (contentLength == -1L) { + progressListeners?.forEach { progressListener -> + progressListener.onProgress( + progress.apply { + finish = true + } + ) + } + } + } + } + + private fun Sink.toProgress() = object : ForwardingSink(this) { + private var writeByteCount = 0L + + @Throws(IOException::class) + override fun write(source: Buffer, byteCount: Long) { + super.write(source, byteCount) + if (!progressListeners.isNullOrEmpty()) { + writeByteCount += byteCount + val currentElapsedTime = SystemClock.elapsedRealtime() + progressListeners.forEach { progressListener -> + progressListener.intervalByteCount += byteCount + val currentInterval = currentElapsedTime - progressListener.elapsedTime + if (!progress.finish && (writeByteCount == contentLength || currentInterval >= progressListener.interval)) { + if (writeByteCount == contentLength) { + progress.finish = true + } + progressListener.onProgress( + progress.apply { + currentByteCount = writeByteCount + totalByteCount = contentLength + intervalByteCount = progressListener.intervalByteCount + intervalTime = currentInterval + } + ) + progressListener.elapsedTime = currentElapsedTime + progressListener.intervalByteCount = 0L + } + } + } + } + } +} \ No newline at end of file diff --git a/app-catalog/samples/wNet/libwNet/src/main/java/lib/wintmain/libwNet/body/NetResponseBody.kt b/app-catalog/samples/wNet/libwNet/src/main/java/lib/wintmain/libwNet/body/NetResponseBody.kt new file mode 100644 index 0000000..db65caf --- /dev/null +++ b/app-catalog/samples/wNet/libwNet/src/main/java/lib/wintmain/libwNet/body/NetResponseBody.kt @@ -0,0 +1,87 @@ +/* + * Copyright 2023-2025 wintmain + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package lib.wintmain.libwNet.body + +import android.os.SystemClock +import lib.wintmain.libwNet.component.Progress +import lib.wintmain.libwNet.interfaces.ProgressListener +import okhttp3.MediaType +import okhttp3.ResponseBody +import okio.* +import java.io.IOException +import java.util.concurrent.ConcurrentLinkedQueue + +class NetResponseBody( + private val body: ResponseBody, + private val progressListeners: ConcurrentLinkedQueue? = null, + private val complete: (() -> Unit)? = null +) : ResponseBody() { + + private val progress = Progress() + private val bufferedSource by lazy { body.source().toProgress().buffer() } + private val contentLength by lazy { body.contentLength() } + + override fun contentType(): MediaType? { + return body.contentType() + } + + override fun contentLength(): Long { + return contentLength + } + + override fun source(): BufferedSource { + return bufferedSource + } + + private fun Source.toProgress() = object : ForwardingSource(this) { + private var readByteCount: Long = 0 + + @Throws(IOException::class) + override fun read(sink: Buffer, byteCount: Long): Long { + try { + val bytesRead = super.read(sink, byteCount) + if (!progressListeners.isNullOrEmpty()) { + readByteCount += if (bytesRead != -1L) bytesRead else 0 + val currentElapsedTime = SystemClock.elapsedRealtime() + progressListeners.forEach { progressListener -> + progressListener.intervalByteCount += if (bytesRead != -1L) bytesRead else 0 + val currentInterval = currentElapsedTime - progressListener.elapsedTime + if (!progress.finish && (readByteCount == contentLength || bytesRead == -1L || currentInterval >= progressListener.interval)) { + if (readByteCount == contentLength || bytesRead == -1L) { + progress.finish = true + } + progressListener.onProgress( + progress.apply { + currentByteCount = readByteCount + totalByteCount = contentLength + intervalByteCount = progressListener.intervalByteCount + intervalTime = currentInterval + } + ) + progressListener.elapsedTime = currentElapsedTime + progressListener.intervalByteCount = 0L + } + } + } + if (bytesRead == -1L) complete?.invoke() + return bytesRead + } catch (e: Exception) { + complete?.invoke() + throw e + } + } + } +} \ No newline at end of file diff --git a/app-catalog/samples/wNet/libwNet/src/main/java/lib/wintmain/libwNet/cache/CacheMode.kt b/app-catalog/samples/wNet/libwNet/src/main/java/lib/wintmain/libwNet/cache/CacheMode.kt new file mode 100644 index 0000000..ae03bfc --- /dev/null +++ b/app-catalog/samples/wNet/libwNet/src/main/java/lib/wintmain/libwNet/cache/CacheMode.kt @@ -0,0 +1,32 @@ +/* + * Copyright 2023-2025 wintmain + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package lib.wintmain.libwNet.cache + +enum class CacheMode { + + /** 只读取缓存, 本操作并不会请求网络故不存在写入缓存 */ + READ, + + /** 只请求网络, 强制写入缓存 */ + WRITE, + + /** 先从缓存读取,如果失败再从网络读取, 强制写入缓存 */ + READ_THEN_REQUEST, + + /** 先从网络读取,如果失败再从缓存读取, 强制写入缓存 */ + REQUEST_THEN_READ, +} \ No newline at end of file diff --git a/app-catalog/samples/wNet/libwNet/src/main/java/lib/wintmain/libwNet/cache/ForceCache.kt b/app-catalog/samples/wNet/libwNet/src/main/java/lib/wintmain/libwNet/cache/ForceCache.kt new file mode 100644 index 0000000..60a8715 --- /dev/null +++ b/app-catalog/samples/wNet/libwNet/src/main/java/lib/wintmain/libwNet/cache/ForceCache.kt @@ -0,0 +1,768 @@ +/* + * Copyright 2023-2025 wintmain + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package lib.wintmain.libwNet.cache + +import lib.wintmain.libwNet.OkHttpUtils +import lib.wintmain.libwNet.request.tagOf +import lib.wintmain.libwNet.tag.NetTag +import okhttp3.* +import okhttp3.MediaType.Companion.toMediaTypeOrNull +import okhttp3.internal.EMPTY_HEADERS +import okhttp3.internal.cache.CacheRequest +import okhttp3.internal.cache.DiskLruCache +import okhttp3.internal.closeQuietly +import okhttp3.internal.discard +import okhttp3.internal.http.ExchangeCodec +import okhttp3.internal.http.RealResponseBody +import okhttp3.internal.http.StatusLine +import okhttp3.internal.platform.Platform +import okhttp3.internal.toLongOrDefault +import okio.* +import okio.ByteString.Companion.decodeBase64 +import okio.ByteString.Companion.encodeUtf8 +import okio.ByteString.Companion.toByteString +import java.io.Closeable +import java.io.File +import java.io.Flushable +import java.io.IOException +import java.security.cert.Certificate +import java.security.cert.CertificateEncodingException +import java.security.cert.CertificateException +import java.security.cert.CertificateFactory +import java.util.* +import java.util.concurrent.TimeUnit + +/** + * Caches HTTP and HTTPS responses to the filesystem so they may be reused, saving time and + * bandwidth. + * + * ## Cache Optimization + * + * To measure cache effectiveness, this class tracks three statistics: + * + * * **[Request Count:][requestCount]** the number of HTTP requests issued since this cache was + * created. + * * **[Network Count:][networkCount]** the number of those requests that required network use. + * * **[Hit Count:][hitCount]** the number of those requests whose responses were served by the + * cache. + * + * Sometimes a request will result in a conditional cache hit. If the cache contains a stale copy of + * the response, the client will issue a conditional `GET`. The server will then send either + * the updated response if it has changed, or a short 'not modified' response if the client's copy + * is still valid. Such responses increment both the network count and hit count. + * + * The best way to improve the cache hit rate is by configuring the web server to return cacheable + * responses. Although this client honors all [HTTP/1.1 (RFC 7234)][rfc_7234] cache headers, it + * doesn't cache partial responses. + * + * ## Force a Network Response + * + * In some situations, such as after a user clicks a 'refresh' button, it may be necessary to skip + * the cache, and fetch data directly from the server. To force a full refresh, add the `no-cache` + * directive: + * + * ``` + * Request request = new Request.Builder() + * .cacheControl(new CacheControl.Builder().noCache().build()) + * .url("http://publicobject.com/helloworld.txt") + * .build(); + * ``` + * + * If it is only necessary to force a cached response to be validated by the server, use the more + * efficient `max-age=0` directive instead: + * + * ``` + * Request request = new Request.Builder() + * .cacheControl(new CacheControl.Builder() + * .maxAge(0, TimeUnit.SECONDS) + * .build()) + * .url("http://publicobject.com/helloworld.txt") + * .build(); + * ``` + * + * ## Force a Cache Response + * + * Sometimes you'll want to show resources if they are available immediately, but not otherwise. + * This can be used so your application can show *something* while waiting for the latest data to be + * downloaded. To restrict a request to locally-cached resources, add the `only-if-cached` + * directive: + * + * ``` + * Request request = new Request.Builder() + * .cacheControl(new CacheControl.Builder() + * .onlyIfCached() + * .build()) + * .url("http://publicobject.com/helloworld.txt") + * .build(); + * Response forceCacheResponse = client.newCall(request).execute(); + * if (forceCacheResponse.code() != 504) { + * // The resource was cached! Show it. + * } else { + * // The resource was not cached. + * } + * ``` + * + * This technique works even better in situations where a stale response is better than no response. + * To permit stale cached responses, use the `max-stale` directive with the maximum staleness in + * seconds: + * + * ``` + * Request request = new Request.Builder() + * .cacheControl(new CacheControl.Builder() + * .maxStale(365, TimeUnit.DAYS) + * .build()) + * .url("http://publicobject.com/helloworld.txt") + * .build(); + * ``` + * + * The [CacheControl] class can configure request caching directives and parse response caching + * directives. It even offers convenient constants [CacheControl.FORCE_NETWORK] and + * [CacheControl.FORCE_CACHE] that address the use cases above. + * + * [rfc_7234]: http://tools.ietf.org/html/rfc7234 + */ +class ForceCache internal constructor( + val cache: DiskLruCache +) : Closeable, Flushable { + + // read and write statistics, all guarded by 'this'. + internal var writeSuccessCount = 0 + internal var writeAbortCount = 0 + + val isClosed: Boolean + get() = cache.isClosed() + + internal fun get(request: Request): Response? { + val snapshot: DiskLruCache.Snapshot = try { + cache[key(request)] ?: return null + } catch (_: IOException) { + return null // Give up because the cache cannot be read. + } + + val entry: Entry = try { + Entry(snapshot.getSource(ENTRY_METADATA)) + } catch (_: IOException) { + snapshot.closeQuietly() + return null + } + + val response = entry.response(snapshot, request.body) + val value = request.tagOf()?.value + return if (value != null && System.currentTimeMillis() - response.receivedResponseAtMillis > value) { + null + } else { + response.newBuilder().request(request).build() + } + } + + internal fun put(response: Response): Response { + if (!response.isSuccessful) return response + val entry = Entry(response) + var editor: DiskLruCache.Editor? = null + val cacheRequest: CacheRequest? = try { + editor = cache.edit(key(response.request)) ?: return response + entry.writeTo(editor) + RealCacheRequest(editor) + } catch (_: IOException) { + abortQuietly(editor) + null + } + // Some apps return a null body; for compatibility we treat that like a null cache request. + cacheRequest ?: return response + val cacheBody = cacheRequest.body().buffer() + val responseBody = response.body ?: return response + val source = responseBody.source() + val cacheWritingSource = object : Source { + private var cacheRequestClosed = false + + @Throws(IOException::class) + override fun read(sink: Buffer, byteCount: Long): Long { + val bytesRead: Long + try { + bytesRead = source.read(sink, byteCount) + } catch (e: IOException) { + if (!cacheRequestClosed) { + cacheRequestClosed = true + cacheRequest.abort() // Failed to write a complete cache response. + } + throw e + } + + if (bytesRead == -1L) { + if (!cacheRequestClosed) { + cacheRequestClosed = true + cacheBody.close() // The cache response is complete! + } + return -1 + } + + sink.copyTo(cacheBody.buffer, sink.size - bytesRead, bytesRead) + cacheBody.emitCompleteSegments() + return bytesRead + } + + override fun timeout() = source.timeout() + + @Throws(IOException::class) + override fun close() { + if (!cacheRequestClosed && + !discard(ExchangeCodec.DISCARD_STREAM_TIMEOUT_MILLIS, TimeUnit.MILLISECONDS) + ) { + cacheRequestClosed = true + cacheRequest.abort() + } + source.close() + } + } + val contentType = response.header("Content-Type") + val contentLength = responseBody.contentLength() + return response.newBuilder() + .body(RealResponseBody(contentType, contentLength, cacheWritingSource.buffer())) + .build() + } + + @Throws(IOException::class) + internal fun remove(request: Request) { + cache.remove(key(request)) + } + + internal fun update(cached: Response, network: Response) { + val entry = Entry(network) + val snapshot = (cached.body as CacheResponseBody).snapshot + var editor: DiskLruCache.Editor? = null + try { + editor = snapshot.edit() ?: return // edit() returns null if snapshot is not current. + entry.writeTo(editor) + editor.commit() + } catch (_: IOException) { + abortQuietly(editor) + } + } + + private fun abortQuietly(editor: DiskLruCache.Editor?) { + // Give up because the cache cannot be written. + try { + editor?.abort() + } catch (_: IOException) { + } + } + + /** + * Initialize the cache. This will include reading the journal files from the storage and building + * up the necessary in-memory cache information. + * + * The initialization time may vary depending on the journal file size and the current actual + * cache size. The application needs to be aware of calling this function during the + * initialization phase and preferably in a background worker thread. + * + * Note that if the application chooses to not call this method to initialize the cache. By + * default, OkHttp will perform lazy initialization upon the first usage of the cache. + */ + @Throws(IOException::class) + fun initialize() { + cache.initialize() + } + + /** + * Closes the cache and deletes all of its stored values. This will delete all files in the cache + * directory including files that weren't created by the cache. + */ + @Throws(IOException::class) + fun delete() { + cache.delete() + } + + /** + * Deletes all values stored in the cache. In-flight writes to the cache will complete normally, + * but the corresponding responses will not be stored. + */ + @Throws(IOException::class) + fun evictAll() { + cache.evictAll() + } + + /** + * Returns an iterator over the URLs in this cache. This iterator doesn't throw + * `ConcurrentModificationException`, but if new responses are added while iterating, their URLs + * will not be returned. If existing responses are evicted during iteration, they will be absent + * (unless they were already returned). + * + * The iterator supports [MutableIterator.remove]. Removing a URL from the iterator evicts the + * corresponding response from the cache. Use this to evict selected responses. + */ + @Throws(IOException::class) + fun urls(): MutableIterator { + return object : MutableIterator { + private val delegate: MutableIterator = cache.snapshots() + private var nextUrl: String? = null + private var canRemove = false + + override fun hasNext(): Boolean { + if (nextUrl != null) return true + + canRemove = false // Prevent delegate.remove() on the wrong item! + while (delegate.hasNext()) { + try { + delegate.next().use { snapshot -> + val metadata = snapshot.getSource(ENTRY_METADATA).buffer() + nextUrl = metadata.readUtf8LineStrict() + return true + } + } catch (_: IOException) { + // We couldn't read the metadata for this snapshot; possibly because the host filesystem + // has disappeared! Skip it. + } + } + + return false + } + + override fun next(): String { + if (!hasNext()) throw NoSuchElementException() + val result = nextUrl!! + nextUrl = null + canRemove = true + return result + } + + override fun remove() { + check(canRemove) { "remove() before next()" } + delegate.remove() + } + } + } + + @Synchronized + fun writeAbortCount(): Int = writeAbortCount + + @Synchronized + fun writeSuccessCount(): Int = writeSuccessCount + + @Throws(IOException::class) + fun size(): Long = cache.size() + + /** Max size of the cache (in bytes). */ + fun maxSize(): Long = cache.maxSize + + @Throws(IOException::class) + override fun flush() { + cache.flush() + } + + @Throws(IOException::class) + override fun close() { + cache.close() + } + + @get:JvmName("directory") + val directory: File + get() = cache.directory + + private inner class RealCacheRequest( + private val editor: DiskLruCache.Editor + ) : CacheRequest { + private val cacheOut: Sink = editor.newSink(ENTRY_BODY) + private val body: Sink + var done = false + + init { + this.body = object : ForwardingSink(cacheOut) { + @Throws(IOException::class) + override fun close() { + synchronized(this@ForceCache) { + if (done) return + done = true + writeSuccessCount++ + } + super.close() + editor.commit() + } + } + } + + override fun abort() { + synchronized(this@ForceCache) { + if (done) return + done = true + writeAbortCount++ + } + cacheOut.closeQuietly() + try { + editor.abort() + } catch (_: IOException) { + } + } + + override fun body(): Sink = body + } + + private class Entry { + private val url: String + private val varyHeaders: Headers + private val requestMethod: String + private val protocol: Protocol + private val code: Int + private val message: String + private val responseHeaders: Headers + private val handshake: Handshake? + private val sentRequestMillis: Long + private val receivedResponseMillis: Long + + private val isHttps: Boolean get() = url.startsWith("https://") + + /** + * Reads an entry from an input stream. A typical entry looks like this: + * + * ``` + * http://google.com/foo + * GET + * 2 + * Accept-Language: fr-CA + * Accept-Charset: UTF-8 + * HTTP/1.1 200 OK + * 3 + * Content-Type: image/png + * Content-Length: 100 + * Cache-Control: max-age=600 + * ``` + * + * A typical HTTPS file looks like this: + * + * ``` + * https://google.com/foo + * GET + * 2 + * Accept-Language: fr-CA + * Accept-Charset: UTF-8 + * HTTP/1.1 200 OK + * 3 + * Content-Type: image/png + * Content-Length: 100 + * Cache-Control: max-age=600 + * + * AES_256_WITH_MD5 + * 2 + * base64-encoded peerCertificate[0] + * base64-encoded peerCertificate[1] + * -1 + * TLSv1.2 + * ``` + * + * The file is newline separated. The first two lines are the URL and the request method. Next + * is the number of HTTP Vary request header lines, followed by those lines. + * + * Next is the response status line, followed by the number of HTTP response header lines, + * followed by those lines. + * + * HTTPS responses also contain SSL session information. This begins with a blank line, and then + * a line containing the cipher suite. Next is the length of the peer certificate chain. These + * certificates are base64-encoded and appear each on their own line. The next line contains the + * length of the local certificate chain. These certificates are also base64-encoded and appear + * each on their own line. A length of -1 is used to encode a null array. The last line is + * optional. If present, it contains the TLS version. + */ + @Throws(IOException::class) + constructor(rawSource: Source) { + try { + val source = rawSource.buffer() + url = source.readUtf8LineStrict() + requestMethod = source.readUtf8LineStrict() + val varyHeadersBuilder = Headers.Builder() + val varyRequestHeaderLineCount = readInt(source) + for (i in 0 until varyRequestHeaderLineCount) { + OkHttpUtils.addLenient(varyHeadersBuilder, source.readUtf8LineStrict()) + } + varyHeaders = varyHeadersBuilder.build() + + val statusLine = StatusLine.parse(source.readUtf8LineStrict()) + protocol = statusLine.protocol + code = statusLine.code + message = statusLine.message + val responseHeadersBuilder = Headers.Builder() + val responseHeaderLineCount = readInt(source) + for (i in 0 until responseHeaderLineCount) { + OkHttpUtils.addLenient(responseHeadersBuilder, source.readUtf8LineStrict()) + } + val sendRequestMillisString = responseHeadersBuilder[SENT_MILLIS] + val receivedResponseMillisString = responseHeadersBuilder[RECEIVED_MILLIS] + responseHeadersBuilder.removeAll(SENT_MILLIS) + responseHeadersBuilder.removeAll(RECEIVED_MILLIS) + sentRequestMillis = sendRequestMillisString?.toLong() ?: 0L + receivedResponseMillis = receivedResponseMillisString?.toLong() ?: 0L + responseHeaders = responseHeadersBuilder.build() + + if (isHttps) { + val blank = source.readUtf8LineStrict() + if (blank.isNotEmpty()) { + throw IOException("expected \"\" but was \"$blank\"") + } + val cipherSuiteString = source.readUtf8LineStrict() + val cipherSuite = CipherSuite.forJavaName(cipherSuiteString) + val peerCertificates = readCertificateList(source) + val localCertificates = readCertificateList(source) + val tlsVersion = if (!source.exhausted()) { + TlsVersion.forJavaName(source.readUtf8LineStrict()) + } else { + TlsVersion.SSL_3_0 + } + handshake = + Handshake.get(tlsVersion, cipherSuite, peerCertificates, localCertificates) + } else { + handshake = null + } + } finally { + rawSource.close() + } + } + + constructor(response: Response) { + this.url = response.request.url.toString() + this.varyHeaders = response.varyHeaders() + this.requestMethod = response.request.method + this.protocol = response.protocol + this.code = response.code + this.message = response.message + this.responseHeaders = response.headers + this.handshake = response.handshake + this.sentRequestMillis = response.sentRequestAtMillis + this.receivedResponseMillis = response.receivedResponseAtMillis + } + + @Throws(IOException::class) + fun writeTo(editor: DiskLruCache.Editor) { + editor.newSink(ENTRY_METADATA).buffer().use { sink -> + sink.writeUtf8(url).writeByte('\n'.toInt()) + sink.writeUtf8(requestMethod).writeByte('\n'.toInt()) + sink.writeDecimalLong(varyHeaders.size.toLong()).writeByte('\n'.toInt()) + for (i in 0 until varyHeaders.size) { + sink.writeUtf8(varyHeaders.name(i)) + .writeUtf8(": ") + .writeUtf8(varyHeaders.value(i)) + .writeByte('\n'.toInt()) + } + + sink.writeUtf8(StatusLine(protocol, code, message).toString()) + .writeByte('\n'.toInt()) + sink.writeDecimalLong((responseHeaders.size + 2).toLong()).writeByte('\n'.toInt()) + for (i in 0 until responseHeaders.size) { + sink.writeUtf8(responseHeaders.name(i)) + .writeUtf8(": ") + .writeUtf8(responseHeaders.value(i)) + .writeByte('\n'.toInt()) + } + sink.writeUtf8(SENT_MILLIS) + .writeUtf8(": ") + .writeDecimalLong(sentRequestMillis) + .writeByte('\n'.toInt()) + sink.writeUtf8(RECEIVED_MILLIS) + .writeUtf8(": ") + .writeDecimalLong(receivedResponseMillis) + .writeByte('\n'.toInt()) + + if (isHttps) { + sink.writeByte('\n'.toInt()) + sink.writeUtf8(handshake!!.cipherSuite.javaName).writeByte('\n'.toInt()) + writeCertList(sink, handshake.peerCertificates) + writeCertList(sink, handshake.localCertificates) + sink.writeUtf8(handshake.tlsVersion.javaName).writeByte('\n'.toInt()) + } + } + } + + @Throws(IOException::class) + private fun readCertificateList(source: BufferedSource): List { + val length = readInt(source) + if (length == -1) return emptyList() // OkHttp v1.2 used -1 to indicate null. + + try { + val certificateFactory = CertificateFactory.getInstance("X.509") + val result = ArrayList(length) + for (i in 0 until length) { + val line = source.readUtf8LineStrict() + val bytes = Buffer() + bytes.write(line.decodeBase64()!!) + result.add(certificateFactory.generateCertificate(bytes.inputStream())) + } + return result + } catch (e: CertificateException) { + throw IOException(e.message) + } + } + + @Throws(IOException::class) + private fun writeCertList(sink: BufferedSink, certificates: List) { + try { + sink.writeDecimalLong(certificates.size.toLong()).writeByte('\n'.toInt()) + for (element in certificates) { + val bytes = element.encoded + val line = bytes.toByteString().base64() + sink.writeUtf8(line).writeByte('\n'.toInt()) + } + } catch (e: CertificateEncodingException) { + throw IOException(e.message) + } + } + + fun response(snapshot: DiskLruCache.Snapshot, requestBody: RequestBody?): Response { + val contentType = responseHeaders["Content-Type"] + val contentLength = responseHeaders["Content-Length"] + val cacheRequest = Request.Builder() + .url(url) + .method(requestMethod, requestBody) + .headers(varyHeaders) + .build() + val builder = Response.Builder() + .request(cacheRequest) + .protocol(protocol) + .code(code) + .message(message) + .headers(responseHeaders) + .handshake(handshake) + .sentRequestAtMillis(sentRequestMillis) + .receivedResponseAtMillis(receivedResponseMillis) + return builder + .cacheResponse(builder.build()) + .body(CacheResponseBody(snapshot, contentType, contentLength)) + .build() + } + + companion object { + /** Synthetic response header: the local time when the request was sent. */ + private val SENT_MILLIS = "${Platform.get().getPrefix()}-Sent-Millis" + + /** Synthetic response header: the local time when the response was received. */ + private val RECEIVED_MILLIS = "${Platform.get().getPrefix()}-Received-Millis" + } + } + + private class CacheResponseBody( + val snapshot: DiskLruCache.Snapshot, + private val contentType: String?, + private val contentLength: String? + ) : ResponseBody() { + private val bodySource: BufferedSource + + init { + val source = snapshot.getSource(ENTRY_BODY) + bodySource = object : ForwardingSource(source) { + @Throws(IOException::class) + override fun close() { + snapshot.close() + super.close() + } + }.buffer() + } + + override fun contentType(): MediaType? = contentType?.toMediaTypeOrNull() + + override fun contentLength(): Long = contentLength?.toLongOrDefault(-1L) ?: -1L + + override fun source(): BufferedSource = bodySource + } + + companion object { + private const val ENTRY_METADATA = 0 + private const val ENTRY_BODY = 1 + + @JvmStatic + fun key(request: Request): String { + val key = + request.tagOf()?.value ?: (request.method + request.url.toString()) + return key.encodeUtf8().sha1().hex() + } + + @Throws(IOException::class) + internal fun readInt(source: BufferedSource): Int { + try { + val result = source.readDecimalLong() + val line = source.readUtf8LineStrict() + if (result < 0L || result > Integer.MAX_VALUE || line.isNotEmpty()) { + throw IOException("expected an int but was \"$result$line\"") + } + return result.toInt() + } catch (e: NumberFormatException) { + throw IOException(e.message) + } + } + + /** + * Returns true if none of the Vary headers have changed between [cachedRequest] and + * [newRequest]. + */ + fun varyMatches( + cachedResponse: Response, + cachedRequest: Headers, + newRequest: Request + ): Boolean { + return cachedResponse.headers.varyFields().none { + cachedRequest.values(it) != newRequest.headers(it) + } + } + + /** Returns true if a Vary header contains an asterisk. Such responses cannot be cached. */ + fun Response.hasVaryAll() = "*" in headers.varyFields() + + /** + * Returns the names of the request headers that need to be checked for equality when caching. + */ + private fun Headers.varyFields(): Set { + var result: MutableSet? = null + for (i in 0 until size) { + if (!"Vary".equals(name(i), ignoreCase = true)) { + continue + } + + val value = value(i) + if (result == null) { + result = TreeSet(String.CASE_INSENSITIVE_ORDER) + } + for (varyField in value.split(',')) { + result.add(varyField.trim()) + } + } + return result ?: emptySet() + } + + /** + * Returns the subset of the headers in this's request that impact the content of this's body. + */ + fun Response.varyHeaders(): Headers { + // Use the request headers sent over the network, since that's what the response varies on. + // Otherwise OkHttp-supplied headers like "Accept-Encoding: gzip" may be lost. + val requestHeaders = networkResponse!!.request.headers + val responseHeaders = headers + return varyHeaders(requestHeaders, responseHeaders) + } + + /** + * Returns the subset of the headers in [requestHeaders] that impact the content of the + * response's body. + */ + private fun varyHeaders(requestHeaders: Headers, responseHeaders: Headers): Headers { + val varyFields = responseHeaders.varyFields() + if (varyFields.isEmpty()) return EMPTY_HEADERS + + val result = Headers.Builder() + for (i in 0 until requestHeaders.size) { + val fieldName = requestHeaders.name(i) + if (fieldName in varyFields) { + result.add(fieldName, requestHeaders.value(i)) + } + } + return result.build() + } + } +} diff --git a/app-catalog/samples/wNet/libwNet/src/main/java/lib/wintmain/libwNet/component/Progress.kt b/app-catalog/samples/wNet/libwNet/src/main/java/lib/wintmain/libwNet/component/Progress.kt new file mode 100644 index 0000000..6ca40e4 --- /dev/null +++ b/app-catalog/samples/wNet/libwNet/src/main/java/lib/wintmain/libwNet/component/Progress.kt @@ -0,0 +1,157 @@ +/* + * Copyright 2023-2025 wintmain + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +@file:Suppress("unused", "MemberVisibilityCanBePrivate", "NAME_SHADOWING", "RedundantSetter") + +package lib.wintmain.libwNet.component + +import android.os.SystemClock +import android.text.format.DateUtils +import android.text.format.Formatter +import lib.wintmain.libwNet.NetConfig + +/** + * 下载/上传进度信息 + */ +class Progress { + + /** 当前已经完成的字节数 */ + var currentByteCount: Long = 0 + internal set + + /** 全部字节数 */ + var totalByteCount: Long = 0 + internal set + + /** 距离上次进度变化的新增字节数 */ + var intervalByteCount: Long = 0 + internal set + + /** 距离上次进度变化的时间 */ + var intervalTime: Long = 0 + internal set + + /** 是否完成 */ + var finish: Boolean = false + internal set + + /** 开始下载的时间 */ + val startElapsedRealtime: Long = SystemClock.elapsedRealtime() + + /** + * 每秒下载速度, 字节单位 + */ + var speedBytes = 0L + get() { + return if (intervalTime <= 0L || intervalByteCount <= 0) { + field + } else { + field = intervalByteCount * 1000 / intervalTime + field + } + } + + override fun toString(): String { + return "Progress(currentByteCount=$currentByteCount, totalByteCount=$totalByteCount, finish=$finish)" + } + + /** + * 文件全部大小 + * 根据字节数自动显示内存单位, 例如 19MB 或者 27KB + */ + fun totalSize(): String { + val totalBytes = if (totalByteCount <= 0) 0 else totalByteCount + return Formatter.formatFileSize(NetConfig.app, totalBytes) + } + + /** + * 已完成文件大小 + * 根据字节数自动显示内存单位, 例如 19MB 或者 27KB + */ + fun currentSize(): String { + return Formatter.formatFileSize(NetConfig.app, currentByteCount) + } + + /** + * 剩余大小 + * 根据字节数自动显示内存单位, 例如 19MB 或者 27KB + */ + fun remainSize(): String { + val remain = if (totalByteCount <= 0) 0 else totalByteCount - currentByteCount + return Formatter.formatFileSize(NetConfig.app, remain) + } + + /** + * 每秒下载速度 + * 根据字节数自动显示内存单位, 例如 19MB 或者 27KB + */ + fun speedSize(): String { + return Formatter.formatFileSize(NetConfig.app, speedBytes) + } + + /** + * 请求或者响应的进度, 值范围在0-100 + * 如果服务器返回的响应体没有包含Content-Length(比如启用gzip压缩后Content-Length会被删除), 则无法计算进度, 始终返回0 + */ + fun progress(): Int { + return when { + finish || currentByteCount == totalByteCount -> 100 + totalByteCount <= 0 -> 0 + else -> (currentByteCount * 100 / totalByteCount).toInt() + } + } + + /** + * 已使用时间 + */ + fun useTime(): String { + return DateUtils.formatElapsedTime((SystemClock.elapsedRealtime() - startElapsedRealtime) / 1000) + } + + /** + * 已使用时间 + * @return 秒 + */ + fun useTimeSeconds(): Long { + return (SystemClock.elapsedRealtime() - startElapsedRealtime) / 1000 + } + + /** + * 剩余时间 + */ + fun remainTime(): String { + val speedBytes = speedBytes + val remainSeconds = if (totalByteCount <= 0 || speedBytes <= 0L) { + 0 + } else { + (totalByteCount - currentByteCount) / speedBytes + } + return DateUtils.formatElapsedTime(remainSeconds) + } + + /** + * 剩余时间 + * @return 秒 + */ + fun remainTimeSeconds(): Long { + val speedBytes = this.speedBytes + return if (totalByteCount <= 0 || speedBytes <= 0L) { + 0 + } else { + (totalByteCount - currentByteCount) / speedBytes + } + } +} \ No newline at end of file diff --git a/app-catalog/samples/wNet/libwNet/src/main/java/lib/wintmain/libwNet/convert/JSONConvert.kt b/app-catalog/samples/wNet/libwNet/src/main/java/lib/wintmain/libwNet/convert/JSONConvert.kt new file mode 100644 index 0000000..439f4e3 --- /dev/null +++ b/app-catalog/samples/wNet/libwNet/src/main/java/lib/wintmain/libwNet/convert/JSONConvert.kt @@ -0,0 +1,93 @@ +/* + * Copyright 2023-2025 wintmain + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +@file:Suppress("MemberVisibilityCanBePrivate", "UNCHECKED_CAST") + +package lib.wintmain.libwNet.convert + +import lib.wintmain.libwNet.NetConfig +import lib.wintmain.libwNet.exception.ConvertException +import lib.wintmain.libwNet.exception.RequestParamsException +import lib.wintmain.libwNet.exception.ResponseException +import lib.wintmain.libwNet.exception.ServerResponseException +import okhttp3.Response +import org.json.JSONException +import org.json.JSONObject +import java.lang.reflect.Type + +/** + * 常见的JSON转换器实现, 如果不满意继承实现自定义的业务逻辑 + * + * @param success 后端定义为成功状态的错误码值 + * @param code 错误码在JSON中的字段名 + * @param message 错误信息在JSON中的字段名 + */ +abstract class JSONConvert( + val success: String = "0", + val code: String = "code", + val message: String = "msg" +) : NetConverter { + + override fun onConvert(succeed: Type, response: Response): R? { + try { + return NetConverter.onConvert(succeed, response) + } catch (e: ConvertException) { + val code = response.code + when { + code in 200..299 -> { // 请求成功 + val bodyString = response.body?.string() ?: return null + return try { + val json = JSONObject(bodyString) // 获取JSON中后端定义的错误码和错误信息 + val srvCode = json.getString(this.code) + if (srvCode == success) { // 对比后端自定义错误码 + bodyString.parseBody(succeed) + } else { // 错误码匹配失败, 开始写入错误异常 + val errorMessage = json.optString( + message, + NetConfig.app.getString(lib.wintmain.libwNet.R.string.no_error_message) + ) + throw ResponseException( + response, + errorMessage, + tag = srvCode + ) // 将业务错误码作为tag传递 + } + } catch (e: JSONException) { // 固定格式JSON分析失败直接解析JSON + bodyString.parseBody(succeed) + } + } + + code in 400..499 -> throw RequestParamsException( + response, + code.toString() + ) // 请求参数错误 + code >= 500 -> throw ServerResponseException(response, code.toString()) // 服务器异常错误 + else -> throw ConvertException( + response, + message = "Http status code not within range" + ) + } + } + } + + /** + * 反序列化JSON + * + * @param succeed JSON对象的类型 + * @receiver 原始字符串 + */ + abstract fun String.parseBody(succeed: Type): R? +} diff --git a/app-catalog/samples/wNet/libwNet/src/main/java/lib/wintmain/libwNet/convert/NetConverter.kt b/app-catalog/samples/wNet/libwNet/src/main/java/lib/wintmain/libwNet/convert/NetConverter.kt new file mode 100644 index 0000000..2292914 --- /dev/null +++ b/app-catalog/samples/wNet/libwNet/src/main/java/lib/wintmain/libwNet/convert/NetConverter.kt @@ -0,0 +1,53 @@ +/* + * Copyright 2023-2025 wintmain + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package lib.wintmain.libwNet.convert + +import lib.wintmain.libwNet.exception.ConvertException +import lib.wintmain.libwNet.response.file +import okhttp3.Response +import okio.ByteString +import java.io.File +import java.lang.reflect.GenericArrayType +import java.lang.reflect.Type + +@Suppress("UNCHECKED_CAST") +interface NetConverter { + + @Throws(Throwable::class) + fun onConvert(succeed: Type, response: Response): R? + + companion object DEFAULT : NetConverter { + /** + * 返回结果应当等于泛型对象, 可空 + * @param succeed 请求要求返回的泛型类型 + * @param response 请求响应对象 + */ + override fun onConvert(succeed: Type, response: Response): R? { + return when { + succeed === String::class.java && response.isSuccessful -> response.body?.string() as R + succeed === ByteString::class.java && response.isSuccessful -> response.body?.byteString() as R + succeed is GenericArrayType && succeed.genericComponentType === Byte::class.java && response.isSuccessful -> response.body?.bytes() as R + succeed === File::class.java && response.isSuccessful -> response.file() as R + succeed === Response::class.java -> response as R + else -> throw ConvertException( + response, + "An exception occurred while converting the NetConverter.DEFAULT" + ) + } + } + } +} \ No newline at end of file diff --git a/app-catalog/samples/wNet/libwNet/src/main/java/lib/wintmain/libwNet/cookie/PersistentCookieJar.kt b/app-catalog/samples/wNet/libwNet/src/main/java/lib/wintmain/libwNet/cookie/PersistentCookieJar.kt new file mode 100644 index 0000000..a4720df --- /dev/null +++ b/app-catalog/samples/wNet/libwNet/src/main/java/lib/wintmain/libwNet/cookie/PersistentCookieJar.kt @@ -0,0 +1,116 @@ +/* + * Copyright 2023-2025 wintmain + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package lib.wintmain.libwNet.cookie + +import android.content.ContentValues +import android.content.Context +import android.database.sqlite.SQLiteDatabase +import android.database.sqlite.SQLiteOpenHelper +import okhttp3.Cookie +import okhttp3.CookieJar +import okhttp3.HttpUrl + +/** + * 持久化存储Cookie + * @param dbName 数据库名称, 设置多个名称可以让不同的客户端共享不同的cookies + */ +class PersistentCookieJar( + val context: Context, + val dbName: String = "net_cookies", +) : CookieJar { + + private var sqlHelper: SQLiteOpenHelper = object : SQLiteOpenHelper(context, dbName, null, 1) { + override fun onCreate(db: SQLiteDatabase) { + db.execSQL("CREATE TABLE IF NOT EXISTS cookies (url TEXT, name TEXT, cookie TEXT, PRIMARY KEY(url, name))") + } + + override fun onUpgrade(db: SQLiteDatabase?, oldVersion: Int, newVersion: Int) { + db?.execSQL("DROP TABLE IF EXISTS cookies") + } + } + + /** + * 添加Cookie到指定域名下 + */ + fun addAll(url: HttpUrl, cookies: List) { + val db = sqlHelper.writableDatabase + cookies.forEach { cookie -> + if (cookie.expiresAt > System.currentTimeMillis()) { + db.replace("cookies", null, ContentValues().apply { + put("url", url.host) + put("name", cookie.name) + put("cookie", cookie.toString()) + }) + } + } + } + + /** + * 获取指定域名下的所有Cookie + */ + fun getAll(url: HttpUrl): List { + val db = sqlHelper.writableDatabase + db.rawQuery("SELECT * FROM cookies WHERE url = ?", arrayOf(url.host)).use { cursor -> + val cookies = mutableListOf() + while (cursor.moveToNext()) { + val cookieColumn = cursor.getString(cursor.getColumnIndex("cookie")) + val cookie = Cookie.parse(url, cookieColumn) + if (cookie != null) { + if (cookie.expiresAt > System.currentTimeMillis()) { + cookies.add(cookie) + } else { + db.delete("cookies", "url = ? AND name = ?", arrayOf(url.host, cookie.name)) + } + } + } + return cookies + } + } + + /** + * 删除指定域名的所有Cookie + */ + fun remove(url: HttpUrl) { + sqlHelper.writableDatabase.delete("cookies", "url = ?", arrayOf(url.host)) + } + + /** + * 删除指定域名下的指定cookie + */ + fun remove(url: HttpUrl, cookieName: String) { + sqlHelper.writableDatabase.delete( + "cookies", + "url = ? AND name = ?", + arrayOf(url.host, cookieName) + ) + } + + /** + * 清除应用所有Cookie + */ + fun clear() { + sqlHelper.writableDatabase.delete("cookies", null, null) + } + + override fun loadForRequest(url: HttpUrl): List { + return getAll(url) + } + + override fun saveFromResponse(url: HttpUrl, cookies: List) { + addAll(url, cookies) + } +} \ No newline at end of file diff --git a/app-catalog/samples/wNet/libwNet/src/main/java/lib/wintmain/libwNet/exception/ConvertException.kt b/app-catalog/samples/wNet/libwNet/src/main/java/lib/wintmain/libwNet/exception/ConvertException.kt new file mode 100644 index 0000000..2f80d25 --- /dev/null +++ b/app-catalog/samples/wNet/libwNet/src/main/java/lib/wintmain/libwNet/exception/ConvertException.kt @@ -0,0 +1,32 @@ +/* + * Copyright 2023-2025 wintmain + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package lib.wintmain.libwNet.exception + +import okhttp3.Response + +/** + * 转换数据异常 + * @param response 响应信息 + * @param message 错误描述信息 + * @param cause 错误原因 + * @param tag 可携带任意对象, 一般用于在转换器/拦截器中添加然后传递给错误处理器去使用判断 + */ +class ConvertException( + response: Response, + message: String? = null, + cause: Throwable? = null, + var tag: Any? = null +) : HttpResponseException(response, message, cause) \ No newline at end of file diff --git a/app-catalog/samples/wNet/libwNet/src/main/java/lib/wintmain/libwNet/exception/DownloadFileException.kt b/app-catalog/samples/wNet/libwNet/src/main/java/lib/wintmain/libwNet/exception/DownloadFileException.kt new file mode 100644 index 0000000..cd04a49 --- /dev/null +++ b/app-catalog/samples/wNet/libwNet/src/main/java/lib/wintmain/libwNet/exception/DownloadFileException.kt @@ -0,0 +1,33 @@ +/* + * Copyright 2023-2025 wintmain + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package lib.wintmain.libwNet.exception + +import okhttp3.Response + +/** + * 下载文件异常 + * @param response 响应信息 + * @param message 错误描述信息 + * @param cause 错误原因 + * @param tag 可携带任意对象, 一般用于在转换器/拦截器中添加然后传递给错误处理器去使用判断 + */ +class DownloadFileException( + response: Response, + message: String? = null, + cause: Throwable? = null, + var tag: Any? = null +) : HttpResponseException(response, message, cause) \ No newline at end of file diff --git a/app-catalog/samples/wNet/libwNet/src/main/java/lib/wintmain/libwNet/exception/HttpFailureException.kt b/app-catalog/samples/wNet/libwNet/src/main/java/lib/wintmain/libwNet/exception/HttpFailureException.kt new file mode 100644 index 0000000..41d7a5a --- /dev/null +++ b/app-catalog/samples/wNet/libwNet/src/main/java/lib/wintmain/libwNet/exception/HttpFailureException.kt @@ -0,0 +1,31 @@ +/* + * Copyright 2023-2025 wintmain + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package lib.wintmain.libwNet.exception + +import okhttp3.Request + +/** + * 该类表示Http请求在服务器响应之前失败 + * @param request 请求信息 + * @param message 错误描述信息 + * @param cause 错误原因 + */ +open class HttpFailureException( + request: Request, + message: String? = null, + cause: Throwable? = null +) : NetException(request, message, cause) \ No newline at end of file diff --git a/app-catalog/samples/wNet/libwNet/src/main/java/lib/wintmain/libwNet/exception/HttpResponseException.kt b/app-catalog/samples/wNet/libwNet/src/main/java/lib/wintmain/libwNet/exception/HttpResponseException.kt new file mode 100644 index 0000000..96b24a5 --- /dev/null +++ b/app-catalog/samples/wNet/libwNet/src/main/java/lib/wintmain/libwNet/exception/HttpResponseException.kt @@ -0,0 +1,35 @@ +/* + * Copyright 2023-2025 wintmain + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package lib.wintmain.libwNet.exception + +import okhttp3.Response + +/** + * 该类表示Http请求在服务器响应成功后失败 + * @param response 响应信息 + * @param message 错误描述信息 + * @param cause 错误原因 + * + * @see ResponseException HttpStatusCode 200...299 + * @see RequestParamsException HttpStatusCode 400...499 + * @see ServerResponseException HttpStatusCode 500...599 + */ +open class HttpResponseException( + open val response: Response, + message: String? = null, + cause: Throwable? = null +) : NetException(response.request, message, cause) \ No newline at end of file diff --git a/app-catalog/samples/wNet/libwNet/src/main/java/lib/wintmain/libwNet/exception/NetCancellationException.kt b/app-catalog/samples/wNet/libwNet/src/main/java/lib/wintmain/libwNet/exception/NetCancellationException.kt new file mode 100644 index 0000000..e461440 --- /dev/null +++ b/app-catalog/samples/wNet/libwNet/src/main/java/lib/wintmain/libwNet/exception/NetCancellationException.kt @@ -0,0 +1,42 @@ +/* + * Copyright 2023-2025 wintmain + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package lib.wintmain.libwNet.exception + +import kotlinx.coroutines.CoroutineExceptionHandler +import kotlinx.coroutines.CoroutineScope +import lib.wintmain.libwNet.Net +import java.util.concurrent.CancellationException + +/** + * 取消网络任务的异常 + */ +class NetCancellationException( + coroutineScope: CoroutineScope, + message: String? = null, +) : CancellationException(message) { + init { + Net.cancelGroup(coroutineScope.coroutineContext[CoroutineExceptionHandler]) + } +} + +/** + * 在作用域中抛出该异常将取消其作用域内所有的网络请求(如果存在的话) + */ +@Suppress("FunctionName") +fun CoroutineScope.NetCancellationException(message: String? = null): NetCancellationException { + return NetCancellationException(this, message) +} \ No newline at end of file diff --git a/app-catalog/samples/wNet/libwNet/src/main/java/lib/wintmain/libwNet/exception/NetConnectException.kt b/app-catalog/samples/wNet/libwNet/src/main/java/lib/wintmain/libwNet/exception/NetConnectException.kt new file mode 100644 index 0000000..41d31a7 --- /dev/null +++ b/app-catalog/samples/wNet/libwNet/src/main/java/lib/wintmain/libwNet/exception/NetConnectException.kt @@ -0,0 +1,31 @@ +/* + * Copyright 2023-2025 wintmain + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package lib.wintmain.libwNet.exception + +import okhttp3.Request + +/** + * 连接错误 + * @param request 请求信息 + * @param message 错误描述信息 + * @param cause 错误原因 + */ +class NetConnectException( + request: Request, + message: String? = null, + cause: Throwable? = null +) : HttpFailureException(request, message, cause) \ No newline at end of file diff --git a/app-catalog/samples/wNet/libwNet/src/main/java/lib/wintmain/libwNet/exception/NetException.kt b/app-catalog/samples/wNet/libwNet/src/main/java/lib/wintmain/libwNet/exception/NetException.kt new file mode 100644 index 0000000..d346102 --- /dev/null +++ b/app-catalog/samples/wNet/libwNet/src/main/java/lib/wintmain/libwNet/exception/NetException.kt @@ -0,0 +1,40 @@ +/* + * Copyright 2023-2025 wintmain + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package lib.wintmain.libwNet.exception + +import okhttp3.Request +import java.io.IOException + +/** + * 表示为Net发生的网络异常 + * 在转换器[lib.wintmain.libwNet.convert.NetConverter]中抛出的异常如果没有继承该类都会被视为数据转换异常[ConvertException], 该类一般用于自定义异常 + * @param request 请求信息 + * @param message 错误描述信息 + * @param cause 错误原因 + */ +open class NetException( + open val request: Request, + message: String? = null, + cause: Throwable? = null, +) : IOException(message, cause) { + + var occurred: String = "" + + override fun getLocalizedMessage(): String? { + return "${if (message == null) "" else message + " "}${request.url}$occurred" + } +} \ No newline at end of file diff --git a/app-catalog/samples/wNet/libwNet/src/main/java/lib/wintmain/libwNet/exception/NetSocketTimeoutException.kt b/app-catalog/samples/wNet/libwNet/src/main/java/lib/wintmain/libwNet/exception/NetSocketTimeoutException.kt new file mode 100644 index 0000000..1c5dd8e --- /dev/null +++ b/app-catalog/samples/wNet/libwNet/src/main/java/lib/wintmain/libwNet/exception/NetSocketTimeoutException.kt @@ -0,0 +1,31 @@ +/* + * Copyright 2023-2025 wintmain + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package lib.wintmain.libwNet.exception + +import okhttp3.Request + +/** + * 请求过程中读取或者写入超时 + * @param request 请求信息 + * @param message 错误描述信息 + * @param cause 错误原因 + */ +class NetSocketTimeoutException( + request: Request, + message: String? = null, + cause: Throwable? = null +) : HttpFailureException(request, message, cause) \ No newline at end of file diff --git a/app-catalog/samples/wNet/libwNet/src/main/java/lib/wintmain/libwNet/exception/NetUnknownHostException.kt b/app-catalog/samples/wNet/libwNet/src/main/java/lib/wintmain/libwNet/exception/NetUnknownHostException.kt new file mode 100644 index 0000000..757b3d2 --- /dev/null +++ b/app-catalog/samples/wNet/libwNet/src/main/java/lib/wintmain/libwNet/exception/NetUnknownHostException.kt @@ -0,0 +1,31 @@ +/* + * Copyright 2023-2025 wintmain + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package lib.wintmain.libwNet.exception + +import okhttp3.Request + +/** + * 主机域名无法访问 + * @param request 请求信息 + * @param message 错误描述信息 + * @param cause 错误原因 + */ +class NetUnknownHostException( + request: Request, + message: String? = null, + cause: Throwable? = null +) : HttpFailureException(request, message, cause) \ No newline at end of file diff --git a/app-catalog/samples/wNet/libwNet/src/main/java/lib/wintmain/libwNet/exception/NetworkingException.kt b/app-catalog/samples/wNet/libwNet/src/main/java/lib/wintmain/libwNet/exception/NetworkingException.kt new file mode 100644 index 0000000..90f02ba --- /dev/null +++ b/app-catalog/samples/wNet/libwNet/src/main/java/lib/wintmain/libwNet/exception/NetworkingException.kt @@ -0,0 +1,31 @@ +/* + * Copyright 2023-2025 wintmain + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package lib.wintmain.libwNet.exception + +import okhttp3.Request + +/** + * 无网络情况 + * @param request 请求信息 + * @param message 错误描述信息 + * @param cause 错误原因 + */ +class NetworkingException( + request: Request, + message: String? = null, + cause: Throwable? = null +) : HttpFailureException(request, message, cause) \ No newline at end of file diff --git a/app-catalog/samples/wNet/libwNet/src/main/java/lib/wintmain/libwNet/exception/NoCacheException.kt b/app-catalog/samples/wNet/libwNet/src/main/java/lib/wintmain/libwNet/exception/NoCacheException.kt new file mode 100644 index 0000000..cdb42d4 --- /dev/null +++ b/app-catalog/samples/wNet/libwNet/src/main/java/lib/wintmain/libwNet/exception/NoCacheException.kt @@ -0,0 +1,38 @@ +/* + * Copyright 2023-2025 wintmain + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package lib.wintmain.libwNet.exception + +import lib.wintmain.libwNet.cache.ForceCache +import okhttp3.Request + +/** + * 读取缓存失败 + * 仅当设置强制缓存模式[lib.wintmain.libwNet.cache.CacheMode.READ]和[lib.wintmain.libwNet.cache.CacheMode.REQUEST_THEN_READ]才会发生此异常 + * @param request 请求信息 + * @param message 错误描述信息 + * @param cause 错误原因 + */ +class NoCacheException( + request: Request, + message: String? = null, + cause: Throwable? = null +) : NetException(request, message, cause) { + + override fun getLocalizedMessage(): String { + return "cacheKey = " + ForceCache.key(request) + " " + super.getLocalizedMessage() + } +} \ No newline at end of file diff --git a/app-catalog/samples/wNet/libwNet/src/main/java/lib/wintmain/libwNet/exception/RequestParamsException.kt b/app-catalog/samples/wNet/libwNet/src/main/java/lib/wintmain/libwNet/exception/RequestParamsException.kt new file mode 100644 index 0000000..ac424f2 --- /dev/null +++ b/app-catalog/samples/wNet/libwNet/src/main/java/lib/wintmain/libwNet/exception/RequestParamsException.kt @@ -0,0 +1,33 @@ +/* + * Copyright 2023-2025 wintmain + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package lib.wintmain.libwNet.exception + +import okhttp3.Response + +/** + * 400 - 499 客户端请求异常 + * @param response 响应信息 + * @param message 错误描述信息 + * @param cause 错误原因 + * @param tag 可携带任意对象, 一般用于在转换器/拦截器中添加然后传递给错误处理器去使用判断 + */ +class RequestParamsException( + response: Response, + message: String? = null, + cause: Throwable? = null, + var tag: Any? = null +) : HttpResponseException(response, message, cause) \ No newline at end of file diff --git a/app-catalog/samples/wNet/libwNet/src/main/java/lib/wintmain/libwNet/exception/ResponseException.kt b/app-catalog/samples/wNet/libwNet/src/main/java/lib/wintmain/libwNet/exception/ResponseException.kt new file mode 100644 index 0000000..5ec2ee6 --- /dev/null +++ b/app-catalog/samples/wNet/libwNet/src/main/java/lib/wintmain/libwNet/exception/ResponseException.kt @@ -0,0 +1,35 @@ +/* + * Copyright 2023-2025 wintmain + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +@file:Suppress("MemberVisibilityCanBePrivate") + +package lib.wintmain.libwNet.exception + +import okhttp3.Response + +/** + * 状态码在200..299, 但是返回数据不符合业务要求可以抛出该异常 + * @param response 响应信息 + * @param message 错误描述信息 + * @param cause 错误原因 + * @param tag 可携带任意对象, 一般用于在转换器/拦截器中添加然后传递给错误处理器去使用判断 + */ +class ResponseException( + response: Response, + message: String? = null, + cause: Throwable? = null, + var tag: Any? = null +) : HttpResponseException(response, message, cause) \ No newline at end of file diff --git a/app-catalog/samples/wNet/libwNet/src/main/java/lib/wintmain/libwNet/exception/ServerResponseException.kt b/app-catalog/samples/wNet/libwNet/src/main/java/lib/wintmain/libwNet/exception/ServerResponseException.kt new file mode 100644 index 0000000..3e6d70a --- /dev/null +++ b/app-catalog/samples/wNet/libwNet/src/main/java/lib/wintmain/libwNet/exception/ServerResponseException.kt @@ -0,0 +1,33 @@ +/* + * Copyright 2023-2025 wintmain + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package lib.wintmain.libwNet.exception + +import okhttp3.Response + +/** + * >= 500 服务器异常 + * @param response 响应信息 + * @param message 错误描述信息 + * @param cause 错误原因 + * @param tag 可携带任意对象, 一般用于在转换器/拦截器中添加然后传递给错误处理器去使用判断 + */ +class ServerResponseException( + response: Response, + message: String? = null, + cause: Throwable? = null, + var tag: Any? = null +) : HttpResponseException(response, message, cause) \ No newline at end of file diff --git a/app-catalog/samples/wNet/libwNet/src/main/java/lib/wintmain/libwNet/exception/URLParseException.kt b/app-catalog/samples/wNet/libwNet/src/main/java/lib/wintmain/libwNet/exception/URLParseException.kt new file mode 100644 index 0000000..676d8f0 --- /dev/null +++ b/app-catalog/samples/wNet/libwNet/src/main/java/lib/wintmain/libwNet/exception/URLParseException.kt @@ -0,0 +1,35 @@ +/* + * Copyright 2023-2025 wintmain + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package lib.wintmain.libwNet.exception + +/** + * URL地址错误 + * + * @param message 错误描述信息 + * @param cause 错误原因 + */ +open class URLParseException( + message: String? = null, + cause: Throwable? = null, +) : Exception(message, cause) { + + var occurred: String = "" + + override fun getLocalizedMessage(): String? { + return super.getLocalizedMessage() + occurred + } +} \ No newline at end of file diff --git a/app-catalog/samples/wNet/libwNet/src/main/java/lib/wintmain/libwNet/interceptor/LogRecordInterceptor.kt b/app-catalog/samples/wNet/libwNet/src/main/java/lib/wintmain/libwNet/interceptor/LogRecordInterceptor.kt new file mode 100644 index 0000000..fe0656f --- /dev/null +++ b/app-catalog/samples/wNet/libwNet/src/main/java/lib/wintmain/libwNet/interceptor/LogRecordInterceptor.kt @@ -0,0 +1,116 @@ +/* + * Copyright 2023-2025 wintmain + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package lib.wintmain.libwNet.interceptor + +import lib.wintmain.libwNet.body.name +import lib.wintmain.libwNet.body.peekBytes +import lib.wintmain.libwNet.body.value +import lib.wintmain.libwNet.log.LogRecorder +import okhttp3.FormBody +import okhttp3.Interceptor +import okhttp3.MultipartBody +import okhttp3.Request +import okhttp3.Response + +/** + * 网络日志记录器 + * 可以参考此拦截器为项目中其他网络请求库配置. 本拦截器属于标准的OkHttp拦截器适用于所有OkHttp拦截器内核的网络请求库 + * + * 在正式环境下请禁用此日志记录器. 因为他会消耗少量网络速度 + * + * @property enabled 是否启用日志输出 + * @property requestByteCount 请求日志输出字节数, -1 则为全部 + * @property responseByteCount 响应日志输出字节数, -1 则为全部 + */ +open class LogRecordInterceptor @JvmOverloads constructor( + var enabled: Boolean, + var requestByteCount: Long = 1024 * 1024, + var responseByteCount: Long = 1024 * 1024 * 4 +) : Interceptor { + + override fun intercept(chain: Interceptor.Chain): Response { + val request = chain.request() + if (!enabled) { + return chain.proceed(request) + } + + val generateId = LogRecorder.generateId() + LogRecorder.recordRequest( + generateId, + request.url.toString(), + request.method, + request.headers.toMultimap(), + getRequestLog(request) + ) + try { + val response = chain.proceed(request) + LogRecorder.recordResponse( + generateId, + System.currentTimeMillis(), + response.code, + response.headers.toMultimap(), + getResponseLog(response) + ) + return response + } catch (e: Exception) { + val error = "Review LogCat for details, occurred exception: ${e.javaClass.simpleName}" + LogRecorder.recordException(generateId, System.currentTimeMillis(), -1, null, error) + throw e + } + } + + /** + * 请求字符串 + */ + protected open fun getRequestLog(request: Request): String? { + val body = request.body ?: return null + val mediaType = body.contentType() + return when { + body is MultipartBody -> { + body.parts.joinToString("&") { + "${it.name()}=${it.value()}" + } + } + + body is FormBody -> { + body.peekBytes(requestByteCount).utf8() + } + + arrayOf("plain", "json", "xml", "html").contains(mediaType?.subtype) -> { + body.peekBytes(requestByteCount).utf8() + } + + else -> { + "$mediaType does not support output logs" + } + } + } + + /** + * 响应字符串 + */ + protected open fun getResponseLog(response: Response): String? { + val body = response.body ?: return null + val mediaType = body.contentType() + val isPrintType = arrayOf("plain", "json", "xml", "html").contains(mediaType?.subtype) + return if (isPrintType) { + body.peekBytes(responseByteCount).utf8() + } else { + "$mediaType does not support output logs" + } + } +} \ No newline at end of file diff --git a/app-catalog/samples/wNet/libwNet/src/main/java/lib/wintmain/libwNet/interceptor/NetOkHttpInterceptor.kt b/app-catalog/samples/wNet/libwNet/src/main/java/lib/wintmain/libwNet/interceptor/NetOkHttpInterceptor.kt new file mode 100644 index 0000000..84531ea --- /dev/null +++ b/app-catalog/samples/wNet/libwNet/src/main/java/lib/wintmain/libwNet/interceptor/NetOkHttpInterceptor.kt @@ -0,0 +1,131 @@ +/* + * Copyright 2023-2025 wintmain + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package lib.wintmain.libwNet.interceptor + +import lib.wintmain.libwNet.NetConfig +import lib.wintmain.libwNet.body.toNetRequestBody +import lib.wintmain.libwNet.body.toNetResponseBody +import lib.wintmain.libwNet.cache.CacheMode +import lib.wintmain.libwNet.cache.ForceCache +import lib.wintmain.libwNet.exception.HttpFailureException +import lib.wintmain.libwNet.exception.NetConnectException +import lib.wintmain.libwNet.exception.NetException +import lib.wintmain.libwNet.exception.NetSocketTimeoutException +import lib.wintmain.libwNet.exception.NetUnknownHostException +import lib.wintmain.libwNet.exception.NoCacheException +import lib.wintmain.libwNet.request.tagOf +import lib.wintmain.libwNet.tag.NetTag +import okhttp3.CacheControl +import okhttp3.Interceptor +import okhttp3.Response +import java.lang.ref.WeakReference +import java.net.ConnectException +import java.net.SocketTimeoutException +import java.net.UnknownHostException + +/** + * Net代理OkHttp的拦截器 + */ +object NetOkHttpInterceptor : Interceptor { + + override fun intercept(chain: Interceptor.Chain): Response { + var request = chain.request() + val reqBody = request.body?.toNetRequestBody(request.tagOf()) + val cache = request.tagOf() ?: NetConfig.forceCache + val cacheMode = request.tagOf() + request = request.newBuilder().apply { + if (cache != null && cacheMode != null) { + cacheControl(CacheControl.Builder().noCache().noStore().build()) + } + }.method(request.method, reqBody).build() + + var response: Response? = null + try { + appendRunningCall(chain) + response = if (cache != null) { + when (cacheMode) { + CacheMode.READ -> cache.get(request) ?: throw NoCacheException(request) + CacheMode.READ_THEN_REQUEST -> cache.get(request) ?: chain.proceed(request) + .run { + cache.put(this) + } + + CacheMode.REQUEST_THEN_READ -> try { + chain.proceed(request).run { + cache.put(this) + } + } catch (e: Exception) { + cache.get(request) ?: throw NoCacheException(request) + } + + CacheMode.WRITE -> chain.proceed(request).run { + cache.put(this) + } + + else -> chain.proceed(request) + } + } else { + chain.proceed(request) + } + val respBody = + response.body?.toNetResponseBody(request.tagOf()) { + removeRunningCall(chain) + } + response = response.newBuilder().body(respBody).build() + return response + } catch (e: SocketTimeoutException) { + throw NetSocketTimeoutException(request, e.message, e) + } catch (e: ConnectException) { + throw NetConnectException(request, cause = e) + } catch (e: UnknownHostException) { + throw NetUnknownHostException(request, message = e.message) + } catch (e: NetException) { + throw e + } catch (e: Throwable) { + throw HttpFailureException(request, cause = e) + } finally { + if (response?.body == null) { + removeRunningCall(chain) + } + } + } + + /** + * 将请求添加到请求队列 + */ + private fun appendRunningCall(chain: Interceptor.Chain) { + NetConfig.runningCalls.add(WeakReference(chain.call())) + } + + /** + * 将请求从请求队列移除 + */ + private fun removeRunningCall(chain: Interceptor.Chain) { + val iterator = NetConfig.runningCalls.iterator() + while (iterator.hasNext()) { + val call = iterator.next().get() + if (call == null) { + iterator.remove() + continue + } + if (call == chain.call()) { + iterator.remove() + return + } + } + } +} \ No newline at end of file diff --git a/app-catalog/samples/wNet/libwNet/src/main/java/lib/wintmain/libwNet/interceptor/RequestInterceptor.kt b/app-catalog/samples/wNet/libwNet/src/main/java/lib/wintmain/libwNet/interceptor/RequestInterceptor.kt new file mode 100644 index 0000000..4743479 --- /dev/null +++ b/app-catalog/samples/wNet/libwNet/src/main/java/lib/wintmain/libwNet/interceptor/RequestInterceptor.kt @@ -0,0 +1,29 @@ +/* + * Copyright 2023-2025 wintmain + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package lib.wintmain.libwNet.interceptor + +import lib.wintmain.libwNet.request.BaseRequest + +interface RequestInterceptor { + + /** + * 当你使用Net发起请求的时候就会触发该拦截器 + * 该拦截器属于轻量级不具备重发的功能, 一般用于请求参数的修改 + * 请勿在这里进行请求重发可能会导致死循环 + */ + fun interceptor(request: BaseRequest) +} \ No newline at end of file diff --git a/app-catalog/samples/wNet/libwNet/src/main/java/lib/wintmain/libwNet/interceptor/RetryInterceptor.kt b/app-catalog/samples/wNet/libwNet/src/main/java/lib/wintmain/libwNet/interceptor/RetryInterceptor.kt new file mode 100644 index 0000000..82fb380 --- /dev/null +++ b/app-catalog/samples/wNet/libwNet/src/main/java/lib/wintmain/libwNet/interceptor/RetryInterceptor.kt @@ -0,0 +1,43 @@ +/* + * Copyright 2023-2025 wintmain + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package lib.wintmain.libwNet.interceptor + +import androidx.annotation.IntRange +import okhttp3.Interceptor +import okhttp3.Response +import okhttp3.internal.closeQuietly + +/** + * 重试次数拦截器 + * OkHttp自带重试请求规则, 本拦截器并不推荐使用 + * 因为长时间阻塞用户请求是不合理的, 发生错误请让用户主动重试, 例如显示缺省页或者提示 + * @property retryCount 重试次数 + */ +class RetryInterceptor(@IntRange(from = 1) var retryCount: Int = 3) : Interceptor { + + override fun intercept(chain: Interceptor.Chain): Response { + var retryCount = 0 + val request = chain.request() + var response = chain.proceed(request) + while (!response.isSuccessful && retryCount < this.retryCount) { + retryCount++ + response.closeQuietly() + response = chain.proceed(request) + } + return response + } +} \ No newline at end of file diff --git a/app-catalog/samples/wNet/libwNet/src/main/java/lib/wintmain/libwNet/interfaces/NetDialogFactory.kt b/app-catalog/samples/wNet/libwNet/src/main/java/lib/wintmain/libwNet/interfaces/NetDialogFactory.kt new file mode 100644 index 0000000..f369df9 --- /dev/null +++ b/app-catalog/samples/wNet/libwNet/src/main/java/lib/wintmain/libwNet/interfaces/NetDialogFactory.kt @@ -0,0 +1,40 @@ +/* + * Copyright 2023-2025 wintmain + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package lib.wintmain.libwNet.interfaces + +import android.app.Dialog +import android.app.ProgressDialog +import androidx.fragment.app.FragmentActivity +import lib.wintmain.libwNet.R + +fun interface NetDialogFactory { + + /** + * 构建并返回Dialog. 当使用 scopeDialog 作用域时将会自动显示该对话框且作用域完成后关闭对话框 + * + * @param activity 请求发生所在的[FragmentActivity] + */ + fun onCreate(activity: FragmentActivity): Dialog + + companion object DEFAULT : NetDialogFactory { + override fun onCreate(activity: FragmentActivity): Dialog { + val progress = ProgressDialog(activity) + progress.setMessage(activity.getString(R.string.net_dialog_msg)) + return progress + } + } +} \ No newline at end of file diff --git a/app-catalog/samples/wNet/libwNet/src/main/java/lib/wintmain/libwNet/interfaces/NetErrorHandler.kt b/app-catalog/samples/wNet/libwNet/src/main/java/lib/wintmain/libwNet/interfaces/NetErrorHandler.kt new file mode 100644 index 0000000..234a2ac --- /dev/null +++ b/app-catalog/samples/wNet/libwNet/src/main/java/lib/wintmain/libwNet/interfaces/NetErrorHandler.kt @@ -0,0 +1,88 @@ +/* + * Copyright 2023-2025 wintmain + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package lib.wintmain.libwNet.interfaces + +import android.view.View +import lib.wintmain.libwNet.Net +import lib.wintmain.libwNet.NetConfig +import lib.wintmain.libwNet.R +import lib.wintmain.libwNet.exception.ConvertException +import lib.wintmain.libwNet.exception.DownloadFileException +import lib.wintmain.libwNet.exception.HttpFailureException +import lib.wintmain.libwNet.exception.NetConnectException +import lib.wintmain.libwNet.exception.NetException +import lib.wintmain.libwNet.exception.NetSocketTimeoutException +import lib.wintmain.libwNet.exception.NoCacheException +import lib.wintmain.libwNet.exception.RequestParamsException +import lib.wintmain.libwNet.exception.ResponseException +import lib.wintmain.libwNet.exception.ServerResponseException +import lib.wintmain.libwNet.exception.URLParseException +import lib.wintmain.libwNet.utils.TipUtils +import java.net.UnknownHostException + +interface NetErrorHandler { + + companion object DEFAULT : NetErrorHandler + + /** + * 全局的网络错误处理 + * + * @param e 发生的错误 + */ + fun onError(e: Throwable) { + val message = when (e) { + is UnknownHostException -> NetConfig.app.getString(R.string.net_host_error) + is URLParseException -> NetConfig.app.getString(R.string.net_url_error) + is NetConnectException -> NetConfig.app.getString(R.string.net_connect_error) + is NetSocketTimeoutException -> NetConfig.app.getString( + R.string.net_connect_timeout_error, + e.message + ) + + is DownloadFileException -> NetConfig.app.getString(R.string.net_download_error) + is ConvertException -> NetConfig.app.getString(R.string.net_parse_error) + is RequestParamsException -> NetConfig.app.getString(R.string.net_request_error) + is ServerResponseException -> NetConfig.app.getString(R.string.net_server_error) + is NullPointerException -> NetConfig.app.getString(R.string.net_null_error) + is NoCacheException -> NetConfig.app.getString(R.string.net_no_cache_error) + is ResponseException -> e.message + is HttpFailureException -> NetConfig.app.getString(R.string.request_failure) + is NetException -> NetConfig.app.getString(R.string.net_error) + else -> NetConfig.app.getString(R.string.net_other_error) + } + + Net.debug(e) + TipUtils.toast(message) + } + + /** + * 当你使用包含缺省页功能的作用域中发生错误将回调本函数处理错误 + * + * @param e 发生的错误 + * @param view 缺省页, StateLayout或者PageRefreshLayout + */ + fun onStateError(e: Throwable, view: View) { + when (e) { + is ConvertException, + is RequestParamsException, + is ResponseException, + is NullPointerException -> onError(e) + + else -> Net.debug(e) + } + } +} \ No newline at end of file diff --git a/app-catalog/samples/wNet/libwNet/src/main/java/lib/wintmain/libwNet/interfaces/ProgressListener.kt b/app-catalog/samples/wNet/libwNet/src/main/java/lib/wintmain/libwNet/interfaces/ProgressListener.kt new file mode 100644 index 0000000..633fa63 --- /dev/null +++ b/app-catalog/samples/wNet/libwNet/src/main/java/lib/wintmain/libwNet/interfaces/ProgressListener.kt @@ -0,0 +1,37 @@ +/* + * Copyright 2023-2025 wintmain + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package lib.wintmain.libwNet.interfaces + +import lib.wintmain.libwNet.component.Progress + +/** + * 进度监听器, 可能为下载或上传 + * + * @param interval 进度监听器刷新的间隔时间, 单位为毫秒, 默认值为500ms + */ +abstract class ProgressListener(var interval: Long = 500) { + // 上次进度变化的的开机时间 + var elapsedTime = 0L + + // 距离上次进度变化的新增字节数 + var intervalByteCount = 0L + + /** + * 监听上传/下载进度回调函数 + * @param p 上传或者下载进度 + */ + abstract fun onProgress(p: Progress) +} \ No newline at end of file diff --git a/app-catalog/samples/wNet/libwNet/src/main/java/lib/wintmain/libwNet/internal/NetDeferred.kt b/app-catalog/samples/wNet/libwNet/src/main/java/lib/wintmain/libwNet/internal/NetDeferred.kt new file mode 100644 index 0000000..48dbe2a --- /dev/null +++ b/app-catalog/samples/wNet/libwNet/src/main/java/lib/wintmain/libwNet/internal/NetDeferred.kt @@ -0,0 +1,40 @@ +/* + * Copyright 2023-2025 wintmain + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package lib.wintmain.libwNet.internal + +import kotlinx.coroutines.Deferred +import lib.wintmain.libwNet.exception.NetException +import lib.wintmain.libwNet.exception.URLParseException + +@PublishedApi +internal class NetDeferred(private val deferred: Deferred) : Deferred by deferred { + + override suspend fun await(): M { + // 追踪到网络请求异常发生位置 + val occurred = + Throwable().stackTrace.getOrNull(1)?.run { " ...(${fileName}:${lineNumber})" } + return try { + deferred.await() + } catch (e: Exception) { + when { + occurred != null && e is NetException -> e.occurred = occurred + occurred != null && e is URLParseException -> e.occurred = occurred + } + throw e + } + } +} \ No newline at end of file diff --git a/app-catalog/samples/wNet/libwNet/src/main/java/lib/wintmain/libwNet/internal/NetInitializer.kt b/app-catalog/samples/wNet/libwNet/src/main/java/lib/wintmain/libwNet/internal/NetInitializer.kt new file mode 100644 index 0000000..ad20847 --- /dev/null +++ b/app-catalog/samples/wNet/libwNet/src/main/java/lib/wintmain/libwNet/internal/NetInitializer.kt @@ -0,0 +1,35 @@ +/* + * Copyright 2023-2025 wintmain + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package lib.wintmain.libwNet.internal + +import android.content.Context +import androidx.startup.Initializer +import lib.wintmain.libwNet.NetConfig + +/** + * 使用AppStartup默认初始化[NetConfig.app], 仅应用主进程下有效 + * 在其他进程使用Net请手动在Application中初始化[NetConfig.initialize] + */ +internal class NetInitializer : Initializer { + override fun create(context: Context) { + NetConfig.app = context + } + + override fun dependencies(): MutableList>> { + return mutableListOf() + } +} \ No newline at end of file diff --git a/app-catalog/samples/wNet/libwNet/src/main/java/lib/wintmain/libwNet/log/LogRecorder.kt b/app-catalog/samples/wNet/libwNet/src/main/java/lib/wintmain/libwNet/log/LogRecorder.kt new file mode 100644 index 0000000..e8839e9 --- /dev/null +++ b/app-catalog/samples/wNet/libwNet/src/main/java/lib/wintmain/libwNet/log/LogRecorder.kt @@ -0,0 +1,235 @@ +/* + * Copyright 2023-2025 wintmain + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package lib.wintmain.libwNet.log + +import android.annotation.SuppressLint +import android.os.Bundle +import android.os.Handler +import android.os.HandlerThread +import android.os.Looper +import android.os.Message +import android.os.Process +import android.util.Log +import lib.wintmain.libwNet.Net +import lib.wintmain.libwNet.log.MessageType.REQUEST_BODY +import lib.wintmain.libwNet.log.MessageType.REQUEST_HEADER +import lib.wintmain.libwNet.log.MessageType.REQUEST_METHOD +import lib.wintmain.libwNet.log.MessageType.REQUEST_TIME +import lib.wintmain.libwNet.log.MessageType.REQUEST_URL +import lib.wintmain.libwNet.log.MessageType.RESPONSE_BODY +import lib.wintmain.libwNet.log.MessageType.RESPONSE_END +import lib.wintmain.libwNet.log.MessageType.RESPONSE_ERROR +import lib.wintmain.libwNet.log.MessageType.RESPONSE_HEADER +import lib.wintmain.libwNet.log.MessageType.RESPONSE_STATUS +import lib.wintmain.libwNet.log.MessageType.RESPONSE_TIME +import java.text.DateFormat +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale +import java.util.concurrent.atomic.AtomicLong + +/** + * 日志记录器 + */ +object LogRecorder { + + /** 是否启用日志记录器 */ + @JvmStatic + var enabled = true + + private val handler by lazy { + val handlerThread = HandlerThread("OkHttpProfiler", Process.THREAD_PRIORITY_BACKGROUND) + handlerThread.start() + LogBodyHandler(handlerThread.looper) + } + + private const val LOG_LENGTH = 4000 + private const val SLOW_DOWN_PARTS_AFTER = 20 + private const val LOG_PREFIX = "OKPRFL" + private const val DELIMITER = "_" + private const val HEADER_DELIMITER = ':' + private const val SPACE = ' ' + private const val KEY_TAG = "TAG" + private const val KEY_VALUE = "VALUE" + private const val KEY_PARTS_COUNT = "PARTS_COUNT" + private val format: DateFormat = SimpleDateFormat("ddhhmmssSSS", Locale.US) + private val previousTime = AtomicLong() + + /** + * 产生一个唯一的基于时间戳Id + */ + @JvmStatic + @Synchronized + fun generateId(): String { + if (!enabled) return "" + var currentTime: Long = format.format(Date()).toLong() + var previousTime: Long = previousTime.get() + if (currentTime <= previousTime) { + currentTime = ++previousTime + } + LogRecorder.previousTime.set(currentTime) + return currentTime.toString(Character.MAX_RADIX) + } + + /** + * 发送请求信息到记录器中 + * + * @param id 请求的唯一标识符 + * @param url 请求URL地址 + * @param method 请求方法 + * @param headers 请求头 + * @param body 请求体 + */ + @JvmStatic + fun recordRequest( + id: String, + url: String, + method: String, + headers: Map>, + body: String? + ) { + if (!enabled) return + fastLog(id, REQUEST_METHOD, method) + fastLog(id, REQUEST_URL, url) + fastLog(id, REQUEST_TIME, System.currentTimeMillis().toString()) + + for ((key, value) in headers) { + var header = value.toString() + if (header.length > 2) header = header.substring(1, header.length - 1) + fastLog(id, REQUEST_HEADER, key + HEADER_DELIMITER + SPACE + header) + } + largeLog(id, REQUEST_BODY, body) + } + + /** + * 发送响应信息到记录器中 + * + * @param id 请求的唯一标识符 + * @param code 响应码 + * @param headers 响应头 + * @param body 响应体 + */ + @JvmStatic + fun recordResponse( + id: String, + requestMillis: Long, + code: Int, + headers: Map>, + body: String? + ) { + if (!enabled) return + largeLog(id, RESPONSE_BODY, body) + logWithHandler(id, RESPONSE_STATUS, code.toString(), 0) + for ((key, value) in headers) { + var header = value.toString() + if (header.length > 2) header = header.substring(1, header.length - 1) + logWithHandler(id, RESPONSE_HEADER, key + HEADER_DELIMITER + header, 0) + } + logWithHandler( + id, + RESPONSE_TIME, + (System.currentTimeMillis() - requestMillis).toString(), + 0 + ) + logWithHandler(id, RESPONSE_END, "-->", 0) + } + + /** + * 发送请求异常到记录器 + * + * @param id 请求的唯一标识符 + * @param requestMillis 请求时间毫秒值 + * @param error 错误信息, 如果存在\n换行符, 仅接受最后一行 + */ + @JvmStatic + fun recordException( + id: String, + requestMillis: Long, + code: Int?, + response: String?, + error: String? + ) { + if (!enabled) return + largeLog(id, RESPONSE_BODY, response) + logWithHandler(id, RESPONSE_STATUS, code.toString(), 0) + logWithHandler(id, RESPONSE_ERROR, error, 0) + logWithHandler( + id, + RESPONSE_TIME, + (System.currentTimeMillis() - requestMillis).toString(), + 0 + ) + logWithHandler(id, RESPONSE_END, "-->", 0) + } + + @SuppressLint("LogNotTimber") + private fun fastLog(id: String, type: MessageType, message: String?) { + val tag = LOG_PREFIX + DELIMITER + id + DELIMITER + type.type + if (message != null) { + Log.v(tag, message) + } + } + + private fun logWithHandler(id: String, type: MessageType, message: String?, partsCount: Int) { + message ?: return + val handlerMessage = handler.obtainMessage() + val tag = LOG_PREFIX + DELIMITER + id + DELIMITER + type.type + val bundle = Bundle() + bundle.putString(KEY_TAG, tag) + bundle.putString(KEY_VALUE, message) + bundle.putInt(KEY_PARTS_COUNT, partsCount) + handlerMessage.data = bundle + handler.sendMessage(handlerMessage) + } + + private fun largeLog(id: String, type: MessageType, content: String?) { + content ?: return + val contentLength = content.length + if (contentLength > LOG_LENGTH) { + val parts = contentLength / LOG_LENGTH + for (i in 0..parts) { + val start = i * LOG_LENGTH + var end = start + LOG_LENGTH + if (end > contentLength) { + end = contentLength + } + logWithHandler(id, type, content.substring(start, end), parts) + } + } else { + logWithHandler(id, type, content, 0) + } + } + + private class LogBodyHandler(looper: Looper) : Handler(looper) { + override fun handleMessage(msg: Message) { + val bundle = msg.data + if (bundle != null) { + val partsCount = bundle.getInt(KEY_PARTS_COUNT, 0) + if (partsCount > SLOW_DOWN_PARTS_AFTER) { + try { + Thread.sleep(5L) + } catch (e: InterruptedException) { + Net.debug(e) + } + } + val data = bundle.getString(KEY_VALUE) ?: "null" + val key = bundle.getString(KEY_TAG) + Log.v(key, data) + } + } + } +} \ No newline at end of file diff --git a/app-catalog/samples/wNet/libwNet/src/main/java/lib/wintmain/libwNet/log/MessageType.kt b/app-catalog/samples/wNet/libwNet/src/main/java/lib/wintmain/libwNet/log/MessageType.kt new file mode 100644 index 0000000..1f00d93 --- /dev/null +++ b/app-catalog/samples/wNet/libwNet/src/main/java/lib/wintmain/libwNet/log/MessageType.kt @@ -0,0 +1,33 @@ +/* + * Copyright 2023-2025 wintmain + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package lib.wintmain.libwNet.log + +enum class MessageType(var type: String) { + REQUEST_URL("RQU"), + REQUEST_TIME("RQT"), + REQUEST_METHOD("RQM"), + REQUEST_HEADER("RQH"), + REQUEST_BODY("RQB"), + REQUEST_END("RQD"), + RESPONSE_TIME("RST"), + RESPONSE_STATUS("RSS"), + RESPONSE_HEADER("RSH"), + RESPONSE_BODY("RSB"), + RESPONSE_END("RSD"), + RESPONSE_ERROR("REE"), + UNKNOWN("UNKNOWN"); +} \ No newline at end of file diff --git a/app-catalog/samples/wNet/libwNet/src/main/java/lib/wintmain/libwNet/okhttp/OkHttpBuilder.kt b/app-catalog/samples/wNet/libwNet/src/main/java/lib/wintmain/libwNet/okhttp/OkHttpBuilder.kt new file mode 100644 index 0000000..268de01 --- /dev/null +++ b/app-catalog/samples/wNet/libwNet/src/main/java/lib/wintmain/libwNet/okhttp/OkHttpBuilder.kt @@ -0,0 +1,136 @@ +/* + * Copyright 2023-2025 wintmain + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package lib.wintmain.libwNet.okhttp + +import lib.wintmain.libwNet.NetConfig +import lib.wintmain.libwNet.convert.NetConverter +import lib.wintmain.libwNet.interceptor.NetOkHttpInterceptor +import lib.wintmain.libwNet.interceptor.RequestInterceptor +import lib.wintmain.libwNet.interfaces.NetDialogFactory +import lib.wintmain.libwNet.interfaces.NetErrorHandler +import lib.wintmain.libwNet.utils.Https +import lib.wintmain.libwNet.utils.chooseTrustManager +import lib.wintmain.libwNet.utils.prepareKeyManager +import lib.wintmain.libwNet.utils.prepareTrustManager +import okhttp3.OkHttpClient +import java.io.InputStream +import java.security.KeyManagementException +import java.security.NoSuchAlgorithmException +import javax.net.ssl.SSLContext +import javax.net.ssl.TrustManager +import javax.net.ssl.X509TrustManager + +/** + * 开启日志 + * @param enabled 是否启用日志 + * @param tag 日志标签 + */ +fun OkHttpClient.Builder.setDebug(enabled: Boolean, tag: String = NetConfig.TAG) = apply { + NetConfig.debug = enabled + NetConfig.TAG = tag +} + +/** + * Net要求经过该函数处理创建特殊的OkHttpClient + */ +fun OkHttpClient.Builder.toNetOkhttp() = apply { + val interceptors = interceptors() + if (!interceptors.contains(NetOkHttpInterceptor)) { + addInterceptor(NetOkHttpInterceptor) + } +} + +/** + * 配置信任所有证书 + * @param trustManager 如果需要自己校验,那么可以自己实现相关校验,如果不需要自己校验,那么传null即可 + * @param bksFile 客户端使用bks证书校验服务端证书 + * @param password bks证书的密码 + */ +fun OkHttpClient.Builder.setSSLCertificate( + trustManager: X509TrustManager?, + bksFile: InputStream? = null, + password: String? = null, +) = apply { + try { + val trustManagerFinal: X509TrustManager = trustManager ?: Https.UnSafeTrustManager + + val keyManagers = prepareKeyManager(bksFile, password) + val sslContext = SSLContext.getInstance("TLS") + // 用上面得到的trustManagers初始化SSLContext,这样sslContext就会信任keyStore中的证书 + // 第一个参数是授权的密钥管理器,用来授权验证,比如授权自签名的证书验证。第二个是被授权的证书管理器,用来验证服务器端的证书 + sslContext.init(keyManagers, arrayOf(trustManagerFinal), null) + // 通过sslContext获取SSLSocketFactory对象 + + sslSocketFactory(sslContext.socketFactory, trustManagerFinal) + } catch (e: NoSuchAlgorithmException) { + throw AssertionError(e) + } catch (e: KeyManagementException) { + throw AssertionError(e) + } +} + +/** + * 配置信任所有证书 + * @param certificates 含有服务端公钥的证书校验服务端证书 + * @param bksFile 客户端使用bks证书校验服务端证书 + * @param password bks证书的密码 + */ +fun OkHttpClient.Builder.setSSLCertificate( + vararg certificates: InputStream, + bksFile: InputStream? = null, + password: String? = null +) = apply { + val trustManager = prepareTrustManager(*certificates)?.let { chooseTrustManager(it) } + setSSLCertificate(trustManager, bksFile, password) +} + +/** + * 信任所有证书 + */ +fun OkHttpClient.Builder.trustSSLCertificate() = apply { + hostnameVerifier(Https.UnSafeHostnameVerifier) + setSSLCertificate(null) +} + +/** + * 转换器 + */ +fun OkHttpClient.Builder.setConverter(converter: NetConverter) = apply { + NetConfig.converter = converter +} + +/** + * 添加轻量级的请求拦截器, 可以在每次请求之前修改参数或者客户端配置 + * 该拦截器不同于OkHttp的Interceptor无需处理请求动作 + */ +fun OkHttpClient.Builder.setRequestInterceptor(interceptor: RequestInterceptor) = apply { + NetConfig.requestInterceptor = interceptor +} + +/** + * 全局错误处理器 + */ +fun OkHttpClient.Builder.setErrorHandler(handler: NetErrorHandler) = apply { + NetConfig.errorHandler = handler +} + +/** + * 请求对话框构建工厂 + */ +fun OkHttpClient.Builder.setDialogFactory(dialogFactory: NetDialogFactory) = apply { + NetConfig.dialogFactory = dialogFactory +} \ No newline at end of file diff --git a/app-catalog/samples/wNet/libwNet/src/main/java/lib/wintmain/libwNet/okhttp/OkHttpExtension.kt b/app-catalog/samples/wNet/libwNet/src/main/java/lib/wintmain/libwNet/okhttp/OkHttpExtension.kt new file mode 100644 index 0000000..db02501 --- /dev/null +++ b/app-catalog/samples/wNet/libwNet/src/main/java/lib/wintmain/libwNet/okhttp/OkHttpExtension.kt @@ -0,0 +1,31 @@ +/* + * Copyright 2023-2025 wintmain + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package lib.wintmain.libwNet.okhttp + +import lib.wintmain.libwNet.interceptor.NetOkHttpInterceptor +import okhttp3.OkHttpClient + +/** + * Net要求经过该函数处理创建特殊的OkHttpClient + */ +fun OkHttpClient.toNetOkhttp() = run { + if (!interceptors.contains(NetOkHttpInterceptor)) { + newBuilder().addInterceptor(NetOkHttpInterceptor).build() + } else { + this + } +} \ No newline at end of file diff --git a/app-catalog/samples/wNet/libwNet/src/main/java/lib/wintmain/libwNet/reflect/TypeUtils.kt b/app-catalog/samples/wNet/libwNet/src/main/java/lib/wintmain/libwNet/reflect/TypeUtils.kt new file mode 100644 index 0000000..b8f7f2d --- /dev/null +++ b/app-catalog/samples/wNet/libwNet/src/main/java/lib/wintmain/libwNet/reflect/TypeUtils.kt @@ -0,0 +1,23 @@ +/* + * Copyright 2023-2025 wintmain + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package lib.wintmain.libwNet.reflect + +import kotlin.reflect.javaType +import kotlin.reflect.typeOf + +@OptIn(ExperimentalStdlibApi::class) +inline fun typeTokenOf() = typeOf().javaType \ No newline at end of file diff --git a/app-catalog/samples/wNet/libwNet/src/main/java/lib/wintmain/libwNet/request/BaseRequest.kt b/app-catalog/samples/wNet/libwNet/src/main/java/lib/wintmain/libwNet/request/BaseRequest.kt new file mode 100644 index 0000000..2a5500c --- /dev/null +++ b/app-catalog/samples/wNet/libwNet/src/main/java/lib/wintmain/libwNet/request/BaseRequest.kt @@ -0,0 +1,505 @@ +/* + * Copyright 2023-2025 wintmain + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +@file:Suppress("unused", "MemberVisibilityCanBePrivate", "NAME_SHADOWING", "RedundantSetter") + +package lib.wintmain.libwNet.request + +import kotlinx.coroutines.CoroutineExceptionHandler +import lib.wintmain.libwNet.NetConfig +import lib.wintmain.libwNet.OkHttpUtils +import lib.wintmain.libwNet.cache.CacheMode +import lib.wintmain.libwNet.cache.ForceCache +import lib.wintmain.libwNet.convert.NetConverter +import lib.wintmain.libwNet.exception.URLParseException +import lib.wintmain.libwNet.interfaces.ProgressListener +import lib.wintmain.libwNet.okhttp.toNetOkhttp +import lib.wintmain.libwNet.reflect.typeTokenOf +import lib.wintmain.libwNet.request.Method.GET +import lib.wintmain.libwNet.response.convert +import lib.wintmain.libwNet.tag.NetTag +import okhttp3.CacheControl +import okhttp3.Call +import okhttp3.Callback +import okhttp3.Headers +import okhttp3.HttpUrl +import okhttp3.HttpUrl.Companion.toHttpUrl +import okhttp3.HttpUrl.Companion.toHttpUrlOrNull +import okhttp3.OkHttpClient +import okhttp3.Request +import java.io.File +import java.lang.reflect.Type +import java.net.URL +import java.util.concurrent.TimeUnit +import kotlin.reflect.typeOf + +abstract class BaseRequest { + + /** 请求的Url构造器 */ + open var httpUrl: HttpUrl.Builder = HttpUrl.Builder() + + /** 当前请求的数据转换器 */ + open var converter: NetConverter = NetConfig.converter + + /** 请求的方法 */ + open var method = GET + + // + + /** 请求对象构造器 */ + open var okHttpRequest: Request.Builder = Request.Builder() + + /** 请求客户端 */ + open var okHttpClient = NetConfig.okHttpClient + set(value) { + field = value.toNetOkhttp() + val forceCache = field.cache?.let { ForceCache(OkHttpUtils.diskLruCache(it)) } + tagOf(forceCache) + } + + /** + * 修改当前Request的OkHttpClient配置, 不会影响全局默认的OkHttpClient + */ + fun setClient(block: OkHttpClient.Builder.() -> Unit) { + okHttpClient = okHttpClient.newBuilder().apply(block).toNetOkhttp().build() + } + // + + // + /** + * 请求ID + * Group和Id在使用场景上有所区别, 预期上Group允许重复赋值给多个请求, Id仅允许赋值给一个请求, 但实际上都允许重复赋值 + * 在作用域中发起请求时会默认使用协程错误处理器作为Group: `setGroup(coroutineContext[CoroutineExceptionHandler])` + * 如果你覆盖Group会导致协程结束不会自动取消请求 + */ + fun setId(id: Any?) { + okHttpRequest.id = id + } + + /** + * 请求分组 + * Group和Id在使用场景上有所区别, 预期上Group允许重复赋值给多个请求, Id仅允许赋值给一个请求, 但实际上都允许重复赋值 + * 在作用域中发起请求时会默认使用协程错误处理器作为Group: `setGroup(coroutineContext[CoroutineExceptionHandler])` + * 如果你覆盖Group会导致协程结束不会自动取消请求 + */ + fun setGroup(group: Any?) { + okHttpRequest.group = group + } + // + + // + + /** + * 设置一个Url字符串, 其参数不会和你初始化时设置的主域名[NetConfig.host]进行拼接 + * 一般情况下我建议使用更为聪明的[setPath] + */ + open fun setUrl(url: String) { + try { + httpUrl = url.toHttpUrl().newBuilder() + } catch (e: Exception) { + throw URLParseException(url, e) + } + } + + /** + * 设置Url + */ + open fun setUrl(url: HttpUrl) { + httpUrl = url.newBuilder() + } + + /** + * 设置Url + */ + open fun setUrl(url: URL) { + setUrl(url.toString()) + } + + /** + * 解析配置Path, 支持识别query参数和绝对路径 + * @param path 如果其不包含http/https则会自动拼接[NetConfig.host] + */ + fun setPath(path: String?) { + val url = path?.toHttpUrlOrNull() + if (url == null) { + try { + httpUrl = (NetConfig.host + path).toHttpUrl().newBuilder() + } catch (e: Throwable) { + throw URLParseException(NetConfig.host + path, e) + } + } else { + this.httpUrl = url.newBuilder() + } + } + + /** + * 设置Url上的Query参数 + */ + fun setQuery(name: String, value: String?, encoded: Boolean = false) { + if (encoded) { + httpUrl.setEncodedQueryParameter(name, value) + } else { + httpUrl.setQueryParameter(name, value) + } + } + + /** + * 设置Url上的Query参数 + */ + fun setQuery(name: String, value: Number?) { + setQuery(name, value?.toString() ?: return) + } + + /** + * 设置Url上的Query参数 + */ + fun setQuery(name: String, value: Boolean?) { + setQuery(name, value?.toString() ?: return) + } + + /** + * 添加Url上的Query参数 + */ + fun addQuery(name: String, value: String?, encoded: Boolean = false) { + if (encoded) { + httpUrl.addEncodedQueryParameter(name, value) + } else { + httpUrl.addQueryParameter(name, value) + } + } + + /** + * 添加Url上的Query参数 + */ + fun addQuery(name: String, value: Number?) { + addQuery(name, value?.toString() ?: return) + } + + /** + * 添加Url上的Query参数 + */ + fun addQuery(name: String, value: Boolean?) { + addQuery(name, value?.toString() ?: return) + } + + // + + // + + /** + * 基础类型表单参数 + * + * 如果当前请求为Url请求则为Query参数 + * 如果当前请求为表单请求则为表单参数 + * 如果当前为Multipart包含流/文件的请求则为multipart参数 + */ + abstract fun param(name: String, value: String?) + + /** + * 基础类型表单参数 + * + * 如果当前请求为Url请求则为Query参数 + * 如果当前请求为表单请求则为表单参数 + * 如果当前为Multipart包含流/文件的请求则为multipart参数 + * + * @param encoded 对应OkHttp参数函数中的encoded表示当前字段参数已经编码过. 不会再被自动编码 + */ + abstract fun param(name: String, value: String?, encoded: Boolean) + + /** + * 基础类型表单参数 + * + * 如果当前请求为Url请求则为Query参数 + * 如果当前请求为表单请求则为表单参数 + * 如果当前为Multipart包含流/文件的请求则为multipart参数 + */ + abstract fun param(name: String, value: Number?) + + /** + * 基础类型表单参数 + * + * 如果当前请求为Url请求则为Query参数 + * 如果当前请求为表单请求则为表单参数 + * 如果当前为Multipart包含流/文件的请求则为multipart参数 + */ + abstract fun param(name: String, value: Boolean?) + + // + + // + /** + * 设置额外信息 + * @see extra 读取 + * @see extras 全部额外信息 + */ + fun setExtra(name: String, tag: Any?) { + okHttpRequest.setExtra(name, tag) + } + + // + + // + + /** + * 使用Any::class作为键名添加标签 + * 使用Request.tag()返回tag + */ + fun tag(tag: Any?) { + okHttpRequest.tag(tag) + } + + /** + * 使用[type]作为key添加标签 + * 使用Request.tagOf()或者Request.tag(Class)读取tag + */ + fun tag(type: Class, tag: T?) { + okHttpRequest.tag(type, tag) + } + + /** + * 添加tag + * 使用Request.tagOf()或者Request.tag(Class)读取tag + */ + inline fun tagOf(tag: T?) { + okHttpRequest.tagOf(tag) + } + + // + + // + + /** + * 添加请求头 + * 如果已存在相同`name`的请求头会添加而不会覆盖, 因为请求头本身存在多个值 + */ + fun addHeader(name: String, value: String) { + okHttpRequest.addHeader(name, value) + } + + /** + * 设置请求头, 会覆盖请求头而不像[addHeader]是添加 + */ + fun setHeader(name: String, value: String) { + okHttpRequest.header(name, value) + } + + /** + * 删除请求头 + */ + fun removeHeader(name: String) { + okHttpRequest.removeHeader(name) + } + + /** + * 批量设置请求头 + */ + fun setHeaders(headers: Headers) { + okHttpRequest.headers(headers) + } + + /** + * 全部请求头 + */ + fun headers(): Headers.Builder { + return okHttpRequest.headers() + } + + // + + // + + /** + * 设置Http缓存协议头的缓存控制 + */ + fun setCacheControl(cacheControl: CacheControl) { + okHttpRequest.cacheControl(cacheControl) + } + + /** + * 设置缓存模式 + * 缓存模式将无视Http缓存协议进行强制读取/写入缓存 + */ + fun setCacheMode(mode: CacheMode) { + tagOf(mode) + } + + /** + * 自定义强制缓存使用的Key, 本方法对于Http缓存协议无效 + * @param key 缓存的Key无论是自定义还是默认(使用RequestMethod+URL作为Key)最终都会被进行SHA1编码, 所以无需考虑特殊字符问题 + */ + fun setCacheKey(key: String) { + tagOf(NetTag.CacheKey(key)) + } + + /** + * 强制缓存有效期 + * 注意即使缓存有效期很长也无法阻止LRU最近最少使用算法清除超出缓存最大限制 + * + * 标准Http缓存协议遵守协议本身的有效期, 当前方法配置无效 + * @param duration 持续时间 + * @param unit 时间单位, 默认毫秒 + */ + fun setCacheValidTime(duration: Long, unit: TimeUnit = TimeUnit.MILLISECONDS) { + tagOf(NetTag.CacheValidTime(unit.toMillis(duration))) + } + // + + // + + /** + * 下载文件名 + * 如果[setDownloadDir]函数使用完整路径(包含文件名的参数)作为参数则将无视本函数设置 + * 如果不调用本函数则默认是读取服务器返回的文件名 + * @see setDownloadFileNameDecode + * @see setDownloadFileNameConflict + * @see setDownloadDir + */ + fun setDownloadFileName(name: String) { + okHttpRequest.tagOf(NetTag.DownloadFileName(name)) + } + + /** + * 下载保存的目录, 也支持包含文件名称的完整路径, 如果使用完整路径则无视setDownloadFileName设置 + */ + fun setDownloadDir(name: String) { + okHttpRequest.tagOf(NetTag.DownloadFileDir(name)) + } + + /** + * 下载保存的目录, 也支持包含文件名称的完整路径, 如果使用完整路径则无视setDownloadFileName设置 + */ + fun setDownloadDir(name: File) { + okHttpRequest.tagOf(NetTag.DownloadFileDir(name)) + } + + /** + * 下载文件MD5校验 + * 如果服务器响应头`Content-MD5`值和指定路径已经存在的文件MD5相同, 则跳过下载直接返回该File + */ + fun setDownloadMd5Verify(enabled: Boolean = true) { + okHttpRequest.tagOf(NetTag.DownloadFileMD5Verify(enabled)) + } + + /** + * 下载文件路径存在同名文件时是创建新文件(添加序号)还是覆盖 + * 重命名规则是: $文件名_($序号).$后缀, 例如`file_name(1).apk` + */ + fun setDownloadFileNameConflict(enabled: Boolean = true) { + okHttpRequest.tagOf(NetTag.DownloadFileConflictRename(enabled)) + } + + /** + * 文件名称是否使用URL解码 + * 例如下载的文件名如果是中文, 服务器传输给你的会是被URL编码的字符串. 你使用URL解码后才是可读的中文名称 + */ + fun setDownloadFileNameDecode(enabled: Boolean = true) { + okHttpRequest.tagOf(NetTag.DownloadFileNameDecode(enabled)) + } + + /** + * 下载是否使用临时文件 + * 避免下载失败后覆盖同名文件或者无法判别是否已下载完整, 仅在下载完整以后才会显示为原有文件名 + * 临时文件命名规则: 文件名 + .downloading + * 下载文件名: install.apk, 临时文件名: install.apk.downloading + */ + fun setDownloadTempFile(enabled: Boolean = true) { + okHttpRequest.tagOf(NetTag.DownloadTempFile(enabled)) + } + + /** + * 下载监听器 + */ + fun addDownloadListener(progressListener: ProgressListener) { + okHttpRequest.downloadListeners().add(progressListener) + } + + // + + /** + * 为请求附着KType信息 + * KType属于Kotlin特有的Type, 某些Kotlin框架可能会使用到, 例如 kotlin.serialization + */ + @OptIn(ExperimentalStdlibApi::class) + inline fun setKType() { + okHttpRequest.kType = typeOf() + } + + /** + * 构建请求对象Request + */ + open fun buildRequest(): Request { + return okHttpRequest.method(method.name, null) + .url(httpUrl.build()) + .setConverter(converter) + .build() + } + + // + /** + * 执行同步请求 + */ + @OptIn(ExperimentalStdlibApi::class) + inline fun execute(): R { + NetConfig.requestInterceptor?.interceptor(this) + setKType() + val request = buildRequest() + val newCall = okHttpClient.newCall(request) + return newCall.execute().convert() + } + + /** + * 执行同步请求 + * 本方法仅为兼容Java使用存在 + * @param type 如果存在泛型嵌套要求使用[typeTokenOf]获取, 否则泛型会被擦除导致无法解析 + */ + fun execute(type: Type): R { + NetConfig.requestInterceptor?.interceptor(this) + val request = buildRequest() + val newCall = okHttpClient.newCall(request) + return newCall.execute().convert(type) + } + + /** + * 执行同步请求 + * @return 一个包含请求成功和错误的Result + */ + inline fun toResult(): Result { + NetConfig.requestInterceptor?.interceptor(this) + setKType() + val request = buildRequest() + val newCall = okHttpClient.newCall(request) + return try { + val value = newCall.execute().convert() + Result.success(value) + } catch (e: Exception) { + Result.failure(e) + } + } + // + + // + /** + * 队列请求. 支持OkHttp的Callback函数组件 + */ + fun enqueue(block: Callback): Call { + NetConfig.requestInterceptor?.interceptor(this) + val request = buildRequest() + val newCall = okHttpClient.newCall(request) + newCall.enqueue(block) + return newCall + } + // +} + diff --git a/app-catalog/samples/wNet/libwNet/src/main/java/lib/wintmain/libwNet/request/BodyRequest.kt b/app-catalog/samples/wNet/libwNet/src/main/java/lib/wintmain/libwNet/request/BodyRequest.kt new file mode 100644 index 0000000..02eb163 --- /dev/null +++ b/app-catalog/samples/wNet/libwNet/src/main/java/lib/wintmain/libwNet/request/BodyRequest.kt @@ -0,0 +1,202 @@ +/* + * Copyright 2023-2025 wintmain + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +@file:Suppress("MemberVisibilityCanBePrivate") + +package lib.wintmain.libwNet.request + +import android.net.Uri +import lib.wintmain.libwNet.interfaces.ProgressListener +import lib.wintmain.libwNet.request.Method.POST +import lib.wintmain.libwNet.utils.fileName +import lib.wintmain.libwNet.utils.toRequestBody +import okhttp3.FormBody +import okhttp3.MediaType +import okhttp3.MultipartBody +import okhttp3.Request +import okhttp3.RequestBody +import okhttp3.RequestBody.Companion.toRequestBody +import okio.ByteString +import org.json.JSONArray +import org.json.JSONObject +import java.io.File + +open class BodyRequest : BaseRequest() { + + /** + * 请求体 + */ + open var body: RequestBody? = null + + /** + * multipart请求体 + * 主要存放文件/IO流 + */ + open var partBody = MultipartBody.Builder() + + /** + * 表单请求体 + * 当你设置`partBody`后当前表单请求体中的所有参数都会被存放到partBody中 + */ + open var formBody = FormBody.Builder() + + /** + * multipart请求体的媒体类型 + */ + open var mediaType: MediaType = MediaConst.FORM + + /** + * 请求方法 + */ + override var method = POST + + // + override fun param(name: String, value: String?) { + formBody.add(name, value ?: return) + } + + override fun param(name: String, value: String?, encoded: Boolean) { + value ?: return + if (encoded) { + formBody.addEncoded(name, value) + } else { + formBody.add(name, value) + } + } + + override fun param(name: String, value: Number?) { + value ?: return + formBody.add(name, value.toString()) + } + + override fun param(name: String, value: Boolean?) { + value ?: return + formBody.add(name, value.toString()) + } + + fun param(name: String, value: RequestBody?) { + value ?: return + partBody.addFormDataPart(name, null, value) + } + + fun param(name: String, filename: String?, value: RequestBody?) { + value ?: return + partBody.addFormDataPart(name, filename, value) + } + + fun param(name: String, value: ByteString?) { + value ?: return + partBody.addFormDataPart(name, null, value.toRequestBody()) + } + + fun param(name: String, value: ByteArray?) { + value ?: return + partBody.addFormDataPart(name, null, value.toRequestBody()) + } + + fun param(name: String, value: Uri?) { + value ?: return + partBody.addFormDataPart(name, value.fileName(), value.toRequestBody()) + } + + fun param(name: String, value: File?) { + value ?: return + partBody.addFormDataPart(name, value.name, value.toRequestBody()) + } + + fun param(name: String, value: List?) { + value?.forEach { file -> + param(name, file) + } + } + + fun param(name: String, fileName: String?, value: File?) { + partBody.addFormDataPart(name, fileName, value?.toRequestBody() ?: return) + } + + fun param(body: MultipartBody.Part) { + partBody.addPart(body) + } + + // + + // + + /** + * 添加Json为请求体 + */ + fun json(body: JSONObject?) { + this.body = body?.toString()?.toRequestBody(MediaConst.JSON) + } + + /** + * 添加Json为请求体 + */ + fun json(body: JSONArray?) { + this.body = body?.toString()?.toRequestBody(MediaConst.JSON) + } + + /** + * 添加Json为请求体 + */ + fun json(body: String?) { + this.body = body?.toRequestBody(MediaConst.JSON) + } + + /** + * 添加Json为请求体 + */ + fun json(body: Map?) { + this.body = JSONObject(body ?: return).toString().toRequestBody(MediaConst.JSON) + } + + /** + * 添加Json对象为请求体 + */ + fun json(vararg body: Pair) { + this.body = JSONObject(body.toMap()).toString().toRequestBody(MediaConst.JSON) + } + // + + /** + * 添加上传进度监听器 + */ + fun addUploadListener(progressListener: ProgressListener) { + okHttpRequest.uploadListeners().add(progressListener) + } + + override fun buildRequest(): Request { + val body = if (body != null) body else { + val form = formBody.build() + try { + partBody.build() + for (i in 0 until form.size) { + val name = form.name(i) + val value = form.value(i) + partBody.addFormDataPart(name, value) + } + partBody.setType(mediaType).build() + } catch (e: IllegalStateException) { + form + } + } + + return okHttpRequest.method(method.name, body) + .url(httpUrl.build()) + .setConverter(converter) + .build() + } +} \ No newline at end of file diff --git a/app-catalog/samples/wNet/libwNet/src/main/java/lib/wintmain/libwNet/request/MediaConst.kt b/app-catalog/samples/wNet/libwNet/src/main/java/lib/wintmain/libwNet/request/MediaConst.kt new file mode 100644 index 0000000..a0ec2f7 --- /dev/null +++ b/app-catalog/samples/wNet/libwNet/src/main/java/lib/wintmain/libwNet/request/MediaConst.kt @@ -0,0 +1,46 @@ +/* + * Copyright 2023-2025 wintmain + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package lib.wintmain.libwNet.request + +import okhttp3.MediaType.Companion.toMediaType + +object MediaConst { + + val IMG = "image/*".toMediaType() + + val GIF = "image/gif".toMediaType() + + val JPEG = "image/jpeg".toMediaType() + + val PNG = "image/png".toMediaType() + + val MP4 = "video/mpeg".toMediaType() + + val TXT = "text/plain".toMediaType() + + val JSON = "application/json; charset=utf-8".toMediaType() + + val XML = "application/xml".toMediaType() + + val HTML = "text/html".toMediaType() + + val FORM = "multipart/form-data".toMediaType() + + val OCTET_STREAM = "application/octet-stream".toMediaType() + + val URLENCODED = "application/x-www-form-urlencoded".toMediaType() +} \ No newline at end of file diff --git a/app-catalog/samples/wNet/libwNet/src/main/java/lib/wintmain/libwNet/request/Method.kt b/app-catalog/samples/wNet/libwNet/src/main/java/lib/wintmain/libwNet/request/Method.kt new file mode 100644 index 0000000..1b0fde3 --- /dev/null +++ b/app-catalog/samples/wNet/libwNet/src/main/java/lib/wintmain/libwNet/request/Method.kt @@ -0,0 +1,22 @@ +/* + * Copyright 2023-2025 wintmain + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package lib.wintmain.libwNet.request + +enum class Method { + GET, HEAD, OPTIONS, TRACE, // Url request + POST, DELETE, PUT, PATCH // Body request +} \ No newline at end of file diff --git a/app-catalog/samples/wNet/libwNet/src/main/java/lib/wintmain/libwNet/request/RequestBuilder.kt b/app-catalog/samples/wNet/libwNet/src/main/java/lib/wintmain/libwNet/request/RequestBuilder.kt new file mode 100644 index 0000000..da722e3 --- /dev/null +++ b/app-catalog/samples/wNet/libwNet/src/main/java/lib/wintmain/libwNet/request/RequestBuilder.kt @@ -0,0 +1,154 @@ +/* + * Copyright 2023-2025 wintmain + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package lib.wintmain.libwNet.request + +import kotlinx.coroutines.CoroutineExceptionHandler +import lib.wintmain.libwNet.OkHttpUtils +import lib.wintmain.libwNet.convert.NetConverter +import lib.wintmain.libwNet.interfaces.ProgressListener +import lib.wintmain.libwNet.tag.NetTag +import okhttp3.Headers +import okhttp3.Request +import java.util.concurrent.ConcurrentLinkedQueue +import kotlin.reflect.KType + +// +/** + * 请求Id + * Group和Id在使用场景上有所区别, 预期上Group允许重复赋值给多个请求, Id仅允许赋值给一个请求, 但实际上都允许重复赋值 + * 在作用域中发起请求时会默认使用协程错误处理器作为Group: `setGroup(coroutineContext[CoroutineExceptionHandler])` + * 如果你覆盖Group会导致协程结束不会自动取消请求 + */ +var Request.Builder.id: Any? + get() = tagOf()?.value + set(value) { + tagOf(value?.let { NetTag.RequestId(it) }) + } + +/** + * 请求分组 + * Group和Id在使用场景上有所区别, 预期上Group允许重复赋值给多个请求, Id仅允许赋值给一个请求, 但实际上都允许重复赋值 + * 在作用域中发起请求时会默认使用协程错误处理器作为Group: `setGroup(coroutineContext[CoroutineExceptionHandler])` + * 如果你覆盖Group会导致协程结束不会自动取消请求 + */ +var Request.Builder.group: Any? + get() = tagOf()?.value + set(value) { + tagOf(value?.let { NetTag.RequestGroup(it) }) + } +// + +/** + * 为请求附着KType信息 + * KType属于Kotlin特有的Type, 某些Kotlin框架可能会使用到, 例如 kotlin.serialization + */ +var Request.Builder.kType: KType? + get() = tagOf()?.value + set(value) { + tagOf(value?.let { NetTag.RequestKType(it) }) + } + +/** + * 全部的请求头 + */ +fun Request.Builder.headers(): Headers.Builder { + return OkHttpUtils.headers(this) +} +// + +// +/** + * 设置额外信息 + * @see extra 读取 + * @see extras 全部额外信息 + */ +fun Request.Builder.setExtra(name: String, value: Any?) = apply { + val extras = extras() + if (value == null) { + extras.remove(name) + } else { + extras[name] = value + } +} + +/** + * 全部额外信息 + */ +fun Request.Builder.extras(): HashMap { + return tagOf() ?: kotlin.run { + val tag = NetTag.Extras() + tagOf(tag) + tag + } +} + +// + +// + +/** + * 读取OkHttp的tag(通过Class区分的tag) + */ +inline fun Request.Builder.tagOf(): T? { + return tags()[T::class.java] as? T +} + +/** + * 设置OkHttp的tag(通过Class区分的tag) + */ +inline fun Request.Builder.tagOf(value: T?) = apply { + tag(T::class.java, value) +} + +/** + * 全部tag + */ +fun Request.Builder.tags(): MutableMap, Any?> { + return OkHttpUtils.tags(this) +} +// + +// +/** + * 全部的上传监听器 + */ +fun Request.Builder.uploadListeners(): ConcurrentLinkedQueue { + return tagOf() ?: kotlin.run { + val tag = NetTag.UploadListeners() + tagOf(tag) + tag + } +} + +/** + * 全部的下载监听器 + */ +fun Request.Builder.downloadListeners(): ConcurrentLinkedQueue { + return tagOf() ?: kotlin.run { + val tag = NetTag.DownloadListeners() + tagOf(tag) + tag + } +} +// + +/** + * 设置转换器 + */ +fun Request.Builder.setConverter(converter: NetConverter) = apply { + tag(NetConverter::class.java, converter) +} \ No newline at end of file diff --git a/app-catalog/samples/wNet/libwNet/src/main/java/lib/wintmain/libwNet/request/RequestExtension.kt b/app-catalog/samples/wNet/libwNet/src/main/java/lib/wintmain/libwNet/request/RequestExtension.kt new file mode 100644 index 0000000..b8b117d --- /dev/null +++ b/app-catalog/samples/wNet/libwNet/src/main/java/lib/wintmain/libwNet/request/RequestExtension.kt @@ -0,0 +1,194 @@ +/* + * Copyright 2023-2025 wintmain + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package lib.wintmain.libwNet.request + +import kotlinx.coroutines.CoroutineExceptionHandler +import lib.wintmain.libwNet.NetConfig +import lib.wintmain.libwNet.OkHttpUtils +import lib.wintmain.libwNet.convert.NetConverter +import lib.wintmain.libwNet.interfaces.ProgressListener +import lib.wintmain.libwNet.tag.NetTag +import okhttp3.Request +import java.util.concurrent.ConcurrentLinkedQueue +import kotlin.reflect.KType + +// +/** + * 请求Id + * Group和Id在使用场景上有所区别, 预期上Group允许重复赋值给多个请求, Id仅允许赋值给一个请求, 但实际上都允许重复赋值 + * 在作用域中发起请求时会默认使用协程错误处理器作为Group: `setGroup(coroutineContext[CoroutineExceptionHandler])` + * 如果你覆盖Group会导致协程结束不会自动取消请求 + */ +var Request.id: Any? + get() = tagOf()?.value + set(value) { + tagOf(value?.let { NetTag.RequestId(it) }) + } + +/** + * 请求分组 + * Group和Id在使用场景上有所区别, 预期上Group允许重复赋值给多个请求, Id仅允许赋值给一个请求, 但实际上都允许重复赋值 + * 在作用域中发起请求时会默认使用协程错误处理器作为Group: `setGroup(coroutineContext[CoroutineExceptionHandler])` + * 如果你覆盖Group会导致协程结束不会自动取消请求 + */ +var Request.group: Any? + get() = tagOf()?.value + set(value) { + tagOf(value?.let { NetTag.RequestGroup(it) }) + } +// + +/** + * 为请求附着KType信息 + * KType属于Kotlin特有的Type, 某些Kotlin框架可能会使用到, 例如 kotlin.serialization + */ +var Request.kType: KType? + get() = tagOf()?.value + set(value) { + tagOf(value?.let { NetTag.RequestKType(it) }) + } + +// +/** + * 读取额外信息 + */ +fun Request.extra(name: String): Any? { + return tagOf()?.get(name) +} + +/** + * 全部额外信息 + */ +fun Request.extras(): HashMap { + val tags = tags() + return tags[NetTag.Extras::class.java] as NetTag.Extras? ?: kotlin.run { + val tag = NetTag.Extras() + tags[NetTag.Extras::class.java] = tag + tag + } +} +// + +// +/** + * 读取OkHttp的tag(通过Class区分的tag) + */ +inline fun Request.tagOf(): T? { + return tag(T::class.java) +} + +/** + * 设置OkHttp的tag(通过Class区分的tag) + */ +inline fun Request.tagOf(value: T?) = apply { + if (value == null) { + tags().remove(T::class.java) + } else { + tags()[T::class.java] = value + } +} + +/** + * 全部tag + */ +fun Request.tags(): MutableMap, Any?> { + return OkHttpUtils.tags(this) +} + +// + +// +/** + * 全部的上传监听器 + */ +fun Request.uploadListeners(): ConcurrentLinkedQueue { + return tagOf() ?: kotlin.run { + val tag = NetTag.UploadListeners() + tagOf(tag) + tag + } +} + +/** + * 全部的下载监听器 + */ +fun Request.downloadListeners(): ConcurrentLinkedQueue { + return tagOf() ?: kotlin.run { + val tag = NetTag.DownloadListeners() + tagOf(tag) + tag + } +} + +// + +// +/** + * 下载文件路径存在同名文件时是覆盖或创建新文件(添加序号) + * 重命名规则是: $文件名_($序号).$后缀, 例如`file_name(1).apk` + */ +fun Request.downloadConflictRename(): Boolean { + return tagOf()?.value == true +} + +/** + * 下载文件MD5校验 + * 如果服务器响应头`Content-MD5`值和指定路径已经存在的文件MD5相同, 则跳过下载直接返回该File + */ +fun Request.downloadMd5Verify(): Boolean { + return tagOf()?.value == true +} + +/** + * 下载文件目录 + */ +fun Request.downloadFileDir(): String { + return tagOf()?.value ?: NetConfig.app.filesDir.absolutePath +} + +/** + * 下载文件名 + */ +fun Request.downloadFileName(): String? { + return tagOf()?.value +} + +/** + * 下载的文件名称是否解码 + * 例如下载的文件名如果是中文, 服务器传输给你的会是被URL编码的字符串. 你使用URL解码后才是可读的中文名称 + */ +fun Request.downloadFileNameDecode(): Boolean { + return tagOf()?.value == true +} + +/** + * 下载是否使用临时文件 + * 避免下载失败后覆盖同名文件或者无法判别是否已下载完整, 仅在下载完整以后才会显示为原有文件名 + * 临时文件命名规则: 文件名 + .downloading + * 下载文件名: install.apk, 临时文件名: install.apk.downloading + */ +fun Request.downloadTempFile(): Boolean { + return tagOf()?.value == true +} +// + +/** + * 返回请求包含的转换器 + */ +fun Request.converter(): NetConverter { + return tagOf() ?: NetConfig.converter +} \ No newline at end of file diff --git a/app-catalog/samples/wNet/libwNet/src/main/java/lib/wintmain/libwNet/request/UrlRequest.kt b/app-catalog/samples/wNet/libwNet/src/main/java/lib/wintmain/libwNet/request/UrlRequest.kt new file mode 100644 index 0000000..e576a7b --- /dev/null +++ b/app-catalog/samples/wNet/libwNet/src/main/java/lib/wintmain/libwNet/request/UrlRequest.kt @@ -0,0 +1,44 @@ +/* + * Copyright 2023-2025 wintmain + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package lib.wintmain.libwNet.request + +open class UrlRequest : BaseRequest() { + + override fun param(name: String, value: String?) { + value ?: return + httpUrl.setQueryParameter(name, value) + } + + override fun param(name: String, value: String?, encoded: Boolean) { + value ?: return + if (encoded) { + httpUrl.setEncodedQueryParameter(name, value) + } else { + httpUrl.setQueryParameter(name, value) + } + } + + override fun param(name: String, value: Number?) { + value ?: return + httpUrl.setQueryParameter(name, value.toString()) + } + + override fun param(name: String, value: Boolean?) { + value ?: return + httpUrl.setQueryParameter(name, value.toString()) + } +} \ No newline at end of file diff --git a/app-catalog/samples/wNet/libwNet/src/main/java/lib/wintmain/libwNet/response/ResponseExtension.kt b/app-catalog/samples/wNet/libwNet/src/main/java/lib/wintmain/libwNet/response/ResponseExtension.kt new file mode 100644 index 0000000..d4af2ce --- /dev/null +++ b/app-catalog/samples/wNet/libwNet/src/main/java/lib/wintmain/libwNet/response/ResponseExtension.kt @@ -0,0 +1,190 @@ +/* + * Copyright 2023-2025 wintmain + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package lib.wintmain.libwNet.response + +import lib.wintmain.libwNet.component.Progress +import lib.wintmain.libwNet.exception.ConvertException +import lib.wintmain.libwNet.exception.DownloadFileException +import lib.wintmain.libwNet.exception.NetException +import lib.wintmain.libwNet.reflect.typeTokenOf +import lib.wintmain.libwNet.request.* +import lib.wintmain.libwNet.tag.NetTag +import lib.wintmain.libwNet.utils.md5 +import okhttp3.Response +import okhttp3.internal.closeQuietly +import okio.buffer +import okio.sink +import java.io.File +import java.io.IOException +import java.lang.reflect.Type +import java.net.SocketException +import java.net.URLDecoder +import kotlin.coroutines.cancellation.CancellationException + +/** + * 按照以下顺序返回最终的下载文件的名称 + * + * 1. 指定文件名 + * 2. 响应头文件名 + * 3. 请求URL路径 + * 4. 时间戳 + */ +fun Response.fileName(): String { + request.downloadFileName().takeUnless { it.isNullOrBlank() }?.let { return it } + val disposition = header("Content-Disposition") + if (disposition != null) { + disposition.substringAfter("filename=", "").takeUnless { it.isBlank() }?.let { return it } + disposition.substringAfter("filename*=", "").trimStart(*"UTF-8''".toCharArray()) + .takeUnless { it.isBlank() }?.let { return it } + } + + var fileName: String = request.url.pathSegments.last().substringBefore("?") + fileName = if (fileName.isBlank()) "unknown_${System.currentTimeMillis()}" else { + if (request.downloadFileNameDecode()) { + try { + URLDecoder.decode(fileName, "UTF8") + } catch (e: Exception) { + fileName + } + } else fileName + } + return fileName +} + +/** + * 下载到指定文件 + */ +@Throws(DownloadFileException::class) +fun Response.file(): File? { + var dir = request.downloadFileDir() // 下载目录 + val fileName: String // 下载文件名 + val dirFile = File(dir) + // 判断downloadDir是否为目录 + var file = if (dirFile.isDirectory) { + fileName = fileName() + File(dir, fileName) + } else { + val temp = dir + dir = dir.substringBeforeLast(File.separatorChar) + fileName = temp.substringAfterLast(File.separatorChar) + dirFile + } + try { + if (file.exists()) { + // MD5校验匹配文件 + if (request.downloadMd5Verify()) { + val md5Header = header("Content-MD5") + if (md5Header != null && file.md5(true) == md5Header) { + val downloadListeners = request.tagOf() + if (!downloadListeners.isNullOrEmpty()) { + val fileSize = file.length() + val progress = Progress().apply { + currentByteCount = fileSize + totalByteCount = fileSize + intervalByteCount = fileSize + finish = true + } + downloadListeners.forEach { + it.onProgress(progress) + } + } + return file + } + } + // 命名冲突添加序列数字的后缀 + if (request.downloadConflictRename() && file.name == fileName) { + val fileExtension = file.extension + val fileNameWithoutExtension = file.nameWithoutExtension + fun rename(index: Long): File { + file = File(dir, fileNameWithoutExtension + "_($index)" + fileExtension) + return if (file.exists()) { + rename(index + 1) + } else file + } + file = rename(1) + } + } + + // 临时文件 + if (request.downloadTempFile()) { + file = File(dir, file.name + ".downloading") + } + val source = body?.source() ?: return null + if (!file.exists()) file.createNewFile() + file.sink().buffer().use { + it.writeAll(source) + source.closeQuietly() + } + // 下载完毕删除临时文件 + if (request.downloadTempFile()) { + val fileFinal = File(dir, fileName) + file.renameTo(fileFinal) + return fileFinal + } + return file + } catch (e: SocketException) { + // 取消请求需要删除下载临时文件 + if (request.downloadTempFile()) file.delete() + throw CancellationException(e) + } catch (e: Exception) { + throw DownloadFileException(this, cause = e) + } +} + +/** + * 响应体使用转换器处理数据 + */ +@Suppress("UNCHECKED_CAST") +@Throws(IOException::class) +inline fun Response.convert(): R { + try { + return request.converter().onConvert(typeTokenOf(), this) as R + } catch (e: CancellationException) { + throw e + } catch (e: NetException) { + throw e + } catch (e: Throwable) { + throw ConvertException( + this, + message = "An unexpected error occurred in the converter", + cause = e + ) + } +} + +/** + * 响应体使用转换器处理数据 + * 本方法仅为兼容Java使用存在 + * @param type 如果存在泛型嵌套要求使用[typeTokenOf]获取, 否则泛型会被擦除导致无法解析 + */ +@Suppress("UNCHECKED_CAST") +@Throws(IOException::class) +fun Response.convert(type: Type): R { + try { + return request.converter().onConvert(type, this) as R + } catch (e: CancellationException) { + throw e + } catch (e: NetException) { + throw e + } catch (e: Throwable) { + throw ConvertException( + this, + message = "An unexpected error occurred in the converter", + cause = e + ) + } +} \ No newline at end of file diff --git a/app-catalog/samples/wNet/libwNet/src/main/java/lib/wintmain/libwNet/scope/AndroidScope.kt b/app-catalog/samples/wNet/libwNet/src/main/java/lib/wintmain/libwNet/scope/AndroidScope.kt new file mode 100644 index 0000000..7728a1c --- /dev/null +++ b/app-catalog/samples/wNet/libwNet/src/main/java/lib/wintmain/libwNet/scope/AndroidScope.kt @@ -0,0 +1,128 @@ +/* + * Copyright 2023-2025 wintmain + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package lib.wintmain.libwNet.scope + +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.Lifecycle.Event +import androidx.lifecycle.LifecycleEventObserver +import androidx.lifecycle.LifecycleOwner +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineExceptionHandler +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.launch +import lib.wintmain.libwNet.Net +import lib.wintmain.libwNet.utils.runMain +import java.io.Closeable +import kotlin.coroutines.CoroutineContext +import kotlin.coroutines.EmptyCoroutineContext + +/** + * 异步协程作用域 + * @param lifecycleOwner 生命周期持有者 + * @param lifeEvent 生命周期事件, 默认为[Lifecycle.Event.ON_DESTROY]下取消协程作用域 + */ +@Suppress("unused", "MemberVisibilityCanBePrivate", "NAME_SHADOWING") +open class AndroidScope( + lifecycleOwner: LifecycleOwner? = null, + lifeEvent: Lifecycle.Event = Lifecycle.Event.ON_DESTROY, + val dispatcher: CoroutineDispatcher = Dispatchers.Main +) : CoroutineScope, Closeable { + + init { + runMain { + lifecycleOwner?.lifecycle?.addObserver(object : LifecycleEventObserver { + override fun onStateChanged(source: LifecycleOwner, event: Event) { + if (lifeEvent == event) cancel() + } + }) + } + } + + protected var catch: (AndroidScope.(Throwable) -> Unit)? = null + protected var finally: (AndroidScope.(Throwable?) -> Unit)? = null + private val exceptionHandler = CoroutineExceptionHandler { _, throwable -> + catch(throwable) + } + + val scopeGroup = exceptionHandler + + override val coroutineContext: CoroutineContext = + dispatcher + exceptionHandler + SupervisorJob() + + open fun launch(block: suspend CoroutineScope.() -> Unit): AndroidScope { + launch(EmptyCoroutineContext) { + block() + }.invokeOnCompletion { + finally(it) + } + return this + } + + protected open fun catch(e: Throwable) { + catch?.invoke(this@AndroidScope, e) ?: handleError(e) + } + + /** + * @param e 如果发生异常导致作用域执行完毕, 则该参数为该异常对象, 正常结束则为null + */ + protected open fun finally(e: Throwable?) { + finally?.invoke(this@AndroidScope, e) + } + + /** + * 当作用域内发生异常时回调 + */ + open fun catch(block: AndroidScope.(Throwable) -> Unit = {}): AndroidScope { + this.catch = block + return this + } + + /** + * 无论正常或者异常结束都将最终执行 + */ + open fun finally(block: AndroidScope.(Throwable?) -> Unit = {}): AndroidScope { + this.finally = block + return this + } + + /** + * 错误处理 + */ + open fun handleError(e: Throwable) { + Net.debug(e) + } + + open fun cancel(cause: CancellationException? = null) { + val job = coroutineContext[Job] + ?: error("Scope cannot be cancelled because it does not have a job: $this") + job.cancel(cause) + } + + open fun cancel( + message: String, + cause: Throwable? = null + ) = cancel(CancellationException(message, cause)) + + override fun close() { + cancel() + } +} + diff --git a/app-catalog/samples/wNet/libwNet/src/main/java/lib/wintmain/libwNet/scope/DialogCoroutineScope.kt b/app-catalog/samples/wNet/libwNet/src/main/java/lib/wintmain/libwNet/scope/DialogCoroutineScope.kt new file mode 100644 index 0000000..ab95d23 --- /dev/null +++ b/app-catalog/samples/wNet/libwNet/src/main/java/lib/wintmain/libwNet/scope/DialogCoroutineScope.kt @@ -0,0 +1,84 @@ +/* + * Copyright 2023-2025 wintmain + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package lib.wintmain.libwNet.scope + +import android.app.Dialog +import androidx.fragment.app.FragmentActivity +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver +import androidx.lifecycle.LifecycleObserver +import androidx.lifecycle.LifecycleOwner +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers +import lib.wintmain.libwNet.NetConfig + +/** + * 自动加载对话框网络请求 + * + * + * 开始: 显示对话框 + * 错误: 提示错误信息, 关闭对话框 + * 完全: 关闭对话框 + * + * @param activity 对话框跟随生命周期的FragmentActivity + * @param dialog 不使用默认的加载对话框而指定对话框 + * @param cancelable 是否允许用户取消对话框 + */ +@Suppress("DEPRECATION") +class DialogCoroutineScope( + val activity: FragmentActivity, + var dialog: Dialog? = null, + val cancelable: Boolean? = null, + dispatcher: CoroutineDispatcher = Dispatchers.Main +) : NetCoroutineScope(dispatcher = dispatcher), LifecycleObserver { + + init { + activity.lifecycle.addObserver(object : LifecycleEventObserver { + override fun onStateChanged(source: LifecycleOwner, event: Lifecycle.Event) { + if (event == Lifecycle.Event.ON_DESTROY) { + dialog?.cancel() + } + } + }) + } + + override fun start() { + activity.runOnUiThread { + val dialog = dialog ?: NetConfig.dialogFactory.onCreate(activity) + this.dialog = dialog + cancelable?.let { dialog.setCancelable(it) } + dialog.setOnCancelListener { + cancel() + } + if (!activity.isFinishing) { + dialog.show() + } + } + } + + override fun previewFinish(succeed: Boolean) { + super.previewFinish(succeed) + if (succeed && previewBreakLoading) { + dialog?.dismiss() + } + } + + override fun finally(e: Throwable?) { + super.finally(e) + dialog?.dismiss() + } +} \ No newline at end of file diff --git a/app-catalog/samples/wNet/libwNet/src/main/java/lib/wintmain/libwNet/scope/NetCoroutineScope.kt b/app-catalog/samples/wNet/libwNet/src/main/java/lib/wintmain/libwNet/scope/NetCoroutineScope.kt new file mode 100644 index 0000000..e0aa3c0 --- /dev/null +++ b/app-catalog/samples/wNet/libwNet/src/main/java/lib/wintmain/libwNet/scope/NetCoroutineScope.kt @@ -0,0 +1,130 @@ +/* + * Copyright 2023-2025 wintmain + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package lib.wintmain.libwNet.scope + +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleOwner +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.supervisorScope +import lib.wintmain.libwNet.Net +import lib.wintmain.libwNet.NetConfig +import kotlin.coroutines.EmptyCoroutineContext + +/** + * 自动显示网络错误信息协程作用域 + */ +@Suppress("unused", "MemberVisibilityCanBePrivate", "NAME_SHADOWING") +open class NetCoroutineScope( + lifecycleOwner: LifecycleOwner? = null, + lifeEvent: Lifecycle.Event = Lifecycle.Event.ON_DESTROY, + dispatcher: CoroutineDispatcher = Dispatchers.Main +) : AndroidScope(lifecycleOwner, lifeEvent, dispatcher) { + + /** 预览模式 */ + protected var preview: (suspend CoroutineScope.() -> Unit)? = null + + /** 是否启用预览 */ + protected var previewEnabled = true + + /** 是否读取缓存成功 */ + protected var previewSucceed = false + get() = if (preview != null) field else false + + /** 使用[preview]预览模式情况下读取缓存成功后, 网络请求失败是否处理错误信息 */ + protected var previewBreakError = false + get() = if (previewSucceed) field else false + + /** 使用[preview]预览模式情况下读取缓存成功后是否关闭加载动画 */ + protected var previewBreakLoading: Boolean = true + + override fun launch(block: suspend CoroutineScope.() -> Unit): NetCoroutineScope { + launch(EmptyCoroutineContext) { + start() + if (preview != null && previewEnabled) { + supervisorScope { + previewSucceed = try { + preview?.invoke(this) + true + } catch (e: Exception) { + false + } + previewFinish(previewSucceed) + } + } + block() + }.invokeOnCompletion { + finally(it) + } + return this + } + + protected open fun start() { + } + + /** + * 读取缓存回调 + * @param succeed 缓存是否成功 + */ + protected open fun previewFinish(succeed: Boolean) { + previewEnabled = false + } + + override fun handleError(e: Throwable) { + NetConfig.errorHandler.onError(e) + } + + override fun catch(e: Throwable) { + val catch = catch + if (catch != null) { + catch.invoke(this@NetCoroutineScope, e) + } else if (!previewBreakError) { + handleError(e) + } + } + + /** + * "预览"作用域 + * 该函数一般用于缓存读取, 只在第一次启动作用域时回调 + * 该函数在作用域[NetCoroutineScope.launch]之前执行 + * 函数内部所有的异常都不会被抛出, 也不会终止作用域执行 + * + * @param breakError 读取缓存成功后不再处理错误信息 + * @param breakLoading 读取缓存成功后结束加载动画 + * @param block 该作用域内的所有异常都算缓存读取失败, 不会吐司和打印任何错误 + * + * 这里指的读取缓存也可以替换为其他任务, 比如读取数据库或者其他接口数据 + */ + fun preview( + breakError: Boolean = false, + breakLoading: Boolean = true, + block: suspend CoroutineScope.() -> Unit + ): AndroidScope { + this.previewBreakError = breakError + this.previewBreakLoading = breakLoading + this.preview = block + return this + } + + override fun cancel(cause: CancellationException?) { + Net.cancelGroup(scopeGroup) + super.cancel(cause) + } +} \ No newline at end of file diff --git a/app-catalog/samples/wNet/libwNet/src/main/java/lib/wintmain/libwNet/scope/PageCoroutineScope.kt b/app-catalog/samples/wNet/libwNet/src/main/java/lib/wintmain/libwNet/scope/PageCoroutineScope.kt new file mode 100644 index 0000000..f137632 --- /dev/null +++ b/app-catalog/samples/wNet/libwNet/src/main/java/lib/wintmain/libwNet/scope/PageCoroutineScope.kt @@ -0,0 +1,78 @@ +/* + * Copyright 2023-2025 wintmain + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package lib.wintmain.libwNet.scope + +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.findViewTreeLifecycleOwner +import com.drake.brv.PageRefreshLayout +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers +import lib.wintmain.libwNet.NetConfig + +@Suppress("unused", "MemberVisibilityCanBePrivate", "NAME_SHADOWING") +class PageCoroutineScope( + val page: PageRefreshLayout, + dispatcher: CoroutineDispatcher = Dispatchers.Main +) : NetCoroutineScope(dispatcher = dispatcher) { + + val index get() = page.index + + init { + page.findViewTreeLifecycleOwner()?.lifecycle?.addObserver(object : LifecycleEventObserver { + override fun onStateChanged(source: LifecycleOwner, event: Lifecycle.Event) { + if (event == Lifecycle.Event.ON_DESTROY) cancel() + } + }) + } + + override fun start() { + previewEnabled = !page.loaded + page.trigger() + } + + override fun previewFinish(succeed: Boolean) { + super.previewFinish(succeed) + if (succeed && previewBreakLoading) { + page.showContent() + } + page.loaded = succeed + } + + override fun catch(e: Throwable) { + super.catch(e) + page.showError(e) + } + + override fun finally(e: Throwable?) { + super.finally(e) + if (e == null || e is CancellationException) { + page.showContent() + } + page.trigger() + } + + override fun handleError(e: Throwable) { + if (page.loaded || !page.stateEnabled) { + NetConfig.errorHandler.onError(e) + } else { + NetConfig.errorHandler.onStateError(e, page) + } + } +} \ No newline at end of file diff --git a/app-catalog/samples/wNet/libwNet/src/main/java/lib/wintmain/libwNet/scope/StateCoroutineScope.kt b/app-catalog/samples/wNet/libwNet/src/main/java/lib/wintmain/libwNet/scope/StateCoroutineScope.kt new file mode 100644 index 0000000..676ef6d --- /dev/null +++ b/app-catalog/samples/wNet/libwNet/src/main/java/lib/wintmain/libwNet/scope/StateCoroutineScope.kt @@ -0,0 +1,73 @@ +/* + * Copyright 2023-2025 wintmain + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package lib.wintmain.libwNet.scope + +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.findViewTreeLifecycleOwner +import com.drake.statelayout.StateLayout +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers +import lib.wintmain.libwNet.NetConfig + +/** + * 缺省页作用域 + */ +class StateCoroutineScope( + val state: StateLayout, + dispatcher: CoroutineDispatcher = Dispatchers.Main +) : NetCoroutineScope(dispatcher = dispatcher) { + + init { + state.findViewTreeLifecycleOwner()?.lifecycle?.addObserver(object : LifecycleEventObserver { + override fun onStateChanged(source: LifecycleOwner, event: Lifecycle.Event) { + if (event == Lifecycle.Event.ON_DESTROY) cancel() + } + }) + } + + override fun start() { + previewEnabled = !state.loaded + state.trigger() + } + + override fun previewFinish(succeed: Boolean) { + super.previewFinish(succeed) + if (succeed) { + state.showContent() + } + } + + override fun catch(e: Throwable) { + super.catch(e) + if (!previewSucceed) state.showError(e) + } + + override fun handleError(e: Throwable) { + NetConfig.errorHandler.onStateError(e, state) + } + + override fun finally(e: Throwable?) { + super.finally(e) + if (e == null || e is CancellationException) { + state.showContent() + } + state.trigger() + } +} diff --git a/app-catalog/samples/wNet/libwNet/src/main/java/lib/wintmain/libwNet/scope/ViewCoroutineScope.kt b/app-catalog/samples/wNet/libwNet/src/main/java/lib/wintmain/libwNet/scope/ViewCoroutineScope.kt new file mode 100644 index 0000000..b008ec5 --- /dev/null +++ b/app-catalog/samples/wNet/libwNet/src/main/java/lib/wintmain/libwNet/scope/ViewCoroutineScope.kt @@ -0,0 +1,43 @@ +/* + * Copyright 2023-2025 wintmain + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package lib.wintmain.libwNet.scope + +import android.view.View +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.findViewTreeLifecycleOwner +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers + +/** + * 视图作用域 + * 会在视图销毁时自动取消作用域 + */ +class ViewCoroutineScope( + val view: View, + dispatcher: CoroutineDispatcher = Dispatchers.Main +) : NetCoroutineScope(dispatcher = dispatcher) { + + init { + view.findViewTreeLifecycleOwner()?.lifecycle?.addObserver(object : LifecycleEventObserver { + override fun onStateChanged(source: LifecycleOwner, event: Lifecycle.Event) { + if (event == Lifecycle.Event.ON_DESTROY) cancel() + } + }) + } +} diff --git a/app-catalog/samples/wNet/libwNet/src/main/java/lib/wintmain/libwNet/tag/NetTag.kt b/app-catalog/samples/wNet/libwNet/src/main/java/lib/wintmain/libwNet/tag/NetTag.kt new file mode 100644 index 0000000..3d4346f --- /dev/null +++ b/app-catalog/samples/wNet/libwNet/src/main/java/lib/wintmain/libwNet/tag/NetTag.kt @@ -0,0 +1,63 @@ +/* + * Copyright 2023-2025 wintmain + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package lib.wintmain.libwNet.tag + +import lib.wintmain.libwNet.interfaces.ProgressListener +import java.io.File +import java.util.concurrent.ConcurrentLinkedQueue +import kotlin.reflect.KType + +sealed class NetTag { + class Extras : HashMap() + class UploadListeners : ConcurrentLinkedQueue() + class DownloadListeners : ConcurrentLinkedQueue() + + @JvmInline + value class RequestId(val value: Any) + + @JvmInline + value class RequestGroup(val value: Any) + + @JvmInline + value class RequestKType(val value: KType) + + @JvmInline + value class DownloadFileMD5Verify(val value: Boolean = true) + + @JvmInline + value class DownloadFileNameDecode(val value: Boolean = true) + + @JvmInline + value class DownloadTempFile(val value: Boolean = true) + + @JvmInline + value class DownloadFileConflictRename(val value: Boolean = true) + + @JvmInline + value class DownloadFileName(val value: String) + + @JvmInline + value class CacheKey(val value: String) + + @JvmInline + value class CacheValidTime(val value: Long) + + @JvmInline + value class DownloadFileDir(val value: String) { + constructor(fileDir: File) : this(fileDir.absolutePath) + } +} diff --git a/app-catalog/samples/wNet/libwNet/src/main/java/lib/wintmain/libwNet/time/Interval.kt b/app-catalog/samples/wNet/libwNet/src/main/java/lib/wintmain/libwNet/time/Interval.kt new file mode 100644 index 0000000..08b0220 --- /dev/null +++ b/app-catalog/samples/wNet/libwNet/src/main/java/lib/wintmain/libwNet/time/Interval.kt @@ -0,0 +1,293 @@ +/* + * Copyright 2023-2025 wintmain + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +@file:Suppress("MemberVisibilityCanBePrivate") + +package lib.wintmain.libwNet.time + +import android.os.Handler +import android.os.Looper +import androidx.fragment.app.Fragment +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver +import androidx.lifecycle.LifecycleOwner +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ObsoleteCoroutinesApi +import kotlinx.coroutines.cancel +import kotlinx.coroutines.channels.ReceiveChannel +import kotlinx.coroutines.channels.TickerMode +import kotlinx.coroutines.channels.ticker +import kotlinx.coroutines.launch +import lib.wintmain.libwNet.time.IntervalStatus.STATE_ACTIVE +import lib.wintmain.libwNet.time.IntervalStatus.STATE_IDLE +import lib.wintmain.libwNet.time.IntervalStatus.STATE_PAUSE +import java.io.Closeable +import java.io.Serializable +import java.util.concurrent.TimeUnit + +/** + * 创建一个轮询器 + * + * 操作 + * 1. 开启 [start] 只有在闲置状态下才可以开始 + * 2. 停止 [stop] + * 3. 暂停 [pause] + * 4. 继续 [resume] + * 5. 重置 [reset] 重置不会导致轮询器停止 + * 6. 开关 [switch] 开启|暂停切换 + * 7. 生命周期 [life] + * + * 函数回调: 允许多次订阅同一个轮询器 + * 1. 每个事件 [subscribe] + * 2. 停止或者结束 [finish] + * + * @param end 结束值, -1 表示永远不结束 + * @param period 计时器间隔 + * @param unit 计时器单位 + * @param initialDelay 第一次事件的间隔时间, 默认0 + * @param start 开始值, 当[start]]比[end]值大, 且end不等于-1时, 即为倒计时, 反之正计时 + */ +open class Interval @JvmOverloads constructor( + var end: Long, + private val period: Long, + private val unit: TimeUnit, + private val start: Long = 0, + private val initialDelay: Long = 0 +) : Serializable, Closeable { + + /** + * 创建一个不会自动结束的轮询器/计时器 + * + * @param period 间隔时间 + * @param unit 时间单位 + * @param initialDelay 初次间隔时间, 默认为0即立即开始 + */ + @JvmOverloads + constructor( + period: Long, + unit: TimeUnit, + initialDelay: Long = 0 + ) : this(-1, period, unit, 0, initialDelay) + + private val subscribeList: MutableList Unit> = mutableListOf() + private val finishList: MutableList Unit> = mutableListOf() + private var countTime = 0L + private var delay = 0L + private var scope: CoroutineScope? = null + private lateinit var ticker: ReceiveChannel + + /** 轮询器的计数 */ + var count = start + + /** 轮询器当前状态 */ + var state = STATE_IDLE + private set + + // + + /** + * 订阅轮询器 + * 每次轮询器计时都会调用该回调函数 + * 轮询器完成时会同时触发回调[block]和[finish] + */ + fun subscribe(block: Interval.(Long) -> Unit) = apply { + subscribeList.add(block) + } + + /** + * 轮询器完成时回调该函数 + * @see stop 执行该函数也会回调finish + * @see cancel 使用该函数取消轮询器不会回调finish + */ + fun finish(block: Interval.(Long) -> Unit) = apply { + finishList.add(block) + } + + // + + // + + /** + * 开始 + * 如果当前为暂停状态将重新开始轮询 + */ + fun start() = apply { + if (state == STATE_ACTIVE) return this + state = STATE_ACTIVE + count = start + launch() + } + + /** + * 停止 + */ + fun stop() { + if (state == STATE_IDLE) return + scope?.cancel() + state = STATE_IDLE + finishList.forEach { + it.invoke(this, count) + } + } + + /** + * 取消 + * 区别于[stop]并不会执行[finish] + */ + fun cancel() { + if (state == STATE_IDLE) return + scope?.cancel() + state = STATE_IDLE + } + + /** 等于[cancel] */ + override fun close() = cancel() + + /** + * 切换轮询器开始或结束 + * 假设轮询器为暂停[IntervalStatus.STATE_PAUSE]状态将继续运行[resume] + */ + fun switch() { + when (state) { + STATE_ACTIVE -> stop() + STATE_IDLE -> start() + STATE_PAUSE -> resume() + } + } + + /** + * 暂停 + */ + fun pause() { + if (state != STATE_ACTIVE) return + scope?.cancel() + state = STATE_PAUSE + delay = System.currentTimeMillis() - countTime + } + + /** + * 继续 + * 要求轮询器为暂停状态[IntervalStatus.STATE_PAUSE], 否则无效 + */ + fun resume() { + if (state != STATE_PAUSE) return + state = STATE_ACTIVE + launch(delay) + } + + /** + * 重置 + */ + fun reset() { + count = start + delay = unit.toMillis(initialDelay) + scope?.cancel() + if (state == STATE_ACTIVE) launch() + } + + // + + // + /** + * 自动在指定生命周期后取消[cancel]轮询器 + * @param lifecycleOwner 生命周期持有者, 一般为Activity/Fragment + * @param lifeEvent 销毁生命周期, 默认为 [Lifecycle.Event.ON_DESTROY] 时停止时停止轮询器 + */ + @JvmOverloads + fun life( + lifecycleOwner: LifecycleOwner, + lifeEvent: Lifecycle.Event = Lifecycle.Event.ON_DESTROY + ) = apply { + runMain { + lifecycleOwner.lifecycle.addObserver(object : LifecycleEventObserver { + override fun onStateChanged(source: LifecycleOwner, event: Lifecycle.Event) { + if (lifeEvent == event) cancel() + } + }) + } + } + + /** + * 自动在指定生命周期后取消[cancel]轮询器 + * @param lifeEvent 销毁生命周期, 默认为 [Lifecycle.Event.ON_DESTROY] 时停止时停止轮询器 + */ + @JvmOverloads + fun life( + fragment: Fragment, + lifeEvent: Lifecycle.Event = Lifecycle.Event.ON_DESTROY + ): Interval { + fragment.viewLifecycleOwnerLiveData.observe(fragment) { + it?.lifecycle?.addObserver(object : LifecycleEventObserver { + override fun onStateChanged(source: LifecycleOwner, event: Lifecycle.Event) { + if (lifeEvent == event) cancel() + } + }) + } + return this + } + + /** + * 当界面不可见时暂停[pause], 当界面可见时继续[resume], 当界面销毁时[cancel]轮询器 + */ + fun onlyResumed(lifecycleOwner: LifecycleOwner) = apply { + runMain { + lifecycleOwner.lifecycle.addObserver(object : LifecycleEventObserver { + override fun onStateChanged(source: LifecycleOwner, event: Lifecycle.Event) { + when (event) { + Lifecycle.Event.ON_RESUME -> resume() + Lifecycle.Event.ON_PAUSE -> pause() + Lifecycle.Event.ON_DESTROY -> cancel() + else -> {} + } + } + }) + } + } + // + + /** 启动轮询器 */ + @OptIn(ObsoleteCoroutinesApi::class) + private fun launch(delay: Long = unit.toMillis(initialDelay)) { + scope = CoroutineScope(Dispatchers.Main) + scope?.launch { + ticker = ticker(unit.toMillis(period), delay, mode = TickerMode.FIXED_DELAY) + for (unit in ticker) { + subscribeList.forEach { + it.invoke(this@Interval, count) + } + if (end != -1L && count == end) { + scope?.cancel() + state = STATE_IDLE + finishList.forEach { + it.invoke(this@Interval, count) + } + } + if (end != -1L && start > end) count-- else count++ + countTime = System.currentTimeMillis() + } + } + } + + private fun runMain(block: () -> Unit) { + if (Looper.myLooper() == Looper.getMainLooper()) { + block() + } else { + Handler(Looper.getMainLooper()).post { block() } + } + } +} + diff --git a/app-catalog/samples/wNet/libwNet/src/main/java/lib/wintmain/libwNet/time/IntervalStatus.kt b/app-catalog/samples/wNet/libwNet/src/main/java/lib/wintmain/libwNet/time/IntervalStatus.kt new file mode 100644 index 0000000..f4d36d0 --- /dev/null +++ b/app-catalog/samples/wNet/libwNet/src/main/java/lib/wintmain/libwNet/time/IntervalStatus.kt @@ -0,0 +1,24 @@ +/* + * Copyright 2023-2025 wintmain + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package lib.wintmain.libwNet.time + +/** + * 计时器的状态 + */ +enum class IntervalStatus { + STATE_ACTIVE, STATE_IDLE, STATE_PAUSE +} \ No newline at end of file diff --git a/app-catalog/samples/wNet/libwNet/src/main/java/lib/wintmain/libwNet/transform/DeferredTransform.kt b/app-catalog/samples/wNet/libwNet/src/main/java/lib/wintmain/libwNet/transform/DeferredTransform.kt new file mode 100644 index 0000000..1a42799 --- /dev/null +++ b/app-catalog/samples/wNet/libwNet/src/main/java/lib/wintmain/libwNet/transform/DeferredTransform.kt @@ -0,0 +1,29 @@ +/* + * Copyright 2023-2025 wintmain + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package lib.wintmain.libwNet.transform + +import kotlinx.coroutines.Deferred + +/** + * 可以将[Deferred]返回结果进行转换 + * [block]在[Deferred]执行成功返回结果时执行 + */ +fun Deferred.transform(block: (T) -> R): DeferredTransform { + return DeferredTransform(this, block) +} + +data class DeferredTransform(val deferred: Deferred, val block: (T) -> R) \ No newline at end of file diff --git a/app-catalog/samples/wNet/libwNet/src/main/java/lib/wintmain/libwNet/utils/Fastest.kt b/app-catalog/samples/wNet/libwNet/src/main/java/lib/wintmain/libwNet/utils/Fastest.kt new file mode 100644 index 0000000..893a135 --- /dev/null +++ b/app-catalog/samples/wNet/libwNet/src/main/java/lib/wintmain/libwNet/utils/Fastest.kt @@ -0,0 +1,112 @@ +/* + * Copyright 2023-2025 wintmain + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package lib.wintmain.libwNet.utils + +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.Deferred +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import lib.wintmain.libwNet.Net +import lib.wintmain.libwNet.transform.DeferredTransform +import java.util.concurrent.CancellationException + +/** + * 该函数将选择[listDeferred]中的Deferred执行[Deferred.await], 然后将返回最快的结果 + * 执行过程中的异常将被忽略, 如果全部抛出异常则将抛出最后一个Deferred的异常 + * + * @param group 指定该值将在成功返回结果后取消掉对应uid的网络请求 + * @param listDeferred 一系列并发任务 + */ +@Suppress("SuspendFunctionOnCoroutineScope") +suspend fun fastest( + listDeferred: List>, + group: Any? = null +): T = coroutineScope { + val deferred = CompletableDeferred() + if (listDeferred.isNullOrEmpty()) { + deferred.completeExceptionally(IllegalArgumentException("Fastest is null or empty")) + } + val mutex = Mutex() + listDeferred.forEach { + launch(Dispatchers.IO) { + try { + val result = it.await() + mutex.withLock { + Net.cancelGroup(group) + deferred.complete(result) + } + } catch (e: Exception) { + it.cancel() + val allFail = listDeferred.all { it.isCancelled } + if (allFail) deferred.completeExceptionally(e) else { + if (e !is CancellationException) { + Net.debug(e) + } + } + } + } + } + deferred.await() +} + +/** + * 该函数将选择[listDeferred]中的Deferred执行[Deferred.await], 然后将返回最快的结果 + * 执行过程中的异常将被忽略, 如果全部抛出异常则将抛出最后一个Deferred的异常 + * + * @see DeferredTransform 允许监听[Deferred]返回数据回调 + * + * @param group 指定该值将在成功返回结果后取消掉对应uid的网络请求 + * @param listDeferred 一系列并发任务 + */ +@JvmName("fastestTransform") +@Suppress("SuspendFunctionOnCoroutineScope") +suspend fun fastest( + listDeferred: List>?, + group: Any? = null +): R = coroutineScope { + val deferred = CompletableDeferred() + if (listDeferred.isNullOrEmpty()) { + deferred.completeExceptionally(IllegalArgumentException("Fastest is null or empty")) + } + val mutex = Mutex() + listDeferred?.forEach { + launch(Dispatchers.IO) { + try { + val result = it.deferred.await() + mutex.withLock { + Net.cancelGroup(group) + if (!deferred.isCompleted) { + val transformResult = it.block(result) + deferred.complete(transformResult) + } + } + } catch (e: Exception) { + it.deferred.cancel() + val allFail = listDeferred.all { it.deferred.isCancelled } + if (allFail) deferred.completeExceptionally(e) else { + if (e !is CancellationException) { + Net.debug(e) + } + } + } + } + } + deferred.await() +} \ No newline at end of file diff --git a/app-catalog/samples/wNet/libwNet/src/main/java/lib/wintmain/libwNet/utils/FileUtils.kt b/app-catalog/samples/wNet/libwNet/src/main/java/lib/wintmain/libwNet/utils/FileUtils.kt new file mode 100644 index 0000000..19bd9f7 --- /dev/null +++ b/app-catalog/samples/wNet/libwNet/src/main/java/lib/wintmain/libwNet/utils/FileUtils.kt @@ -0,0 +1,85 @@ +/* + * Copyright 2023-2025 wintmain + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package lib.wintmain.libwNet.utils + +import android.webkit.MimeTypeMap +import lib.wintmain.libwNet.Net +import okhttp3.MediaType +import okhttp3.MediaType.Companion.toMediaTypeOrNull +import okhttp3.RequestBody +import okio.BufferedSink +import okio.ByteString.Companion.toByteString +import okio.source +import java.io.File +import java.io.FileInputStream +import java.io.IOException +import java.security.DigestInputStream +import java.security.MessageDigest + +/** + * 返回文件的MD5值 + * @param base64 是否将md5值进行base64编码, 否则将返回hex编码 + */ +fun File.md5(base64: Boolean = false): String? { + try { + val fileInputStream = FileInputStream(this) + val digestInputStream = DigestInputStream(fileInputStream, MessageDigest.getInstance("MD5")) + val buffer = ByteArray(1024 * 256) + digestInputStream.use { + while (true) if (digestInputStream.read(buffer) <= 0) break + } + val md5 = digestInputStream.messageDigest.digest() + return if (base64) { + md5.toByteString().base64() + } else { + md5.toByteString().hex() + } + } catch (e: IOException) { + Net.debug(e) + } + return null +} + +/** + * 返回文件的MediaType值, 如果不存在返回null + */ +fun File.mediaType(): MediaType? { + val fileExtension = MimeTypeMap.getFileExtensionFromUrl(absolutePath) + return MimeTypeMap.getSingleton().getMimeTypeFromExtension(fileExtension)?.toMediaTypeOrNull() +} + +/** + * 创建File的RequestBody + * @param contentType 如果为null则通过判断扩展名来生成MediaType + */ +fun File.toRequestBody(contentType: MediaType? = null): RequestBody { + val fileMediaType = contentType ?: mediaType() + return object : RequestBody() { + + override fun contentType(): MediaType? { + return fileMediaType + } + + override fun contentLength() = length() + + override fun writeTo(sink: BufferedSink) { + source().use { source -> + sink.writeAll(source) + } + } + } +} \ No newline at end of file diff --git a/app-catalog/samples/wNet/libwNet/src/main/java/lib/wintmain/libwNet/utils/FlowUtils.kt b/app-catalog/samples/wNet/libwNet/src/main/java/lib/wintmain/libwNet/utils/FlowUtils.kt new file mode 100644 index 0000000..9eaf5b2 --- /dev/null +++ b/app-catalog/samples/wNet/libwNet/src/main/java/lib/wintmain/libwNet/utils/FlowUtils.kt @@ -0,0 +1,88 @@ +/* + * Copyright 2023-2025 wintmain + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package lib.wintmain.libwNet.utils + +import android.text.Editable +import android.text.TextWatcher +import android.widget.EditText +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleOwner +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.InternalCoroutinesApi +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.FlowCollector +import kotlinx.coroutines.flow.callbackFlow +import kotlinx.coroutines.flow.debounce +import lib.wintmain.libwNet.scope.AndroidScope + +/** + * Flow直接创建作用域 + * @param owner 跟随的生命周期组件 + * @param event 销毁时机 + * @param dispatcher 指定调度器 + */ +@OptIn(InternalCoroutinesApi::class) +inline fun Flow.launchIn( + owner: LifecycleOwner? = null, + event: Lifecycle.Event = Lifecycle.Event.ON_DESTROY, + dispatcher: CoroutineDispatcher = Dispatchers.Main, + crossinline action: suspend CoroutineScope.(value: T) -> Unit +): AndroidScope = AndroidScope(owner, event, dispatcher).launch { + this@launchIn.collect(object : FlowCollector { + override suspend fun emit(value: T) = action(this@launch, value) + }) +} + +/** + * 为EditText的输入框文本变化启用节流阀, 即超过指定时间后(默认800毫秒)的输入框文本变化事件[TextWatcher.onTextChanged]会被下游收集到 + * + * @param timeoutMillis 节流阀超时时间/单位毫秒, 默认值为800 + */ +@OptIn(ExperimentalCoroutinesApi::class, FlowPreview::class) +fun EditText.debounce(timeoutMillis: Long = 800) = callbackFlow { + + val textWatcher = object : TextWatcher { + + override fun beforeTextChanged( + s: CharSequence, + start: Int, + count: Int, + after: Int + ) { + } + + override fun onTextChanged( + s: CharSequence, + start: Int, + before: Int, + count: Int + ) { + } + + override fun afterTextChanged(s: Editable) { + trySend(s.toString()) + } + } + addTextChangedListener(textWatcher) + awaitClose { this@debounce.removeTextChangedListener(textWatcher) } +}.debounce(timeoutMillis) + diff --git a/app-catalog/samples/wNet/libwNet/src/main/java/lib/wintmain/libwNet/utils/Https.kt b/app-catalog/samples/wNet/libwNet/src/main/java/lib/wintmain/libwNet/utils/Https.kt new file mode 100644 index 0000000..cfc8f93 --- /dev/null +++ b/app-catalog/samples/wNet/libwNet/src/main/java/lib/wintmain/libwNet/utils/Https.kt @@ -0,0 +1,107 @@ +/* + * Copyright 2023-2025 wintmain + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package lib.wintmain.libwNet.utils + +import lib.wintmain.libwNet.Net +import java.io.IOException +import java.io.InputStream +import java.security.KeyStore +import java.security.cert.CertificateException +import java.security.cert.CertificateFactory +import java.security.cert.X509Certificate +import javax.net.ssl.* + +object Https { + + /** + * 此类是用于主机名验证的基接口。 在握手期间,如果 URL 的主机名和服务器的标识主机名不匹配, + * 则验证机制可以回调此接口的实现程序来确定是否应该允许此连接。策略可以是基于证书的或依赖于其他验证方案。 + * 当验证 URL 主机名使用的默认规则失败时使用这些回调。如果主机名是可接受的,则返回 true + */ + var UnSafeHostnameVerifier = HostnameVerifier { _, _ -> true } + + /** + * 为了解决客户端不信任服务器数字证书的问题,网络上大部分的解决方案都是让客户端不对证书做任何检查, + * 这是一种有很大安全漏洞的办法 + */ + var UnSafeTrustManager: X509TrustManager = object : X509TrustManager { + @Throws(CertificateException::class) + override fun checkClientTrusted(chain: Array, authType: String) { + } + + @Throws(CertificateException::class) + override fun checkServerTrusted(chain: Array, authType: String) { + } + + override fun getAcceptedIssuers(): Array { + return arrayOf() + } + } +} + +internal fun prepareKeyManager(bksFile: InputStream?, password: String?): Array? { + try { + if (bksFile == null || password == null) return null + val clientKeyStore = KeyStore.getInstance("BKS") + clientKeyStore.load(bksFile, password.toCharArray()) + val kmf = KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm()) + kmf.init(clientKeyStore, password.toCharArray()) + return kmf.keyManagers + } catch (e: Exception) { + Net.debug(e) + } + return null +} + +internal fun prepareTrustManager(vararg certificates: InputStream?): Array? { + if (certificates.isEmpty()) return null + try { + val certificateFactory = CertificateFactory.getInstance("X.509") + // 创建一个默认类型的KeyStore,存储我们信任的证书 + val keyStore = KeyStore.getInstance(KeyStore.getDefaultType()) + keyStore.load(null) + for ((index, certStream) in certificates.withIndex()) { + val certificateAlias = (index).toString() + // 证书工厂根据证书文件的流生成证书 cert + val cert = certificateFactory.generateCertificate(certStream) + // 将cert作为可信证书放入到keyStore中 + keyStore.setCertificateEntry(certificateAlias, cert) + try { + certStream?.close() + } catch (e: IOException) { + Net.debug(e) + } + } + // 我们创建一个默认类型的TrustManagerFactory + val tmf = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm()) + // 用我们之前的keyStore实例初始化TrustManagerFactory,这样tmf就会信任keyStore中的证书 + tmf.init(keyStore) + // 通过tmf获取TrustManager数组,TrustManager也会信任keyStore中的证书 + return tmf.trustManagers + } catch (e: Exception) { + Net.debug(e) + } + return null +} + +internal fun chooseTrustManager(trustManagers: Array): X509TrustManager? { + for (trustManager in trustManagers) { + if (trustManager is X509TrustManager) { + return trustManager + } + } + return null +} \ No newline at end of file diff --git a/app-catalog/samples/wNet/libwNet/src/main/java/lib/wintmain/libwNet/utils/NetUtils.kt b/app-catalog/samples/wNet/libwNet/src/main/java/lib/wintmain/libwNet/utils/NetUtils.kt new file mode 100644 index 0000000..2af7259 --- /dev/null +++ b/app-catalog/samples/wNet/libwNet/src/main/java/lib/wintmain/libwNet/utils/NetUtils.kt @@ -0,0 +1,42 @@ +/* + * Copyright 2023-2025 wintmain + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package lib.wintmain.libwNet.utils + +import android.content.Context +import android.net.ConnectivityManager +import android.net.NetworkCapabilities +import android.os.Build + +/** + * 是否处于联网中 + */ +fun Context.isNetworking(): Boolean { + val connectivityManager = + applicationContext.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + val networkCapabilities = connectivityManager.activeNetwork ?: return false + val actNw = connectivityManager.getNetworkCapabilities(networkCapabilities) ?: return false + when { + actNw.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) -> true + actNw.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) -> true + actNw.hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET) -> true + else -> false + } + } else { + connectivityManager.activeNetworkInfo?.isConnected == true + } +} \ No newline at end of file diff --git a/app-catalog/samples/wNet/libwNet/src/main/java/lib/wintmain/libwNet/utils/Scope.kt b/app-catalog/samples/wNet/libwNet/src/main/java/lib/wintmain/libwNet/utils/Scope.kt new file mode 100644 index 0000000..f742305 --- /dev/null +++ b/app-catalog/samples/wNet/libwNet/src/main/java/lib/wintmain/libwNet/utils/Scope.kt @@ -0,0 +1,240 @@ +/* + * Copyright 2023-2025 wintmain + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package lib.wintmain.libwNet.utils + +import android.app.Dialog +import android.view.View +import androidx.fragment.app.Fragment +import androidx.fragment.app.FragmentActivity +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver +import androidx.lifecycle.LifecycleOwner +import com.drake.brv.PageRefreshLayout +import com.drake.statelayout.StateLayout +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import lib.wintmain.libwNet.scope.AndroidScope +import lib.wintmain.libwNet.scope.DialogCoroutineScope +import lib.wintmain.libwNet.scope.NetCoroutineScope +import lib.wintmain.libwNet.scope.PageCoroutineScope +import lib.wintmain.libwNet.scope.StateCoroutineScope +import lib.wintmain.libwNet.scope.ViewCoroutineScope + +/** + * 作用域内部全在主线程 + * 作用域全部属于异步 + * 作用域内部异常全部被捕获, 不会引起应用崩溃 + */ + +// +/** + * 异步作用域 + * + * 该作用域生命周期跟随整个应用, 注意内存泄漏 + * @param dispatcher 调度器, 默认运行在[Dispatchers.Main]即主线程下 + */ +fun scope( + dispatcher: CoroutineDispatcher = Dispatchers.Main, + block: suspend CoroutineScope.() -> Unit +): AndroidScope { + return AndroidScope(dispatcher = dispatcher).launch(block) +} + +/** + * 异步作用域 + * + * 该作用域生命周期跟随[LifecycleOwner] + * @param lifeEvent 生命周期事件, 默认为[Lifecycle.Event.ON_DESTROY]下取消协程作用域 + * @param dispatcher 调度器, 默认运行在[Dispatchers.Main]即主线程下 + */ +fun LifecycleOwner.scopeLife( + lifeEvent: Lifecycle.Event = Lifecycle.Event.ON_DESTROY, + dispatcher: CoroutineDispatcher = Dispatchers.Main, + block: suspend CoroutineScope.() -> Unit +) = AndroidScope(this, lifeEvent, dispatcher).launch(block) + +/** + * 异步作用域 + * + * 该作用域生命周期跟随[Fragment] + * @param lifeEvent 生命周期事件, 默认为[Lifecycle.Event.ON_DESTROY]下取消协程作用域 + * @param dispatcher 调度器, 默认运行在[Dispatchers.Main]即主线程下 + */ +fun Fragment.scopeLife( + lifeEvent: Lifecycle.Event = Lifecycle.Event.ON_DESTROY, + dispatcher: CoroutineDispatcher = Dispatchers.Main, + block: suspend CoroutineScope.() -> Unit +): AndroidScope { + val coroutineScope = AndroidScope(dispatcher = dispatcher).launch(block) + viewLifecycleOwnerLiveData.observe(this) { + it?.lifecycle?.addObserver(object : LifecycleEventObserver { + override fun onStateChanged(source: LifecycleOwner, event: Lifecycle.Event) { + if (lifeEvent == event) coroutineScope.cancel() + } + }) + } + return coroutineScope +} +// + +// + +/** + * 作用域开始时自动显示加载对话框, 结束时自动关闭加载对话框 + * 可以设置全局对话框 [lib.wintmain.libwNet.NetConfig.dialogFactory] + * 对话框被取消或者界面关闭作用域被取消 + * + * @param dialog 仅该作用域使用的对话框 + * @param cancelable 对话框是否可取消 + * @param dispatcher 调度器, 默认运行在[Dispatchers.Main]即主线程下 + */ +fun FragmentActivity.scopeDialog( + dialog: Dialog? = null, + cancelable: Boolean? = null, + dispatcher: CoroutineDispatcher = Dispatchers.Main, + block: suspend CoroutineScope.() -> Unit +) = DialogCoroutineScope(this, dialog, cancelable, dispatcher).launch(block) + +/** + * 作用域开始时自动显示加载对话框, 结束时自动关闭加载对话框 + * 可以设置全局对话框 [lib.wintmain.libwNet.NetConfig.dialogFactory] + * 对话框被取消或者界面关闭作用域被取消 + * @param dialog 仅该作用域使用的对话框 + * @param cancelable 对话框是否可取消 + * @param dispatcher 调度器, 默认运行在[Dispatchers.Main]即主线程下 + */ +fun Fragment.scopeDialog( + dialog: Dialog? = null, + cancelable: Boolean? = null, + dispatcher: CoroutineDispatcher = Dispatchers.Main, + block: suspend CoroutineScope.() -> Unit +) = DialogCoroutineScope(requireActivity(), dialog, cancelable, dispatcher).launch(block) + +// + +// +/** + * 自动处理缺省页的异步作用域 + * 作用域开始执行时显示加载中缺省页 + * 作用域正常结束时显示成功缺省页 + * 作用域抛出异常时显示错误缺省页 + * 并且自动吐司错误信息, 可配置 [lib.wintmain.libwNet.interfaces.NetErrorHandler.onStateError] + * 自动打印异常日志 + * 布局被销毁或者界面关闭作用域被取消 + * @receiver 当前视图会被缺省页包裹 + * @param dispatcher 调度器, 默认运行在[Dispatchers.Main]即主线程下 + */ +fun StateLayout.scope( + dispatcher: CoroutineDispatcher = Dispatchers.Main, + block: suspend CoroutineScope.() -> Unit +): NetCoroutineScope { + val scope = StateCoroutineScope(this, dispatcher) + scope.launch(block) + return scope +} + +/** + * PageRefreshLayout的异步作用域 + * + * 1. 下拉刷新自动结束 + * 2. 上拉加载自动结束 + * 3. 捕获异常 + * 4. 打印异常日志 + * 5. 吐司部分异常[lib.wintmain.libwNet.interfaces.NetErrorHandler.onStateError] + * 6. 判断添加还是覆盖数据 + * 7. 自动显示缺省页 + * + * 布局被销毁或者界面关闭作用域被取消 + * @param dispatcher 调度器, 默认运行在[Dispatchers.Main]即主线程下 + */ +fun PageRefreshLayout.scope( + dispatcher: CoroutineDispatcher = Dispatchers.Main, + block: suspend CoroutineScope.() -> Unit +): PageCoroutineScope { + val scope = PageCoroutineScope(this, dispatcher) + scope.launch(block) + return scope +} + +/** + * 视图作用域 + * 会在视图销毁时自动取消作用域 + * @param dispatcher 调度器, 默认运行在[Dispatchers.Main]即主线程下 + */ +fun View.scopeNetLife( + dispatcher: CoroutineDispatcher = Dispatchers.Main, + block: suspend CoroutineScope.() -> Unit +): ViewCoroutineScope { + val scope = ViewCoroutineScope(this, dispatcher) + scope.launch(block) + return scope +} +// + +// + +/** + * 该函数比[scope]多了以下功能 + * - 在作用域内抛出异常时会被回调的[lib.wintmain.libwNet.interfaces.NetErrorHandler.onError]函数中 + * - 自动显示错误信息吐司, 可以通过指定[lib.wintmain.libwNet.interfaces.NetErrorHandler.onError]来取消或者增加自己的处理 + * + * 该作用域生命周期跟随整个应用, 注意内存泄漏 + * @param dispatcher 调度器, 默认运行在[Dispatchers.Main]即主线程下 + */ +fun scopeNet( + dispatcher: CoroutineDispatcher = Dispatchers.Main, + block: suspend CoroutineScope.() -> Unit +) = NetCoroutineScope(dispatcher = dispatcher).launch(block) + +/** + * 该函数比scopeNet多了自动取消作用域功能 + * + * 该作用域生命周期跟随LifecycleOwner. 比如传入Activity会默认在[FragmentActivity.onDestroy]时取消网络请求. + * @receiver 可传入FragmentActivity/AppCompatActivity, 或者其他的实现了LifecycleOwner的类 + * @param lifeEvent 指定LifecycleOwner处于生命周期下取消网络请求/作用域 + * @param dispatcher 调度器, 默认运行在[Dispatchers.Main]即主线程下 + */ +fun LifecycleOwner.scopeNetLife( + lifeEvent: Lifecycle.Event = Lifecycle.Event.ON_DESTROY, + dispatcher: CoroutineDispatcher = Dispatchers.Main, + block: suspend CoroutineScope.() -> Unit +) = NetCoroutineScope(this, lifeEvent, dispatcher).launch(block) + +/** + * 和[scopeNetLife]功能相同, 只是接受者为Fragment + * + * @param lifeEvent 生命周期事件, 默认为[Lifecycle.Event.ON_DESTROY]下取消协程作用域 + * @param dispatcher 调度器, 默认运行在[Dispatchers.Main]即主线程下 + */ +fun Fragment.scopeNetLife( + lifeEvent: Lifecycle.Event = Lifecycle.Event.ON_DESTROY, + dispatcher: CoroutineDispatcher = Dispatchers.Main, + block: suspend CoroutineScope.() -> Unit +): NetCoroutineScope { + val coroutineScope = NetCoroutineScope(dispatcher = dispatcher).launch(block) + viewLifecycleOwnerLiveData.observe(this) { + it?.lifecycle?.addObserver(object : LifecycleEventObserver { + override fun onStateChanged(source: LifecycleOwner, event: Lifecycle.Event) { + if (lifeEvent == event) coroutineScope.cancel() + } + }) + } + return coroutineScope +} + +// \ No newline at end of file diff --git a/app-catalog/samples/wNet/libwNet/src/main/java/lib/wintmain/libwNet/utils/Suspend.kt b/app-catalog/samples/wNet/libwNet/src/main/java/lib/wintmain/libwNet/utils/Suspend.kt new file mode 100644 index 0000000..7ffa729 --- /dev/null +++ b/app-catalog/samples/wNet/libwNet/src/main/java/lib/wintmain/libwNet/utils/Suspend.kt @@ -0,0 +1,62 @@ +/* + * Copyright 2023-2025 wintmain + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package lib.wintmain.libwNet.utils + +import android.os.Handler +import android.os.Looper +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +// + +/** + * 切换到主线程调度器 + */ +suspend fun withMain(block: suspend CoroutineScope.() -> T) = + withContext(Dispatchers.Main, block) + +/** + * 切换到IO程调度器 + */ +suspend fun withIO(block: suspend CoroutineScope.() -> T) = withContext(Dispatchers.IO, block) + +/** + * 切换到默认调度器 + */ +suspend fun withDefault(block: suspend CoroutineScope.() -> T) = + withContext(Dispatchers.Default, block) + +/** + * 切换到没有限制的调度器 + */ +suspend fun withUnconfined(block: suspend CoroutineScope.() -> T) = + withContext(Dispatchers.Unconfined, block) + +// + +/** + * 在主线程运行 + */ +private val mainThreadHandler by lazy { Handler(Looper.getMainLooper()) } +fun runMain(block: () -> Unit) { + if (Looper.myLooper() == Looper.getMainLooper()) { + block() + } else { + mainThreadHandler.post { block() } + } +} \ No newline at end of file diff --git a/app-catalog/samples/wNet/libwNet/src/main/java/lib/wintmain/libwNet/utils/TipUtils.kt b/app-catalog/samples/wNet/libwNet/src/main/java/lib/wintmain/libwNet/utils/TipUtils.kt new file mode 100644 index 0000000..838957a --- /dev/null +++ b/app-catalog/samples/wNet/libwNet/src/main/java/lib/wintmain/libwNet/utils/TipUtils.kt @@ -0,0 +1,41 @@ +/* + * Copyright 2023-2025 wintmain + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package lib.wintmain.libwNet.utils + +import android.annotation.SuppressLint +import android.widget.Toast +import lib.wintmain.libwNet.NetConfig + +object TipUtils { + + private var toast: Toast? = null + + /** + * 重复显示不会覆盖, 可以在子线程显示 + * 本方法会导致报内存泄露, 这是因为为了避免吐司反复显示导致重叠会长期持有Toast引用来保持单例导致, 可以无视或者自己实现吐司 + */ + @SuppressLint("ShowToast") + @JvmStatic + fun toast(message: String?) { + message ?: return + runMain { + toast?.cancel() + toast = Toast.makeText(NetConfig.app, message, Toast.LENGTH_SHORT) + toast?.show() + } + } +} \ No newline at end of file diff --git a/app-catalog/samples/wNet/libwNet/src/main/java/lib/wintmain/libwNet/utils/Uri.kt b/app-catalog/samples/wNet/libwNet/src/main/java/lib/wintmain/libwNet/utils/Uri.kt new file mode 100644 index 0000000..2307044 --- /dev/null +++ b/app-catalog/samples/wNet/libwNet/src/main/java/lib/wintmain/libwNet/utils/Uri.kt @@ -0,0 +1,62 @@ +/* + * Copyright 2023-2025 wintmain + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package lib.wintmain.libwNet.utils + +import android.net.Uri +import android.webkit.MimeTypeMap +import androidx.documentfile.provider.DocumentFile +import lib.wintmain.libwNet.NetConfig +import okhttp3.MediaType +import okhttp3.MediaType.Companion.toMediaTypeOrNull +import okhttp3.RequestBody +import okio.BufferedSink +import okio.source +import java.io.FileNotFoundException + +fun Uri.fileName(): String? { + return DocumentFile.fromSingleUri(NetConfig.app, this)?.name +} + +fun Uri.mediaType(): MediaType? { + val fileName = DocumentFile.fromSingleUri(NetConfig.app, this)?.name + val fileExtension = MimeTypeMap.getFileExtensionFromUrl(fileName) + return MimeTypeMap.getSingleton().getMimeTypeFromExtension(fileExtension)?.toMediaTypeOrNull() +} + +/** + * 当Uri指向的文件不存在时将抛出异常[FileNotFoundException] + */ +@Throws(FileNotFoundException::class) +fun Uri.toRequestBody(): RequestBody { + val document = DocumentFile.fromSingleUri(NetConfig.app, this) + val contentResolver = NetConfig.app.contentResolver + val contentLength = document?.length() ?: -1L + val contentType = mediaType() + return object : RequestBody() { + override fun contentType(): MediaType? { + return contentType + } + + override fun contentLength() = contentLength + + override fun writeTo(sink: BufferedSink) { + contentResolver.openInputStream(this@toRequestBody)?.use { + sink.writeAll(it.source()) + } + } + } +} \ No newline at end of file diff --git a/app-catalog/samples/wNet/libwNet/src/main/res/drawable/ic_limited_time.xml b/app-catalog/samples/wNet/libwNet/src/main/res/drawable/ic_limited_time.xml new file mode 100644 index 0000000..eb841bc --- /dev/null +++ b/app-catalog/samples/wNet/libwNet/src/main/res/drawable/ic_limited_time.xml @@ -0,0 +1,25 @@ + + + + + diff --git a/app-catalog/samples/wNet/libwNet/src/main/res/drawable/ic_timing.xml b/app-catalog/samples/wNet/libwNet/src/main/res/drawable/ic_timing.xml new file mode 100644 index 0000000..9894001 --- /dev/null +++ b/app-catalog/samples/wNet/libwNet/src/main/res/drawable/ic_timing.xml @@ -0,0 +1,28 @@ + + + + + + diff --git a/app-catalog/samples/wNet/libwNet/src/main/res/values/strings.xml b/app-catalog/samples/wNet/libwNet/src/main/res/values/strings.xml new file mode 100644 index 0000000..9356429 --- /dev/null +++ b/app-catalog/samples/wNet/libwNet/src/main/res/values/strings.xml @@ -0,0 +1,39 @@ + + + + wNet + + + 连接网络失败 + 请求资源地址错误 + 无法找到指定服务器主机 + 连接服务器超时,%s + 下载过程发生错误 + 读取缓存失败 + 解析数据时发生异常 + 请求失败 + 请求参数错误 + 服务响应错误 + 发生空异常 + 未知网络错误 + 未知错误 + 无错误信息 + + + 加载中 + + diff --git a/app-catalog/samples/wNet/libwNet/src/main/res/xml/network_security_config.xml b/app-catalog/samples/wNet/libwNet/src/main/res/xml/network_security_config.xml new file mode 100644 index 0000000..5298cf3 --- /dev/null +++ b/app-catalog/samples/wNet/libwNet/src/main/res/xml/network_security_config.xml @@ -0,0 +1,19 @@ + + + + + \ No newline at end of file From 555b178bc1fe3b58e32faa13e3aec3d957a95af4 Mon Sep 17 00:00:00 2001 From: wintmain Date: Mon, 2 Jun 2025 19:10:48 +0800 Subject: [PATCH 3/4] [common][chore]Adjust sub-module support --- app-catalog/app/proguard-rules.pro | 32 ++++++++++++++++++++++++- app-catalog/samples/wBasis/build.gradle | 2 +- gradle/libs.versions.toml | 15 +++++++++++- 3 files changed, 46 insertions(+), 3 deletions(-) diff --git a/app-catalog/app/proguard-rules.pro b/app-catalog/app/proguard-rules.pro index 481bb43..2adf2a4 100644 --- a/app-catalog/app/proguard-rules.pro +++ b/app-catalog/app/proguard-rules.pro @@ -18,4 +18,34 @@ # If you keep the line number information, uncomment this to # hide the original source file name. -#-renamesourcefileattribute SourceFile \ No newline at end of file +#-renamesourcefileattribute SourceFile + +-dontwarn com.google.common.collect.ArrayListMultimap +-dontwarn com.google.common.collect.Multimap +-dontwarn java.awt.Color +-dontwarn java.awt.Font +-dontwarn java.awt.Point +-dontwarn java.awt.Rectangle +-dontwarn javax.money.CurrencyUnit +-dontwarn javax.money.Monetary +-dontwarn javax.ws.rs.Consumes +-dontwarn javax.ws.rs.Produces +-dontwarn javax.ws.rs.core.Response +-dontwarn javax.ws.rs.core.StreamingOutput +-dontwarn javax.ws.rs.ext.MessageBodyReader +-dontwarn javax.ws.rs.ext.MessageBodyWriter +-dontwarn javax.ws.rs.ext.Provider +-dontwarn org.glassfish.jersey.internal.spi.AutoDiscoverable +-dontwarn org.javamoney.moneta.Money +-dontwarn org.joda.time.DateTime +-dontwarn org.joda.time.DateTimeZone +-dontwarn org.joda.time.Duration +-dontwarn org.joda.time.Instant +-dontwarn org.joda.time.LocalDate +-dontwarn org.joda.time.LocalDateTime +-dontwarn org.joda.time.LocalTime +-dontwarn org.joda.time.Period +-dontwarn org.joda.time.ReadablePartial +-dontwarn org.joda.time.format.DateTimeFormat +-dontwarn org.joda.time.format.DateTimeFormatter +-dontwarn springfox.documentation.spring.web.json.Json diff --git a/app-catalog/samples/wBasis/build.gradle b/app-catalog/samples/wBasis/build.gradle index f9a9f72..3860e29 100644 --- a/app-catalog/samples/wBasis/build.gradle +++ b/app-catalog/samples/wBasis/build.gradle @@ -28,7 +28,7 @@ dependencies { implementation libs.androidx.constraintlayout implementation libs.androidx.activity.compose implementation libs.casa.ui - implementation libs.androidx.recyclerviewExt + implementation libs.androidx.recyclerview implementation libs.androidx.cardview implementation libs.androidx.navigation.fragment implementation libs.androidx.navigation.ui diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 72c2943..4f88b20 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -23,15 +23,20 @@ coil = "2.6.0" compose-bom = "2025.02.00" composeCompiler = "1.5.9" documentfile = "1.0.1" +glide = "4.11.0" +gson = "2.10.1" kotlin = "1.9.22" hilt = "2.48.1" +kotlinReflect = "2.0.21" kotlinxCoroutines = "1.7.3" +kotlinxSerialization = "1.6.3" ksp = "1.9.22-1.0.17" # Should be updated when kotlin version is updated coreExt = "1.13.1" androidx_activity = "1.9.2" androidx_appcompat = "1.7.0" androidx_navigation = "2.8.1" androidx_window = "1.3.0" +legacySupportV4 = "1.0.0" lifecycleExtensions = "2.2.0" lifecycleRuntimeKtx = "2.8.7" multidex = "1.0.3" @@ -58,6 +63,7 @@ hilt-testing = { group = "com.google.dagger", name = "hilt-android-testing", ver kotlin-gradlePlugin = { module = "org.jetbrains.kotlin:kotlin-gradle-plugin", version.ref = "kotlin" } kotlin-reflect = { module = "org.jetbrains.kotlin:kotlin-reflect", version.ref = "kotlin" } +androidx-legacy-support-v4 = { module = "androidx.legacy:legacy-support-v4", version.ref = "legacySupportV4" } androidx-activityExt = { group = "androidx.activity", name = "activity", version.ref = "androidx_activity" } androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "androidx_activity" } androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "androidx_appcompat" } @@ -88,7 +94,7 @@ compose-ui-test-manifest = { module = "androidx.compose.ui:ui-test-manifest" } # 具体的版本号用version = xxx,其他的是 version.ref = xxx androidx-cardview = "androidx.cardview:cardview:1.0.0" androidx-constraintlayout = "androidx.constraintlayout:constraintlayout:2.1.4" -androidx-recyclerviewExt = { group = "androidx.recyclerview", name = "recyclerview", version = "1.3.2" } +androidx-recyclerview = { group = "androidx.recyclerview", name = "recyclerview", version = "1.3.2" } # For control over item selection of both touch and mouse driven selection androidx-recyclerview-selection = "androidx.recyclerview:recyclerview-selection:1.1.0" androidx-slidingpanelayout = "androidx.slidingpanelayout:slidingpanelayout:1.2.0" @@ -109,6 +115,8 @@ androidx-dynamicanimation = "androidx.dynamicanimation:dynamicanimation-ktx:1.0. androidx-navigation-fragment = { module = "androidx.navigation:navigation-fragment", version.ref = "androidx_navigation" } androidx-navigation-ui = { module = "androidx.navigation:navigation-ui", version.ref = "androidx_navigation" } +androidx-navigation-ui-ktx = { module = "androidx.navigation:navigation-ui-ktx", version.ref = "androidx_navigation" } +androidx-navigation-fragment-ktx = { module = "androidx.navigation:navigation-fragment-ktx", version.ref = "androidx_navigation" } androidx-navigation-compose = { module = "androidx.navigation:navigation-compose", version.ref = "androidx_navigation" } androidx-navigation-testing = { module = "androidx.navigation:navigation-testing", version.ref = "androidx_navigation" } androidx-navigation-dff = { module = "androidx.navigation:navigation-dynamic-features-fragment", version.ref = "androidx_navigation"} @@ -127,12 +135,17 @@ androidx-lifecycle-livedata-ktx = { module = "androidx.lifecycle:lifecycle-lived androidx-lifecycle-viewmodel-ktx = { module = "androidx.lifecycle:lifecycle-viewmodel-ktx", version.ref = "lifecycleExtensions" } androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycleRuntimeKtx" } androidx-lifecycle-viewmodel-compose = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "lifecycleRuntimeKtx" } +kotlin-reflect-v2021 = { module = "org.jetbrains.kotlin:kotlin-reflect", version.ref = "kotlinReflect" } kotlinx-coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "kotlinxCoroutines" } kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "kotlinxCoroutines" } +kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinxSerialization" } +kotlinx-serialization-protobuf = { module = "org.jetbrains.kotlinx:kotlinx-serialization-protobuf", version.ref = "kotlinxSerialization" } material = "com.google.android.material:material:1.12.0" accompanist-permissions = "com.google.accompanist:accompanist-permissions:0.32.0" multidex = { module = "com.android.support:multidex", version.ref = "multidex" } okhttp = { module = "com.squareup.okhttp3:okhttp", version.ref = "okhttp" } +glide = { module = "com.github.bumptech.glide:glide", version.ref = "glide" } +gson = { module = "com.google.code.gson:gson", version.ref = "gson" } smartrefreshheader = { module = "com.scwang.smartrefresh:SmartRefreshHeader", version.ref = "smartrefresh" } smartrefreshlayout = { module = "com.scwang.smartrefresh:SmartRefreshLayout", version.ref = "smartrefresh" } From cff0c4ed4c008a1bed932c3b18b8f4b20ef40bc4 Mon Sep 17 00:00:00 2001 From: wintmain Date: Mon, 2 Jun 2025 19:11:36 +0800 Subject: [PATCH 4/4] [wNet][feat]Add net sample --- app-catalog/samples/wNet/README.md | 7 + app-catalog/samples/wNet/build.gradle.kts | 99 ++++++++++ .../wNet/libwNet/src/main/AndroidManifest.xml | 3 +- .../libwNet/cookie/PersistentCookieJar.kt | 2 + .../lib/wintmain/libwNet/utils/NetUtils.kt | 8 +- .../samples/wNet/src/main/AndroidManifest.xml | 38 ++++ .../com/wintmain/wNet/WNetMainActivity.kt | 127 ++++++++++++ .../java/com/wintmain/wNet/constants/Api.kt | 35 ++++ .../com/wintmain/wNet/constants/UserConfig.kt | 31 +++ .../wNet/contract/AlbumSelectContract.kt | 38 ++++ .../wNet/converter/FastJsonConverter.kt | 34 ++++ .../wintmain/wNet/converter/GsonConverter.kt | 37 ++++ .../wintmain/wNet/converter/MoshiConverter.kt | 39 ++++ .../wNet/converter/ProtobufConverter.kt | 63 ++++++ .../wNet/converter/SerializationConverter.kt | 96 +++++++++ .../interceptor/EncryptDataInterceptor.kt | 66 +++++++ .../interceptor/GlobalHeaderInterceptor.kt | 31 +++ .../interceptor/RefreshTokenInterceptor.kt | 49 +++++ .../com/wintmain/wNet/mock/MockDispatcher.kt | 99 ++++++++++ .../java/com/wintmain/wNet/model/ArrayData.kt | 22 +++ .../java/com/wintmain/wNet/model/BasicData.kt | 23 +++ .../com/wintmain/wNet/model/ConfigModel.kt | 24 +++ .../java/com/wintmain/wNet/model/GameModel.kt | 37 ++++ .../java/com/wintmain/wNet/model/SubData.kt | 22 +++ .../com/wintmain/wNet/model/TokenModel.kt | 25 +++ .../com/wintmain/wNet/model/UserInfoModel.kt | 27 +++ .../wintmain/wNet/ui/activity/MainActivity.kt | 62 ++++++ .../wNet/ui/fragment/AsyncTaskFragment.kt | 54 +++++ .../wNet/ui/fragment/AutoDialogFragment.kt | 46 +++++ .../ui/fragment/CallbackRequestFragment.kt | 51 +++++ .../ui/fragment/CoroutineScopeFragment.kt | 61 ++++++ .../wNet/ui/fragment/DownloadFileFragment.kt | 80 ++++++++ .../wNet/ui/fragment/EditDebounceFragment.kt | 72 +++++++ .../wNet/ui/fragment/ErrorHandlerFragment.kt | 40 ++++ .../ui/fragment/ExceptionTraceFragment.kt | 38 ++++ .../wNet/ui/fragment/FastestFragment.kt | 71 +++++++ .../ui/fragment/HttpsCertificateFragment.kt | 70 +++++++ .../wNet/ui/fragment/InterceptorFragment.kt | 38 ++++ .../wNet/ui/fragment/LimitedTimeFragment.kt | 58 ++++++ .../ui/fragment/ParallelNetworkFragment.kt | 47 +++++ .../wNet/ui/fragment/PreviewCacheFragment.kt | 52 +++++ .../wNet/ui/fragment/PullRefreshFragment.kt | 53 +++++ .../wNet/ui/fragment/PushRefreshFragment.kt | 49 +++++ .../wNet/ui/fragment/ReadCacheFragment.kt | 45 +++++ .../wNet/ui/fragment/SimpleRequestFragment.kt | 140 +++++++++++++ .../wNet/ui/fragment/StateLayoutFragment.kt | 38 ++++ .../wNet/ui/fragment/SuperIntervalFragment.kt | 65 ++++++ .../ui/fragment/SwitchDispatcherFragment.kt | 48 +++++ .../wNet/ui/fragment/SyncRequestFragment.kt | 46 +++++ .../wNet/ui/fragment/TimingRequestFragment.kt | 83 ++++++++ .../wNet/ui/fragment/UniqueRequestFragment.kt | 47 +++++ .../wNet/ui/fragment/UploadFileFragment.kt | 98 +++++++++ .../ui/fragment/ViewModelRequestFragment.kt | 53 +++++ .../fragment/converter/BaseConvertFragment.kt | 47 +++++ .../converter/FastJsonConvertFragment.kt | 44 +++++ .../fragment/converter/GsonConvertFragment.kt | 46 +++++ .../converter/MoshiConvertFragment.kt | 47 +++++ .../converter/SerializationConvertFragment.kt | 51 +++++ .../java/com/wintmain/wNet/utils/AESUtils.kt | 50 +++++ .../java/com/wintmain/wNet/utils/HttpUtils.kt | 49 +++++ .../wintmain/wNet/utils/RandomFileUtils.kt | 84 ++++++++ .../com/wintmain/wNet/vm/UserViewModel.kt | 55 ++++++ .../wNet/src/main/res/drawable/bg_card.xml | 25 +++ .../wNet/src/main/res/drawable/bg_empty.webp | Bin 0 -> 17000 bytes .../wNet/src/main/res/drawable/bg_error.webp | Bin 0 -> 17204 bytes .../wNet/src/main/res/drawable/bg_input.xml | 22 +++ .../src/main/res/drawable/ic_async_task.xml | 25 +++ .../main/res/drawable/ic_callback_request.xml | 26 +++ .../main/res/drawable/ic_config_dialog.xml | 25 +++ .../wNet/src/main/res/drawable/ic_convert.xml | 25 +++ .../src/main/res/drawable/ic_debounce.xml | 27 +++ .../main/res/drawable/ic_download_file.xml | 25 +++ .../main/res/drawable/ic_error_handler.xml | 25 +++ .../main/res/drawable/ic_exception_trace.xml | 25 +++ .../wNet/src/main/res/drawable/ic_fastest.xml | 26 +++ .../wNet/src/main/res/drawable/ic_https.xml | 26 +++ .../src/main/res/drawable/ic_interceptor.xml | 25 +++ .../src/main/res/drawable/ic_interval.xml | 25 +++ .../res/drawable/ic_launcher_background.xml | 187 ++++++++++++++++++ .../wNet/src/main/res/drawable/ic_menu.xml | 25 +++ .../main/res/drawable/ic_parallel_network.xml | 25 +++ .../main/res/drawable/ic_preview_cache.xml | 28 +++ .../src/main/res/drawable/ic_pull_refresh.xml | 25 +++ .../src/main/res/drawable/ic_push_refresh.xml | 25 +++ .../src/main/res/drawable/ic_read_cache.xml | 25 +++ .../wNet/src/main/res/drawable/ic_scope.xml | 25 +++ .../main/res/drawable/ic_simple_request.xml | 25 +++ .../src/main/res/drawable/ic_state_layout.xml | 25 +++ .../res/drawable/ic_switch_dispatcher.xml | 25 +++ .../src/main/res/drawable/ic_sync_request.xml | 26 +++ .../wNet/src/main/res/drawable/ic_unique.xml | 26 +++ .../src/main/res/drawable/ic_upload_file.xml | 25 +++ .../src/main/res/drawable/ic_view_model.xml | 26 +++ .../src/main/res/layout/activity_main.xml | 62 ++++++ .../main/res/layout/fragment_async_task.xml | 37 ++++ .../main/res/layout/fragment_auto_dialog.xml | 38 ++++ .../res/layout/fragment_callback_request.xml | 36 ++++ .../res/layout/fragment_coroutine_scope.xml | 37 ++++ .../res/layout/fragment_custom_convert.xml | 47 +++++ .../res/layout/fragment_download_file.xml | 57 ++++++ .../res/layout/fragment_edit_debounce.xml | 53 +++++ .../res/layout/fragment_error_handler.xml | 37 ++++ .../res/layout/fragment_exception_trace.xml | 37 ++++ .../src/main/res/layout/fragment_fastest.xml | 37 ++++ .../res/layout/fragment_https_certificate.xml | 53 +++++ .../main/res/layout/fragment_interceptor.xml | 37 ++++ .../main/res/layout/fragment_limited_time.xml | 43 ++++ .../res/layout/fragment_parallel_network.xml | 37 ++++ .../main/res/layout/fragment_pull_refresh.xml | 35 ++++ .../main/res/layout/fragment_push_refresh.xml | 37 ++++ .../main/res/layout/fragment_read_cache.xml | 38 ++++ .../res/layout/fragment_simple_request.xml | 37 ++++ .../main/res/layout/fragment_state_layout.xml | 38 ++++ .../res/layout/fragment_super_interval.xml | 38 ++++ .../res/layout/fragment_switch_dispatcher.xml | 37 ++++ .../main/res/layout/fragment_sync_request.xml | 36 ++++ .../res/layout/fragment_timing_request.xml | 67 +++++++ .../res/layout/fragment_unique_request.xml | 60 ++++++ .../main/res/layout/fragment_upload_file.xml | 59 ++++++ .../layout/fragment_view_model_request.xml | 63 ++++++ .../wNet/src/main/res/layout/item_game.xml | 52 +++++ .../src/main/res/layout/item_pull_list.xml | 52 +++++ .../res/layout/layout_drawer_nav_header.xml | 64 ++++++ .../wNet/src/main/res/layout/layout_empty.xml | 46 +++++ .../wNet/src/main/res/layout/layout_error.xml | 50 +++++ .../src/main/res/layout/layout_loading.xml | 30 +++ .../wNet/src/main/res/menu/menu_converter.xml | 31 +++ .../wNet/src/main/res/menu/menu_download.xml | 25 +++ .../wNet/src/main/res/menu/menu_interval.xml | 41 ++++ .../wNet/src/main/res/menu/menu_main.xml | 128 ++++++++++++ .../src/main/res/menu/menu_request_method.xml | 47 +++++ .../src/main/res/navigation/nav_converter.xml | 41 ++++ .../wNet/src/main/res/navigation/nav_main.xml | 154 +++++++++++++++ .../samples/wNet/src/main/res/raw/array.json | 18 ++ .../samples/wNet/src/main/res/raw/config.json | 3 + .../samples/wNet/src/main/res/raw/data.json | 8 + .../samples/wNet/src/main/res/raw/game.json | 173 ++++++++++++++++ .../samples/wNet/src/main/res/raw/user.json | 6 + .../wNet/src/main/res/values/colors.xml | 38 ++++ .../wNet/src/main/res/values/strings.xml | 19 ++ .../wNet/src/main/res/values/styles.xml | 32 +++ 141 files changed, 6303 insertions(+), 3 deletions(-) create mode 100644 app-catalog/samples/wNet/README.md create mode 100644 app-catalog/samples/wNet/build.gradle.kts create mode 100644 app-catalog/samples/wNet/src/main/AndroidManifest.xml create mode 100644 app-catalog/samples/wNet/src/main/java/com/wintmain/wNet/WNetMainActivity.kt create mode 100644 app-catalog/samples/wNet/src/main/java/com/wintmain/wNet/constants/Api.kt create mode 100644 app-catalog/samples/wNet/src/main/java/com/wintmain/wNet/constants/UserConfig.kt create mode 100644 app-catalog/samples/wNet/src/main/java/com/wintmain/wNet/contract/AlbumSelectContract.kt create mode 100644 app-catalog/samples/wNet/src/main/java/com/wintmain/wNet/converter/FastJsonConverter.kt create mode 100644 app-catalog/samples/wNet/src/main/java/com/wintmain/wNet/converter/GsonConverter.kt create mode 100644 app-catalog/samples/wNet/src/main/java/com/wintmain/wNet/converter/MoshiConverter.kt create mode 100644 app-catalog/samples/wNet/src/main/java/com/wintmain/wNet/converter/ProtobufConverter.kt create mode 100644 app-catalog/samples/wNet/src/main/java/com/wintmain/wNet/converter/SerializationConverter.kt create mode 100644 app-catalog/samples/wNet/src/main/java/com/wintmain/wNet/interceptor/EncryptDataInterceptor.kt create mode 100644 app-catalog/samples/wNet/src/main/java/com/wintmain/wNet/interceptor/GlobalHeaderInterceptor.kt create mode 100644 app-catalog/samples/wNet/src/main/java/com/wintmain/wNet/interceptor/RefreshTokenInterceptor.kt create mode 100644 app-catalog/samples/wNet/src/main/java/com/wintmain/wNet/mock/MockDispatcher.kt create mode 100644 app-catalog/samples/wNet/src/main/java/com/wintmain/wNet/model/ArrayData.kt create mode 100644 app-catalog/samples/wNet/src/main/java/com/wintmain/wNet/model/BasicData.kt create mode 100644 app-catalog/samples/wNet/src/main/java/com/wintmain/wNet/model/ConfigModel.kt create mode 100644 app-catalog/samples/wNet/src/main/java/com/wintmain/wNet/model/GameModel.kt create mode 100644 app-catalog/samples/wNet/src/main/java/com/wintmain/wNet/model/SubData.kt create mode 100644 app-catalog/samples/wNet/src/main/java/com/wintmain/wNet/model/TokenModel.kt create mode 100644 app-catalog/samples/wNet/src/main/java/com/wintmain/wNet/model/UserInfoModel.kt create mode 100644 app-catalog/samples/wNet/src/main/java/com/wintmain/wNet/ui/activity/MainActivity.kt create mode 100644 app-catalog/samples/wNet/src/main/java/com/wintmain/wNet/ui/fragment/AsyncTaskFragment.kt create mode 100644 app-catalog/samples/wNet/src/main/java/com/wintmain/wNet/ui/fragment/AutoDialogFragment.kt create mode 100644 app-catalog/samples/wNet/src/main/java/com/wintmain/wNet/ui/fragment/CallbackRequestFragment.kt create mode 100644 app-catalog/samples/wNet/src/main/java/com/wintmain/wNet/ui/fragment/CoroutineScopeFragment.kt create mode 100644 app-catalog/samples/wNet/src/main/java/com/wintmain/wNet/ui/fragment/DownloadFileFragment.kt create mode 100644 app-catalog/samples/wNet/src/main/java/com/wintmain/wNet/ui/fragment/EditDebounceFragment.kt create mode 100644 app-catalog/samples/wNet/src/main/java/com/wintmain/wNet/ui/fragment/ErrorHandlerFragment.kt create mode 100644 app-catalog/samples/wNet/src/main/java/com/wintmain/wNet/ui/fragment/ExceptionTraceFragment.kt create mode 100644 app-catalog/samples/wNet/src/main/java/com/wintmain/wNet/ui/fragment/FastestFragment.kt create mode 100644 app-catalog/samples/wNet/src/main/java/com/wintmain/wNet/ui/fragment/HttpsCertificateFragment.kt create mode 100644 app-catalog/samples/wNet/src/main/java/com/wintmain/wNet/ui/fragment/InterceptorFragment.kt create mode 100644 app-catalog/samples/wNet/src/main/java/com/wintmain/wNet/ui/fragment/LimitedTimeFragment.kt create mode 100644 app-catalog/samples/wNet/src/main/java/com/wintmain/wNet/ui/fragment/ParallelNetworkFragment.kt create mode 100644 app-catalog/samples/wNet/src/main/java/com/wintmain/wNet/ui/fragment/PreviewCacheFragment.kt create mode 100644 app-catalog/samples/wNet/src/main/java/com/wintmain/wNet/ui/fragment/PullRefreshFragment.kt create mode 100644 app-catalog/samples/wNet/src/main/java/com/wintmain/wNet/ui/fragment/PushRefreshFragment.kt create mode 100644 app-catalog/samples/wNet/src/main/java/com/wintmain/wNet/ui/fragment/ReadCacheFragment.kt create mode 100644 app-catalog/samples/wNet/src/main/java/com/wintmain/wNet/ui/fragment/SimpleRequestFragment.kt create mode 100644 app-catalog/samples/wNet/src/main/java/com/wintmain/wNet/ui/fragment/StateLayoutFragment.kt create mode 100644 app-catalog/samples/wNet/src/main/java/com/wintmain/wNet/ui/fragment/SuperIntervalFragment.kt create mode 100644 app-catalog/samples/wNet/src/main/java/com/wintmain/wNet/ui/fragment/SwitchDispatcherFragment.kt create mode 100644 app-catalog/samples/wNet/src/main/java/com/wintmain/wNet/ui/fragment/SyncRequestFragment.kt create mode 100644 app-catalog/samples/wNet/src/main/java/com/wintmain/wNet/ui/fragment/TimingRequestFragment.kt create mode 100644 app-catalog/samples/wNet/src/main/java/com/wintmain/wNet/ui/fragment/UniqueRequestFragment.kt create mode 100644 app-catalog/samples/wNet/src/main/java/com/wintmain/wNet/ui/fragment/UploadFileFragment.kt create mode 100644 app-catalog/samples/wNet/src/main/java/com/wintmain/wNet/ui/fragment/ViewModelRequestFragment.kt create mode 100644 app-catalog/samples/wNet/src/main/java/com/wintmain/wNet/ui/fragment/converter/BaseConvertFragment.kt create mode 100644 app-catalog/samples/wNet/src/main/java/com/wintmain/wNet/ui/fragment/converter/FastJsonConvertFragment.kt create mode 100644 app-catalog/samples/wNet/src/main/java/com/wintmain/wNet/ui/fragment/converter/GsonConvertFragment.kt create mode 100644 app-catalog/samples/wNet/src/main/java/com/wintmain/wNet/ui/fragment/converter/MoshiConvertFragment.kt create mode 100644 app-catalog/samples/wNet/src/main/java/com/wintmain/wNet/ui/fragment/converter/SerializationConvertFragment.kt create mode 100644 app-catalog/samples/wNet/src/main/java/com/wintmain/wNet/utils/AESUtils.kt create mode 100644 app-catalog/samples/wNet/src/main/java/com/wintmain/wNet/utils/HttpUtils.kt create mode 100644 app-catalog/samples/wNet/src/main/java/com/wintmain/wNet/utils/RandomFileUtils.kt create mode 100644 app-catalog/samples/wNet/src/main/java/com/wintmain/wNet/vm/UserViewModel.kt create mode 100644 app-catalog/samples/wNet/src/main/res/drawable/bg_card.xml create mode 100644 app-catalog/samples/wNet/src/main/res/drawable/bg_empty.webp create mode 100644 app-catalog/samples/wNet/src/main/res/drawable/bg_error.webp create mode 100644 app-catalog/samples/wNet/src/main/res/drawable/bg_input.xml create mode 100644 app-catalog/samples/wNet/src/main/res/drawable/ic_async_task.xml create mode 100644 app-catalog/samples/wNet/src/main/res/drawable/ic_callback_request.xml create mode 100644 app-catalog/samples/wNet/src/main/res/drawable/ic_config_dialog.xml create mode 100644 app-catalog/samples/wNet/src/main/res/drawable/ic_convert.xml create mode 100644 app-catalog/samples/wNet/src/main/res/drawable/ic_debounce.xml create mode 100644 app-catalog/samples/wNet/src/main/res/drawable/ic_download_file.xml create mode 100644 app-catalog/samples/wNet/src/main/res/drawable/ic_error_handler.xml create mode 100644 app-catalog/samples/wNet/src/main/res/drawable/ic_exception_trace.xml create mode 100644 app-catalog/samples/wNet/src/main/res/drawable/ic_fastest.xml create mode 100644 app-catalog/samples/wNet/src/main/res/drawable/ic_https.xml create mode 100644 app-catalog/samples/wNet/src/main/res/drawable/ic_interceptor.xml create mode 100644 app-catalog/samples/wNet/src/main/res/drawable/ic_interval.xml create mode 100644 app-catalog/samples/wNet/src/main/res/drawable/ic_launcher_background.xml create mode 100644 app-catalog/samples/wNet/src/main/res/drawable/ic_menu.xml create mode 100644 app-catalog/samples/wNet/src/main/res/drawable/ic_parallel_network.xml create mode 100644 app-catalog/samples/wNet/src/main/res/drawable/ic_preview_cache.xml create mode 100644 app-catalog/samples/wNet/src/main/res/drawable/ic_pull_refresh.xml create mode 100644 app-catalog/samples/wNet/src/main/res/drawable/ic_push_refresh.xml create mode 100644 app-catalog/samples/wNet/src/main/res/drawable/ic_read_cache.xml create mode 100644 app-catalog/samples/wNet/src/main/res/drawable/ic_scope.xml create mode 100644 app-catalog/samples/wNet/src/main/res/drawable/ic_simple_request.xml create mode 100644 app-catalog/samples/wNet/src/main/res/drawable/ic_state_layout.xml create mode 100644 app-catalog/samples/wNet/src/main/res/drawable/ic_switch_dispatcher.xml create mode 100644 app-catalog/samples/wNet/src/main/res/drawable/ic_sync_request.xml create mode 100644 app-catalog/samples/wNet/src/main/res/drawable/ic_unique.xml create mode 100644 app-catalog/samples/wNet/src/main/res/drawable/ic_upload_file.xml create mode 100644 app-catalog/samples/wNet/src/main/res/drawable/ic_view_model.xml create mode 100644 app-catalog/samples/wNet/src/main/res/layout/activity_main.xml create mode 100644 app-catalog/samples/wNet/src/main/res/layout/fragment_async_task.xml create mode 100644 app-catalog/samples/wNet/src/main/res/layout/fragment_auto_dialog.xml create mode 100644 app-catalog/samples/wNet/src/main/res/layout/fragment_callback_request.xml create mode 100644 app-catalog/samples/wNet/src/main/res/layout/fragment_coroutine_scope.xml create mode 100644 app-catalog/samples/wNet/src/main/res/layout/fragment_custom_convert.xml create mode 100644 app-catalog/samples/wNet/src/main/res/layout/fragment_download_file.xml create mode 100644 app-catalog/samples/wNet/src/main/res/layout/fragment_edit_debounce.xml create mode 100644 app-catalog/samples/wNet/src/main/res/layout/fragment_error_handler.xml create mode 100644 app-catalog/samples/wNet/src/main/res/layout/fragment_exception_trace.xml create mode 100644 app-catalog/samples/wNet/src/main/res/layout/fragment_fastest.xml create mode 100644 app-catalog/samples/wNet/src/main/res/layout/fragment_https_certificate.xml create mode 100644 app-catalog/samples/wNet/src/main/res/layout/fragment_interceptor.xml create mode 100644 app-catalog/samples/wNet/src/main/res/layout/fragment_limited_time.xml create mode 100644 app-catalog/samples/wNet/src/main/res/layout/fragment_parallel_network.xml create mode 100644 app-catalog/samples/wNet/src/main/res/layout/fragment_pull_refresh.xml create mode 100644 app-catalog/samples/wNet/src/main/res/layout/fragment_push_refresh.xml create mode 100644 app-catalog/samples/wNet/src/main/res/layout/fragment_read_cache.xml create mode 100644 app-catalog/samples/wNet/src/main/res/layout/fragment_simple_request.xml create mode 100644 app-catalog/samples/wNet/src/main/res/layout/fragment_state_layout.xml create mode 100644 app-catalog/samples/wNet/src/main/res/layout/fragment_super_interval.xml create mode 100644 app-catalog/samples/wNet/src/main/res/layout/fragment_switch_dispatcher.xml create mode 100644 app-catalog/samples/wNet/src/main/res/layout/fragment_sync_request.xml create mode 100644 app-catalog/samples/wNet/src/main/res/layout/fragment_timing_request.xml create mode 100644 app-catalog/samples/wNet/src/main/res/layout/fragment_unique_request.xml create mode 100644 app-catalog/samples/wNet/src/main/res/layout/fragment_upload_file.xml create mode 100644 app-catalog/samples/wNet/src/main/res/layout/fragment_view_model_request.xml create mode 100644 app-catalog/samples/wNet/src/main/res/layout/item_game.xml create mode 100644 app-catalog/samples/wNet/src/main/res/layout/item_pull_list.xml create mode 100644 app-catalog/samples/wNet/src/main/res/layout/layout_drawer_nav_header.xml create mode 100644 app-catalog/samples/wNet/src/main/res/layout/layout_empty.xml create mode 100644 app-catalog/samples/wNet/src/main/res/layout/layout_error.xml create mode 100644 app-catalog/samples/wNet/src/main/res/layout/layout_loading.xml create mode 100644 app-catalog/samples/wNet/src/main/res/menu/menu_converter.xml create mode 100644 app-catalog/samples/wNet/src/main/res/menu/menu_download.xml create mode 100644 app-catalog/samples/wNet/src/main/res/menu/menu_interval.xml create mode 100644 app-catalog/samples/wNet/src/main/res/menu/menu_main.xml create mode 100644 app-catalog/samples/wNet/src/main/res/menu/menu_request_method.xml create mode 100644 app-catalog/samples/wNet/src/main/res/navigation/nav_converter.xml create mode 100644 app-catalog/samples/wNet/src/main/res/navigation/nav_main.xml create mode 100644 app-catalog/samples/wNet/src/main/res/raw/array.json create mode 100644 app-catalog/samples/wNet/src/main/res/raw/config.json create mode 100644 app-catalog/samples/wNet/src/main/res/raw/data.json create mode 100644 app-catalog/samples/wNet/src/main/res/raw/game.json create mode 100644 app-catalog/samples/wNet/src/main/res/raw/user.json create mode 100644 app-catalog/samples/wNet/src/main/res/values/colors.xml create mode 100644 app-catalog/samples/wNet/src/main/res/values/strings.xml create mode 100644 app-catalog/samples/wNet/src/main/res/values/styles.xml diff --git a/app-catalog/samples/wNet/README.md b/app-catalog/samples/wNet/README.md new file mode 100644 index 0000000..4c53249 --- /dev/null +++ b/app-catalog/samples/wNet/README.md @@ -0,0 +1,7 @@ +# wNet + +这个模块是集成网络库以及其使用。 + +Original Link:https://github.com/liangjingkanji/Net + +[回到主页](../../../README.md) diff --git a/app-catalog/samples/wNet/build.gradle.kts b/app-catalog/samples/wNet/build.gradle.kts new file mode 100644 index 0000000..aa3d867 --- /dev/null +++ b/app-catalog/samples/wNet/build.gradle.kts @@ -0,0 +1,99 @@ +/* + * Copyright 2023-2025 wintmain + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +plugins { + id("com.android.library") + id("org.jetbrains.kotlin.android") + id("com.google.devtools.ksp") + id("kotlin-kapt") +} + +android { + namespace = "com.wintmain.wNet" + compileSdk = 35 + + defaultConfig { + // sub模块只需要指定最小的sdk即可 + minSdk = 26 + } + + buildFeatures { + dataBinding = true + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + kotlinOptions { + jvmTarget = "17" + } + + composeOptions { + kotlinCompilerExtensionVersion = libs.versions.composeCompiler.get() + } +} + +dependencies { + val composeBom = platform(libs.compose.bom) + implementation(composeBom) + androidTestImplementation(composeBom) + + implementation(libs.casa.base) + ksp(libs.casa.processor) + + implementation(libs.hilt.android) + ksp(libs.hilt.compiler) + + implementation(libs.androidx.coreExtkt) + implementation(libs.compose.foundation.foundation) + implementation(libs.compose.ui.ui) + implementation(libs.compose.material.material3) + + // [customize] - 将common的依赖放到这里 + implementation(libs.androidx.appcompat) + implementation(libs.material) + + // Add from here + implementation(libs.androidx.coreExtkt) + implementation(libs.androidx.constraintlayout) + implementation(libs.androidx.legacy.support.v4) + implementation(libs.androidx.recyclerview) + implementation(libs.material) + implementation(libs.androidx.navigation.fragment.ktx) + implementation(libs.androidx.navigation.ui.ktx) + + // ------------------------------网络请求------------------------------------- + implementation(project(":app-catalog:samples:wNet:libwNet")) + implementation(libs.glide) + // ------------------------------JSON解析------------------------------------- + implementation(libs.kotlinx.serialization.json) // JSON序列化库, 首选推荐使用 + implementation(libs.kotlinx.serialization.protobuf) // protobuf序列化 + implementation("com.squareup.moshi:moshi-kotlin:1.14.0") // JSON序列化库, 强校验, JSON字段缺失会导致解析异常, 故不推荐 + implementation(libs.kotlin.reflect.v2021) + implementation(libs.gson) // JSON序列化库, 会导致kotlin默认值无效, 故不推荐 + implementation("com.alibaba:fastjson:1.2.73") // JSON序列化库, 会导致kotlin默认值无效(除非引入kt-reflect), 不推荐 + // ------------------------------其他库------------------------------------- + implementation("com.github.liangjingkanji:StatusBar:2.0.2") // 透明状态栏 + implementation("com.github.liangjingkanji:debugkit:1.3.0") // 开发调试窗口工具 + implementation("com.github.liangjingkanji:Tooltip:1.2.2") // 吐司工具 + implementation("com.github.liangjingkanji:Engine:0.0.74") + implementation("com.github.liangjingkanji:Serialize:3.0.1") + implementation("com.github.liangjingkanji:BRV:1.5.2") // 提供自动分页/缺省页/自动下拉刷新功能 + implementation("com.github.chuckerteam.chucker:library:3.5.2") // 通知栏监听网络日志 + implementation("com.squareup.okhttp3:mockwebserver:4.10.0") + //debugImplementation("com.squareup.leakcanary:leakcanary-android:2.12") +} \ No newline at end of file diff --git a/app-catalog/samples/wNet/libwNet/src/main/AndroidManifest.xml b/app-catalog/samples/wNet/libwNet/src/main/AndroidManifest.xml index 9f7c384..4c7eb7d 100644 --- a/app-catalog/samples/wNet/libwNet/src/main/AndroidManifest.xml +++ b/app-catalog/samples/wNet/libwNet/src/main/AndroidManifest.xml @@ -20,7 +20,8 @@ - { val db = sqlHelper.writableDatabase db.rawQuery("SELECT * FROM cookies WHERE url = ?", arrayOf(url.host)).use { cursor -> diff --git a/app-catalog/samples/wNet/libwNet/src/main/java/lib/wintmain/libwNet/utils/NetUtils.kt b/app-catalog/samples/wNet/libwNet/src/main/java/lib/wintmain/libwNet/utils/NetUtils.kt index 2af7259..0108ee4 100644 --- a/app-catalog/samples/wNet/libwNet/src/main/java/lib/wintmain/libwNet/utils/NetUtils.kt +++ b/app-catalog/samples/wNet/libwNet/src/main/java/lib/wintmain/libwNet/utils/NetUtils.kt @@ -16,18 +16,22 @@ package lib.wintmain.libwNet.utils +import android.Manifest.permission import android.content.Context import android.net.ConnectivityManager import android.net.NetworkCapabilities -import android.os.Build +import android.os.Build.VERSION +import android.os.Build.VERSION_CODES +import androidx.annotation.RequiresPermission /** * 是否处于联网中 */ +@RequiresPermission(permission.ACCESS_NETWORK_STATE) fun Context.isNetworking(): Boolean { val connectivityManager = applicationContext.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager - return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + return if (VERSION.SDK_INT >= VERSION_CODES.M) { val networkCapabilities = connectivityManager.activeNetwork ?: return false val actNw = connectivityManager.getNetworkCapabilities(networkCapabilities) ?: return false when { diff --git a/app-catalog/samples/wNet/src/main/AndroidManifest.xml b/app-catalog/samples/wNet/src/main/AndroidManifest.xml new file mode 100644 index 0000000..107bf1f --- /dev/null +++ b/app-catalog/samples/wNet/src/main/AndroidManifest.xml @@ -0,0 +1,38 @@ + + + + + + + + + + + + + + + + + diff --git a/app-catalog/samples/wNet/src/main/java/com/wintmain/wNet/WNetMainActivity.kt b/app-catalog/samples/wNet/src/main/java/com/wintmain/wNet/WNetMainActivity.kt new file mode 100644 index 0000000..d8414f0 --- /dev/null +++ b/app-catalog/samples/wNet/src/main/java/com/wintmain/wNet/WNetMainActivity.kt @@ -0,0 +1,127 @@ +/* + * Copyright 2023-2025 wintmain + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.wintmain.wNet + +import android.content.Intent +import android.os.Bundle +import androidx.appcompat.app.AppCompatActivity +import com.chuckerteam.chucker.api.ChuckerCollector +import com.chuckerteam.chucker.api.ChuckerInterceptor +import com.drake.brv.utils.BRV +import com.drake.statelayout.StateConfig +import com.drake.tooltip.dialog.BubbleDialog +import com.google.android.catalog.framework.annotations.Sample +import com.scwang.smart.refresh.footer.ClassicsFooter +import com.scwang.smart.refresh.header.MaterialHeader +import com.scwang.smart.refresh.layout.SmartRefreshLayout +import com.wintmain.wNet.converter.SerializationConverter +import com.wintmain.wNet.interceptor.GlobalHeaderInterceptor +import com.wintmain.wNet.mock.MockDispatcher +import lib.wintmain.libwNet.NetConfig +import lib.wintmain.libwNet.cookie.PersistentCookieJar +import lib.wintmain.libwNet.interceptor.LogRecordInterceptor +import lib.wintmain.libwNet.okhttp.setConverter +import lib.wintmain.libwNet.okhttp.setDebug +import lib.wintmain.libwNet.okhttp.setDialogFactory +import lib.wintmain.libwNet.okhttp.setRequestInterceptor +import okhttp3.Cache +import java.util.concurrent.TimeUnit + +@Sample( + name = "wNet", + description = "网络库例子", + documentation = "", + tags = ["A-Self_demos", "wNet"] +) +class WNetMainActivity : AppCompatActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + NetConfig.initialize(com.wintmain.wNet.constants.Api.HOST, this) { + + // 超时设置 + connectTimeout(30, TimeUnit.SECONDS) + readTimeout(30, TimeUnit.SECONDS) + writeTimeout(30, TimeUnit.SECONDS) + + // 本框架支持Http缓存协议和强制缓存模式 + cache(Cache(cacheDir, 1024 * 1024 * 128)) // 缓存设置, 当超过maxSize最大值会根据最近最少使用算法清除缓存来限制缓存大小 + + // LogCat是否输出异常日志, 异常日志可以快速定位网络请求错误 + setDebug(true) + + // AndroidStudio OkHttp Profiler 插件输出网络日志 + addInterceptor(LogRecordInterceptor(true)) + + // 添加持久化Cookie管理 + cookieJar(PersistentCookieJar(applicationContext)) + + // 仅开发模式启用通知栏监听网络日志, 该框架存在下载大文件时内存溢出崩溃请等待官方修复 https://github.com/ChuckerTeam/chucker/issues/1068 + addInterceptor( + ChuckerInterceptor.Builder(applicationContext) + .collector(ChuckerCollector(applicationContext)) + .maxContentLength(250000L) + .redactHeaders(emptySet()) + .alwaysReadResponseBody(false) + .build() + ) + + // 添加请求拦截器, 可配置全局/动态参数 + setRequestInterceptor(GlobalHeaderInterceptor()) + + // 数据转换器 + setConverter(SerializationConverter()) + + // 自定义全局加载对话框 + setDialogFactory { + BubbleDialog(it, "加载中....") + } + } + + MockDispatcher.initialize() + + initializeView() + } + + /** 初始化第三方依赖库库 */ + private fun initializeView() { + + // 全局缺省页配置 [https://github.com/liangjingkanji/StateLayout] + StateConfig.apply { + emptyLayout = R.layout.layout_empty + loadingLayout = R.layout.layout_loading + errorLayout = R.layout.layout_error + setRetryIds(R.id.iv) + } + + // 初始化SmartRefreshLayout, 这是自动下拉刷新和上拉加载采用的第三方库 [https://github.com/scwang90/SmartRefreshLayout/tree/master] V2版本 + SmartRefreshLayout.setDefaultRefreshHeaderCreator { context, _ -> + MaterialHeader(context) + } + + SmartRefreshLayout.setDefaultRefreshFooterCreator { context, _ -> + ClassicsFooter(context) + } + + BRV.modelId = BR.m + + val intent = Intent().apply { + setClassName("com.wintmain.catalog.app", "com.wintmain.wNet.ui.activity.MainActivity") + } + startActivity(intent) + } +} \ No newline at end of file diff --git a/app-catalog/samples/wNet/src/main/java/com/wintmain/wNet/constants/Api.kt b/app-catalog/samples/wNet/src/main/java/com/wintmain/wNet/constants/Api.kt new file mode 100644 index 0000000..934d5db --- /dev/null +++ b/app-catalog/samples/wNet/src/main/java/com/wintmain/wNet/constants/Api.kt @@ -0,0 +1,35 @@ +/* + * Copyright 2023-2025 wintmain + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.wintmain.wNet.constants + +/* +建议请求路径都写在一个单例类中, 方便查找和替换 +*/ +object Api { + const val HOST = "http://127.0.0.1:8091" + + const val TEXT = "/text" + const val DELAY = "/delay" + const val UPLOAD = "/upload" + const val GAME = "/game" + const val DATA = "/data" + const val ARRAY = "/array" + const val CONFIG = "/config" + const val USER_INFO = "/userInfo" + const val TIME = "/time" + const val Token = "/token" +} \ No newline at end of file diff --git a/app-catalog/samples/wNet/src/main/java/com/wintmain/wNet/constants/UserConfig.kt b/app-catalog/samples/wNet/src/main/java/com/wintmain/wNet/constants/UserConfig.kt new file mode 100644 index 0000000..fe132b8 --- /dev/null +++ b/app-catalog/samples/wNet/src/main/java/com/wintmain/wNet/constants/UserConfig.kt @@ -0,0 +1,31 @@ +/* + * Copyright 2023-2025 wintmain + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.wintmain.wNet.constants + +import com.drake.serialize.serialize.annotation.SerializeConfig +import com.drake.serialize.serialize.serialLazy + +/** + * 本单例类使用 https://github.com/liangjingkanji/Serialize 为字段提供持久化存储 + */ +@SerializeConfig(mmapID = "user_config") +object UserConfig { + + var token by serialLazy(default = "6cad0ff06f5a214b9cfdf2a4a7c432339") + + var isLogin by serialLazy(default = false) +} \ No newline at end of file diff --git a/app-catalog/samples/wNet/src/main/java/com/wintmain/wNet/contract/AlbumSelectContract.kt b/app-catalog/samples/wNet/src/main/java/com/wintmain/wNet/contract/AlbumSelectContract.kt new file mode 100644 index 0000000..2af010a --- /dev/null +++ b/app-catalog/samples/wNet/src/main/java/com/wintmain/wNet/contract/AlbumSelectContract.kt @@ -0,0 +1,38 @@ +/* + * Copyright 2023-2025 wintmain + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.wintmain.wNet.contract + +import android.content.Context +import android.content.Intent +import android.net.Uri +import android.provider.MediaStore +import androidx.activity.result.contract.ActivityResultContract + +class AlbumSelectContract : ActivityResultContract() { + + override fun createIntent(context: Context, input: Unit?): Intent { + val intent = Intent(Intent.ACTION_PICK) + intent.setDataAndType(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, "image/*") + return intent + } + + class AlbumSelectResult(val code: Int, val uri: Uri?) + + override fun parseResult(resultCode: Int, intent: Intent?): AlbumSelectResult { + return AlbumSelectResult(resultCode, intent?.data) + } +} \ No newline at end of file diff --git a/app-catalog/samples/wNet/src/main/java/com/wintmain/wNet/converter/FastJsonConverter.kt b/app-catalog/samples/wNet/src/main/java/com/wintmain/wNet/converter/FastJsonConverter.kt new file mode 100644 index 0000000..82ba2be --- /dev/null +++ b/app-catalog/samples/wNet/src/main/java/com/wintmain/wNet/converter/FastJsonConverter.kt @@ -0,0 +1,34 @@ +/* + * Copyright 2023-2025 wintmain + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.wintmain.wNet.converter + +import com.alibaba.fastjson.JSON +import lib.wintmain.libwNet.convert.JSONConvert +import org.json.JSONObject +import java.lang.reflect.Type + +class FastJsonConverter : JSONConvert(code = "errorCode", message = "errorMsg", success = "0") { + + override fun String.parseBody(succeed: Type): R? { + val string = try { + JSONObject(this).getString("data") + } catch (e: Exception) { + this + } + return JSON.parseObject(string, succeed) + } +} \ No newline at end of file diff --git a/app-catalog/samples/wNet/src/main/java/com/wintmain/wNet/converter/GsonConverter.kt b/app-catalog/samples/wNet/src/main/java/com/wintmain/wNet/converter/GsonConverter.kt new file mode 100644 index 0000000..6ae3577 --- /dev/null +++ b/app-catalog/samples/wNet/src/main/java/com/wintmain/wNet/converter/GsonConverter.kt @@ -0,0 +1,37 @@ +/* + * Copyright 2023-2025 wintmain + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.wintmain.wNet.converter + +import com.google.gson.GsonBuilder +import lib.wintmain.libwNet.convert.JSONConvert +import org.json.JSONObject +import java.lang.reflect.Type + +class GsonConverter : JSONConvert(code = "errorCode", message = "errorMsg") { + companion object { + private val gson = GsonBuilder().serializeNulls().create() + } + + override fun String.parseBody(succeed: Type): R? { + val string = try { + JSONObject(this).getString("data") + } catch (e: Exception) { + this + } + return gson.fromJson(string, succeed) + } +} \ No newline at end of file diff --git a/app-catalog/samples/wNet/src/main/java/com/wintmain/wNet/converter/MoshiConverter.kt b/app-catalog/samples/wNet/src/main/java/com/wintmain/wNet/converter/MoshiConverter.kt new file mode 100644 index 0000000..7f3f65a --- /dev/null +++ b/app-catalog/samples/wNet/src/main/java/com/wintmain/wNet/converter/MoshiConverter.kt @@ -0,0 +1,39 @@ +/* + * Copyright 2023-2025 wintmain + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.wintmain.wNet.converter + +import com.squareup.moshi.Moshi +import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory +import lib.wintmain.libwNet.convert.JSONConvert +import org.json.JSONObject +import java.lang.reflect.Type + +class MoshiConverter : JSONConvert(code = "errorCode", message = "errorMsg", success = "0") { + + companion object { + private val moshi = Moshi.Builder().add(KotlinJsonAdapterFactory()).build() + } + + override fun String.parseBody(succeed: Type): R? { + val string = try { + JSONObject(this).getString("data") + } catch (e: Exception) { + this + } + return moshi.adapter(succeed).fromJson(string) + } +} \ No newline at end of file diff --git a/app-catalog/samples/wNet/src/main/java/com/wintmain/wNet/converter/ProtobufConverter.kt b/app-catalog/samples/wNet/src/main/java/com/wintmain/wNet/converter/ProtobufConverter.kt new file mode 100644 index 0000000..f6cc05b --- /dev/null +++ b/app-catalog/samples/wNet/src/main/java/com/wintmain/wNet/converter/ProtobufConverter.kt @@ -0,0 +1,63 @@ +/* + * Copyright 2023-2025 wintmain + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +@file:Suppress("UNCHECKED_CAST", "MemberVisibilityCanBePrivate") + +package com.wintmain.wNet.converter + +import kotlinx.serialization.protobuf.ProtoBuf +import kotlinx.serialization.serializer +import lib.wintmain.libwNet.convert.NetConverter +import lib.wintmain.libwNet.exception.ConvertException +import lib.wintmain.libwNet.exception.RequestParamsException +import lib.wintmain.libwNet.exception.ServerResponseException +import lib.wintmain.libwNet.request.kType +import okhttp3.Response +import java.lang.reflect.Type + +class ProtobufConverter : NetConverter { + + override fun onConvert(succeed: Type, response: Response): R? { + try { + return NetConverter.onConvert(succeed, response) + } catch (e: ConvertException) { + val code = response.code + when { + code in 200..299 -> { // 请求成功 + val bytes = response.body?.bytes() ?: return null + val kType = response.request.kType ?: throw ConvertException( + response, + "Request does not contain KType" + ) + return ProtoBuf.decodeFromByteArray( + ProtoBuf.serializersModule.serializer(kType), + bytes + ) as R + } + + code in 400..499 -> throw RequestParamsException( + response, + code.toString() + ) // 请求参数错误 + code >= 500 -> throw ServerResponseException(response, code.toString()) // 服务器异常错误 + else -> throw ConvertException( + response, + message = "Http status code not within range" + ) + } + } + } +} \ No newline at end of file diff --git a/app-catalog/samples/wNet/src/main/java/com/wintmain/wNet/converter/SerializationConverter.kt b/app-catalog/samples/wNet/src/main/java/com/wintmain/wNet/converter/SerializationConverter.kt new file mode 100644 index 0000000..93aa459 --- /dev/null +++ b/app-catalog/samples/wNet/src/main/java/com/wintmain/wNet/converter/SerializationConverter.kt @@ -0,0 +1,96 @@ +/* + * Copyright 2023-2025 wintmain + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +@file:Suppress("UNCHECKED_CAST", "MemberVisibilityCanBePrivate") + +package com.wintmain.wNet.converter + +import kotlinx.serialization.json.Json +import kotlinx.serialization.serializer +import lib.wintmain.libwNet.NetConfig +import lib.wintmain.libwNet.convert.NetConverter +import lib.wintmain.libwNet.exception.ConvertException +import lib.wintmain.libwNet.exception.RequestParamsException +import lib.wintmain.libwNet.exception.ResponseException +import lib.wintmain.libwNet.exception.ServerResponseException +import lib.wintmain.libwNet.request.kType +import okhttp3.Response +import org.json.JSONException +import org.json.JSONObject +import java.lang.reflect.Type +import kotlin.reflect.KType + +class SerializationConverter( + val success: String = "0", + val code: String = "errorCode", + val message: String = "errorMsg", +) : NetConverter { + + companion object { + val jsonDecoder = Json { + ignoreUnknownKeys = true // 数据类可以不用声明Json的所有字段 + coerceInputValues = true // 如果Json字段是Null则使用数据类字段默认值 + } + } + + override fun onConvert(succeed: Type, response: Response): R? { + try { + return NetConverter.onConvert(succeed, response) + } catch (e: ConvertException) { + val code = response.code + when { + code in 200..299 -> { // 请求成功 + val bodyString = response.body?.string() ?: return null + val kType = response.request.kType + ?: throw ConvertException(response, "Request does not contain KType") + return try { + val json = JSONObject(bodyString) // 获取JSON中后端定义的错误码和错误信息 + val srvCode = json.getString(this.code) + if (srvCode == success) { // 对比后端自定义错误码 + json.getString("data").parseBody(kType) + } else { // 错误码匹配失败, 开始写入错误异常 + val errorMessage = json.optString( + message, + NetConfig.app.getString(lib.wintmain.libwNet.R.string.no_error_message) + ) + throw ResponseException( + response, + errorMessage, + tag = srvCode + ) // 将业务错误码作为tag传递 + } + } catch (e: JSONException) { // 固定格式JSON分析失败直接解析JSON + bodyString.parseBody(kType) + } + } + + code in 400..499 -> throw RequestParamsException( + response, + code.toString() + ) // 请求参数错误 + code >= 500 -> throw ServerResponseException(response, code.toString()) // 服务器异常错误 + else -> throw ConvertException( + response, + message = "Http status code not within range" + ) + } + } + } + + fun String.parseBody(succeed: KType): R? { + return jsonDecoder.decodeFromString(Json.serializersModule.serializer(succeed), this) as R + } +} \ No newline at end of file diff --git a/app-catalog/samples/wNet/src/main/java/com/wintmain/wNet/interceptor/EncryptDataInterceptor.kt b/app-catalog/samples/wNet/src/main/java/com/wintmain/wNet/interceptor/EncryptDataInterceptor.kt new file mode 100644 index 0000000..285531a --- /dev/null +++ b/app-catalog/samples/wNet/src/main/java/com/wintmain/wNet/interceptor/EncryptDataInterceptor.kt @@ -0,0 +1,66 @@ +/* + * Copyright 2023-2025 wintmain + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.wintmain.wNet.interceptor + +import com.wintmain.wNet.utils.AESUtils +import lib.wintmain.libwNet.request.MediaConst +import okhttp3.Interceptor +import okhttp3.RequestBody.Companion.toRequestBody +import okhttp3.Response +import okhttp3.ResponseBody.Companion.toResponseBody +import okio.Buffer + +/** + * 演示如何加密请求参数/解密响应数据 + */ +class EncryptDataInterceptor : Interceptor { + override fun intercept(chain: Interceptor.Chain): Response { + var request = chain.request() + + // 加密, 仅加密 POST请求/JSON参数类型 + if (request.method == "POST" && request.header("Content-Type") == MediaConst.JSON.toString()) { + val body = request.body + if (body != null) { + val buff = Buffer() + body.writeTo(buff) + val json = buff.readUtf8() + if (json.isNotBlank()) { + val encryptJson = AESUtils.encrypt(json) + val newBody = encryptJson.toRequestBody(MediaConst.JSON) + request = request.newBuilder().post(newBody).build() + } + } + } + + var response = chain.proceed(request) + + // 解密, 仅解密JSON响应类型 + if (response.header("Content-Type") == MediaConst.JSON.toString()) { + val body = response.body + if (body != null) { + val json = body.string() + if (json.isNotBlank()) { + val decryptJson = AESUtils.decrypt(json) + val requestBody = decryptJson.toResponseBody(MediaConst.JSON) + response = response.newBuilder().body(requestBody).build() + } + } + } + + return response + } +} \ No newline at end of file diff --git a/app-catalog/samples/wNet/src/main/java/com/wintmain/wNet/interceptor/GlobalHeaderInterceptor.kt b/app-catalog/samples/wNet/src/main/java/com/wintmain/wNet/interceptor/GlobalHeaderInterceptor.kt new file mode 100644 index 0000000..7bcf88f --- /dev/null +++ b/app-catalog/samples/wNet/src/main/java/com/wintmain/wNet/interceptor/GlobalHeaderInterceptor.kt @@ -0,0 +1,31 @@ +/* + * Copyright 2023-2025 wintmain + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.wintmain.wNet.interceptor + +import com.wintmain.wNet.constants.UserConfig +import lib.wintmain.libwNet.interceptor.RequestInterceptor +import lib.wintmain.libwNet.request.BaseRequest + +/** 演示添加全局请求头/参数 */ +class GlobalHeaderInterceptor : RequestInterceptor { + + /** 本方法每次请求发起都会调用, 这里添加的参数可以是动态参数 */ + override fun interceptor(request: BaseRequest) { + request.setHeader("client", "Android") + request.setHeader("token", UserConfig.token) + } +} \ No newline at end of file diff --git a/app-catalog/samples/wNet/src/main/java/com/wintmain/wNet/interceptor/RefreshTokenInterceptor.kt b/app-catalog/samples/wNet/src/main/java/com/wintmain/wNet/interceptor/RefreshTokenInterceptor.kt new file mode 100644 index 0000000..690cc94 --- /dev/null +++ b/app-catalog/samples/wNet/src/main/java/com/wintmain/wNet/interceptor/RefreshTokenInterceptor.kt @@ -0,0 +1,49 @@ +/* + * Copyright 2023-2025 wintmain + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.wintmain.wNet.interceptor + +import com.wintmain.wNet.constants.UserConfig +import com.wintmain.wNet.model.TokenModel +import lib.wintmain.libwNet.Net +import lib.wintmain.libwNet.exception.ResponseException +import okhttp3.Interceptor +import okhttp3.Response + +/** + * 客户端token自动续期示例 + */ +class RefreshTokenInterceptor : Interceptor { + override fun intercept(chain: Interceptor.Chain): Response { + val request = chain.request() + val response = chain.proceed(request) + return synchronized(RefreshTokenInterceptor::class.java) { + if (response.code == 401 && UserConfig.isLogin && !request.url.encodedPath.contains(com.wintmain.wNet.constants.Api.Token)) { + val tokenInfo = Net.get(com.wintmain.wNet.constants.Api.Token) + .execute() // 同步请求token + if (tokenInfo.isExpired) { + // token过期抛出异常, 由全局错误处理器处理, 在其中可以跳转到登陆界面提示用户重新登陆 + throw ResponseException(response, "登录状态失效") + } else { + UserConfig.token = tokenInfo.token + } + chain.proceed(request) + } else { + response + } + } + } +} \ No newline at end of file diff --git a/app-catalog/samples/wNet/src/main/java/com/wintmain/wNet/mock/MockDispatcher.kt b/app-catalog/samples/wNet/src/main/java/com/wintmain/wNet/mock/MockDispatcher.kt new file mode 100644 index 0000000..f681eff --- /dev/null +++ b/app-catalog/samples/wNet/src/main/java/com/wintmain/wNet/mock/MockDispatcher.kt @@ -0,0 +1,99 @@ +/* + * Copyright 2023-2025 wintmain + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.wintmain.wNet.mock + +import android.util.Log +import com.drake.engine.base.app +import com.wintmain.wNet.R +import okhttp3.internal.closeQuietly +import okhttp3.mockwebserver.Dispatcher +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import okhttp3.mockwebserver.RecordedRequest +import okio.buffer +import okio.sink +import okio.source +import java.io.File +import java.text.SimpleDateFormat +import java.util.Date +import java.util.concurrent.TimeUnit +import kotlin.concurrent.thread + +class MockDispatcher : Dispatcher() { + + companion object { + fun initialize() { + val srv = MockWebServer() + srv.dispatcher = MockDispatcher() + thread { + try { + srv.start(8091) + } catch (e: Exception) { + Log.e("日志", "MOCK服务启动失败", e) + } + } + } + } + + override fun dispatch(request: RecordedRequest): MockResponse { + return when (request.requestUrl?.encodedPath ?: "") { + com.wintmain.wNet.constants.Api.TEXT -> getString("Request Success : ${request.method}") + com.wintmain.wNet.constants.Api.DELAY -> getString("Request Success : ${request.method}").setBodyDelay( + 2, + TimeUnit.SECONDS + ) + + com.wintmain.wNet.constants.Api.UPLOAD -> uploadFile(request) + com.wintmain.wNet.constants.Api.GAME -> getRawResponse(R.raw.game) + com.wintmain.wNet.constants.Api.DATA -> getRawResponse(R.raw.data) + com.wintmain.wNet.constants.Api.ARRAY -> getRawResponse(R.raw.array) + com.wintmain.wNet.constants.Api.USER_INFO -> getRawResponse(R.raw.user) + com.wintmain.wNet.constants.Api.CONFIG -> getRawResponse(R.raw.user) + com.wintmain.wNet.constants.Api.TIME -> getString( + SimpleDateFormat("yyyy-MM-dd HH:mm:ss").format( + Date() + ) + ).setBodyDelay(1, TimeUnit.SECONDS) + + else -> MockResponse().setResponseCode(404) + } + } + + private fun getString(text: String): MockResponse { + return MockResponse().setHeader("Content-Type", "text/plain").setBody(text) + } + + // 将接口上传的文件复制到应用缓存目录 + private fun uploadFile(req: RecordedRequest): MockResponse { + val file = File(app.cacheDir.absolutePath, "uploadFile.apk") + val source = req.body + file.createNewFile() + val sink = file.sink().buffer() + sink.writeAll(source) + sink.closeQuietly() + source.closeQuietly() + return MockResponse().setHeader("Content-Type", "text/plain").setBody("Upload success") + } + + private fun getRawResponse(rawId: Int, delay: Long = 500): MockResponse { + val buf = app.resources.openRawResource(rawId).source().buffer().readUtf8() + return MockResponse() + .setHeader("Content-Type", "application/json; charset=utf-8") + .setBodyDelay(delay, TimeUnit.MILLISECONDS) + .setBody(buf) + } +} \ No newline at end of file diff --git a/app-catalog/samples/wNet/src/main/java/com/wintmain/wNet/model/ArrayData.kt b/app-catalog/samples/wNet/src/main/java/com/wintmain/wNet/model/ArrayData.kt new file mode 100644 index 0000000..d127a01 --- /dev/null +++ b/app-catalog/samples/wNet/src/main/java/com/wintmain/wNet/model/ArrayData.kt @@ -0,0 +1,22 @@ +/* + * Copyright 2023-2025 wintmain + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.wintmain.wNet.model + +class ArrayData { + var title: String = "" + var name: String = "" +} \ No newline at end of file diff --git a/app-catalog/samples/wNet/src/main/java/com/wintmain/wNet/model/BasicData.kt b/app-catalog/samples/wNet/src/main/java/com/wintmain/wNet/model/BasicData.kt new file mode 100644 index 0000000..baddce8 --- /dev/null +++ b/app-catalog/samples/wNet/src/main/java/com/wintmain/wNet/model/BasicData.kt @@ -0,0 +1,23 @@ +/* + * Copyright 2023-2025 wintmain + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.wintmain.wNet.model + +open class BasicData { + val code: String = "" + val msg: String = "" + val data: T? = null +} diff --git a/app-catalog/samples/wNet/src/main/java/com/wintmain/wNet/model/ConfigModel.kt b/app-catalog/samples/wNet/src/main/java/com/wintmain/wNet/model/ConfigModel.kt new file mode 100644 index 0000000..01a0e39 --- /dev/null +++ b/app-catalog/samples/wNet/src/main/java/com/wintmain/wNet/model/ConfigModel.kt @@ -0,0 +1,24 @@ +/* + * Copyright 2023-2025 wintmain + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.wintmain.wNet.model + +import kotlinx.serialization.Serializable + +@Serializable +data class ConfigModel( + var maintain: Boolean = false +) \ No newline at end of file diff --git a/app-catalog/samples/wNet/src/main/java/com/wintmain/wNet/model/GameModel.kt b/app-catalog/samples/wNet/src/main/java/com/wintmain/wNet/model/GameModel.kt new file mode 100644 index 0000000..0ad8865 --- /dev/null +++ b/app-catalog/samples/wNet/src/main/java/com/wintmain/wNet/model/GameModel.kt @@ -0,0 +1,37 @@ +/* + * Copyright 2023-2025 wintmain + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.wintmain.wNet.model + +@kotlinx.serialization.Serializable +data class GameModel( + var total: Int = 0, + var list: List = listOf() +) { + + @kotlinx.serialization.Serializable + data class Data( + var id: Int = 0, + var img: String = "", + var name: String = "", + var label: List = listOf(), + var price: String = "", + var initialPrice: String = "", + var grade: Int = 0, + var discount: Double = 0.0, + var endTime: Int = 0 + ) +} \ No newline at end of file diff --git a/app-catalog/samples/wNet/src/main/java/com/wintmain/wNet/model/SubData.kt b/app-catalog/samples/wNet/src/main/java/com/wintmain/wNet/model/SubData.kt new file mode 100644 index 0000000..21b770d --- /dev/null +++ b/app-catalog/samples/wNet/src/main/java/com/wintmain/wNet/model/SubData.kt @@ -0,0 +1,22 @@ +/* + * Copyright 2023-2025 wintmain + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.wintmain.wNet.model + +class SubData : BasicData() { + var title: String = "" + var name: String = "" +} \ No newline at end of file diff --git a/app-catalog/samples/wNet/src/main/java/com/wintmain/wNet/model/TokenModel.kt b/app-catalog/samples/wNet/src/main/java/com/wintmain/wNet/model/TokenModel.kt new file mode 100644 index 0000000..8ce3366 --- /dev/null +++ b/app-catalog/samples/wNet/src/main/java/com/wintmain/wNet/model/TokenModel.kt @@ -0,0 +1,25 @@ +/* + * Copyright 2023-2025 wintmain + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.wintmain.wNet.model + +import kotlinx.serialization.Serializable + +@Serializable +data class TokenModel( + var token: String = "", + var isExpired: Boolean = false +) \ No newline at end of file diff --git a/app-catalog/samples/wNet/src/main/java/com/wintmain/wNet/model/UserInfoModel.kt b/app-catalog/samples/wNet/src/main/java/com/wintmain/wNet/model/UserInfoModel.kt new file mode 100644 index 0000000..308cb30 --- /dev/null +++ b/app-catalog/samples/wNet/src/main/java/com/wintmain/wNet/model/UserInfoModel.kt @@ -0,0 +1,27 @@ +/* + * Copyright 2023-2025 wintmain + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.wintmain.wNet.model + +import kotlinx.serialization.Serializable + +@Serializable +data class UserInfoModel( + var userId: Int = 0, + var username: String = "", + var age: Int = 0, + var balance: String = "" +) \ No newline at end of file diff --git a/app-catalog/samples/wNet/src/main/java/com/wintmain/wNet/ui/activity/MainActivity.kt b/app-catalog/samples/wNet/src/main/java/com/wintmain/wNet/ui/activity/MainActivity.kt new file mode 100644 index 0000000..9f5fef2 --- /dev/null +++ b/app-catalog/samples/wNet/src/main/java/com/wintmain/wNet/ui/activity/MainActivity.kt @@ -0,0 +1,62 @@ +/* + * Copyright 2023-2025 wintmain + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.wintmain.wNet.ui.activity + +import androidx.core.view.GravityCompat +import androidx.navigation.findNavController +import androidx.navigation.fragment.FragmentNavigator +import androidx.navigation.ui.AppBarConfiguration +import androidx.navigation.ui.setupWithNavController +import com.bumptech.glide.Glide +import com.drake.engine.base.EngineActivity +import com.drake.statusbar.immersive +import com.wintmain.wNet.R +import com.wintmain.wNet.databinding.ActivityMainBinding + +/** + * 以下代码设置导航, 和框架本身无关无需关心, 请查看[com.wintmain.wNet.ui.fragment]内的Fragment + */ +class MainActivity : EngineActivity(R.layout.activity_main) { + + override fun initView() { + immersive(binding.toolbar, true) + setSupportActionBar(binding.toolbar) + val navController = findNavController(R.id.nav) + + Glide.with(this) + .load("https://avatars.githubusercontent.com/u/21078112?v=4") + .circleCrop() + .into(binding.drawerNav.getHeaderView(0).findViewById(R.id.iv)) + + binding.toolbar.setupWithNavController( + navController, + AppBarConfiguration(binding.drawerNav.menu, binding.drawer) + ) + navController.addOnDestinationChangedListener { _, destination, _ -> + binding.toolbar.subtitle = + (destination as FragmentNavigator.Destination).className.substringAfterLast('.') + } + binding.drawerNav.setupWithNavController(navController) + } + + override fun initData() { + } + + override fun onBackPressed() { + if (binding.drawer.isDrawerOpen(GravityCompat.START)) binding.drawer.closeDrawers() else super.onBackPressed() + } +} diff --git a/app-catalog/samples/wNet/src/main/java/com/wintmain/wNet/ui/fragment/AsyncTaskFragment.kt b/app-catalog/samples/wNet/src/main/java/com/wintmain/wNet/ui/fragment/AsyncTaskFragment.kt new file mode 100644 index 0000000..0ce58ad --- /dev/null +++ b/app-catalog/samples/wNet/src/main/java/com/wintmain/wNet/ui/fragment/AsyncTaskFragment.kt @@ -0,0 +1,54 @@ +/* + * Copyright 2023-2025 wintmain + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.wintmain.wNet.ui.fragment + +import com.drake.engine.base.EngineFragment +import com.wintmain.wNet.R +import com.wintmain.wNet.databinding.FragmentAsyncTaskBinding +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.delay +import kotlinx.coroutines.withContext +import lib.wintmain.libwNet.utils.scope + +class AsyncTaskFragment : EngineFragment(R.layout.fragment_async_task) { + + override fun initView() { + scope { + binding.tvFragment.text = withContext(Dispatchers.IO) { + delay(2000) + "结果" + } + } + } + + override fun initData() { + } + + /** + * 抽出异步任务为一个函数 + */ + private suspend fun withDownloadFile() = withContext(Dispatchers.IO) { + delay(200) + "结果" + } + + private fun CoroutineScope.asyncDownloadFile() = async { + "结果" + } +} diff --git a/app-catalog/samples/wNet/src/main/java/com/wintmain/wNet/ui/fragment/AutoDialogFragment.kt b/app-catalog/samples/wNet/src/main/java/com/wintmain/wNet/ui/fragment/AutoDialogFragment.kt new file mode 100644 index 0000000..d5f046c --- /dev/null +++ b/app-catalog/samples/wNet/src/main/java/com/wintmain/wNet/ui/fragment/AutoDialogFragment.kt @@ -0,0 +1,46 @@ +/* + * Copyright 2023-2025 wintmain + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.wintmain.wNet.ui.fragment + +import com.drake.engine.base.EngineFragment +import com.drake.tooltip.toast +import com.wintmain.wNet.R +import com.wintmain.wNet.databinding.FragmentAutoDialogBinding +import kotlinx.coroutines.CancellationException +import lib.wintmain.libwNet.Post +import lib.wintmain.libwNet.utils.scopeDialog + +class AutoDialogFragment : + EngineFragment(R.layout.fragment_auto_dialog) { + + override fun initView() { + scopeDialog { + binding.tvFragment.text = Post(com.wintmain.wNet.constants.Api.DELAY) { + param("username", "你的账号") + param("password", "123456") + }.await() + }.finally { + // 关闭对话框后执行的异常 + if (it is CancellationException) { + toast("对话框被关闭, 网络请求自动取消") // 这里存在Handler吐司崩溃, 如果不想处理就直接使用我的吐司库 https://github.com/liangjingkanji/Tooltip + } + } + } + + override fun initData() { + } +} diff --git a/app-catalog/samples/wNet/src/main/java/com/wintmain/wNet/ui/fragment/CallbackRequestFragment.kt b/app-catalog/samples/wNet/src/main/java/com/wintmain/wNet/ui/fragment/CallbackRequestFragment.kt new file mode 100644 index 0000000..add625e --- /dev/null +++ b/app-catalog/samples/wNet/src/main/java/com/wintmain/wNet/ui/fragment/CallbackRequestFragment.kt @@ -0,0 +1,51 @@ +/* + * Copyright 2023-2025 wintmain + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.wintmain.wNet.ui.fragment + +import com.drake.engine.base.EngineFragment +import com.wintmain.wNet.R +import com.wintmain.wNet.databinding.FragmentCallbackRequestBinding +import lib.wintmain.libwNet.Net +import lib.wintmain.libwNet.utils.runMain +import okhttp3.Call +import okhttp3.Callback +import okhttp3.Response +import java.io.IOException + +class CallbackRequestFragment : + EngineFragment(R.layout.fragment_callback_request) { + + override fun initData() { + } + + override fun initView() { + // Net同样支持OkHttp原始的队列任务 + Net.post(com.wintmain.wNet.constants.Api.TEXT).enqueue(object : Callback { + override fun onFailure(call: Call, e: IOException) { + } + + override fun onResponse(call: Call, response: Response) { + // 此处为子线程 + val body = response.body?.string() ?: "无数据" + runMain { + // 此处为主线程 + binding.tvFragment.text = body + } + } + }) + } +} \ No newline at end of file diff --git a/app-catalog/samples/wNet/src/main/java/com/wintmain/wNet/ui/fragment/CoroutineScopeFragment.kt b/app-catalog/samples/wNet/src/main/java/com/wintmain/wNet/ui/fragment/CoroutineScopeFragment.kt new file mode 100644 index 0000000..c9ddceb --- /dev/null +++ b/app-catalog/samples/wNet/src/main/java/com/wintmain/wNet/ui/fragment/CoroutineScopeFragment.kt @@ -0,0 +1,61 @@ +/* + * Copyright 2023-2025 wintmain + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.wintmain.wNet.ui.fragment + +import androidx.lifecycle.Lifecycle +import com.drake.engine.base.EngineFragment +import com.wintmain.wNet.R +import com.wintmain.wNet.databinding.FragmentCoroutineScopeBinding +import kotlinx.coroutines.delay +import lib.wintmain.libwNet.utils.scope +import lib.wintmain.libwNet.utils.scopeLife +import lib.wintmain.libwNet.utils.scopeNet +import lib.wintmain.libwNet.utils.scopeNetLife + +class CoroutineScopeFragment : + EngineFragment(R.layout.fragment_coroutine_scope) { + override fun initData() { + // 其作用域在应用进程销毁时才会被动取消 + scope { + + } + + // 其作用域在Activity或者Fragment销毁(onDestroy)时被动取消 [scopeNetLife] + scopeLife { + delay(2000) + binding.tvFragment.text = "任务结束" + } + + // 自定义取消跟随的生命周期, 失去焦点时立即取消作用域 + scopeLife(Lifecycle.Event.ON_PAUSE) { + + } + + // 此作用域会捕捉发生的异常, 如果是网络异常会进入网络异常的全局处理函数, 例如自动弹出吐司 [NetConfig.onError] + scopeNet { + + } + + // 自动网络处理 + 生命周期管理 + scopeNetLife { + + } + } + + override fun initView() { + } +} diff --git a/app-catalog/samples/wNet/src/main/java/com/wintmain/wNet/ui/fragment/DownloadFileFragment.kt b/app-catalog/samples/wNet/src/main/java/com/wintmain/wNet/ui/fragment/DownloadFileFragment.kt new file mode 100644 index 0000000..3b9aef9 --- /dev/null +++ b/app-catalog/samples/wNet/src/main/java/com/wintmain/wNet/ui/fragment/DownloadFileFragment.kt @@ -0,0 +1,80 @@ +/* + * Copyright 2023-2025 wintmain + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.wintmain.wNet.ui.fragment + +import android.annotation.SuppressLint +import android.view.Menu +import android.view.MenuInflater +import android.view.MenuItem +import com.drake.engine.base.EngineFragment +import com.wintmain.wNet.R +import com.wintmain.wNet.databinding.FragmentDownloadFileBinding +import lib.wintmain.libwNet.Get +import lib.wintmain.libwNet.component.Progress +import lib.wintmain.libwNet.interfaces.ProgressListener +import lib.wintmain.libwNet.scope.NetCoroutineScope +import lib.wintmain.libwNet.utils.scopeNetLife +import java.io.File + +class DownloadFileFragment : + EngineFragment(R.layout.fragment_download_file) { + + private lateinit var downloadScope: NetCoroutineScope + + @SuppressLint("SetTextI18n") + override fun initView() { + setHasOptionsMenu(true) + downloadScope = scopeNetLife { + val file = + Get("https://dl.coolapk.com/down?pn=com.coolapk.market&id=NDU5OQ&h=46bb9d98&from=from-web") { + setDownloadFileName("net.apk") + setDownloadDir(requireContext().filesDir) + setDownloadMd5Verify() + setDownloadTempFile() + addDownloadListener(object : ProgressListener() { + override fun onProgress(p: Progress) { + binding.seek?.post { + val progress = p.progress() + binding.seek.progress = progress + binding.tvProgress.text = + "下载进度: $progress% 下载速度: ${p.speedSize()} " + + "\n\n文件大小: ${p.totalSize()} 已下载: ${p.currentSize()} 剩余大小: ${p.remainSize()}" + + "\n\n已使用时间: ${p.useTime()} 剩余时间: ${p.remainTime()}" + } + } + }) + }.await() + + // 下载完成才会执行此处(所有网络请求使用await都会等待请求完成), 只是监听文件下载完成请不要使用[addDownloadListener] + } + } + + override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) { + super.onCreateOptionsMenu(menu, inflater) + inflater.inflate(R.menu.menu_download, menu) + } + + override fun onOptionsItemSelected(item: MenuItem): Boolean { + when (item.itemId) { + R.id.cancel -> downloadScope.cancel() // 取消下载 + } + return super.onOptionsItemSelected(item) + } + + override fun initData() { + } +} diff --git a/app-catalog/samples/wNet/src/main/java/com/wintmain/wNet/ui/fragment/EditDebounceFragment.kt b/app-catalog/samples/wNet/src/main/java/com/wintmain/wNet/ui/fragment/EditDebounceFragment.kt new file mode 100644 index 0000000..22508c3 --- /dev/null +++ b/app-catalog/samples/wNet/src/main/java/com/wintmain/wNet/ui/fragment/EditDebounceFragment.kt @@ -0,0 +1,72 @@ +/* + * Copyright 2023-2025 wintmain + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.wintmain.wNet.ui.fragment + +import com.drake.brv.utils.models +import com.drake.brv.utils.setup +import com.drake.engine.base.EngineFragment +import com.wintmain.wNet.R +import com.wintmain.wNet.databinding.FragmentEditDebounceBinding +import com.wintmain.wNet.model.GameModel +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.distinctUntilChanged +import lib.wintmain.libwNet.Get +import lib.wintmain.libwNet.utils.debounce +import lib.wintmain.libwNet.utils.launchIn +import lib.wintmain.libwNet.utils.scope + +class EditDebounceFragment : + EngineFragment(R.layout.fragment_edit_debounce) { + + override fun initData() { + } + + override fun initView() { + var searchText = "" + var scope: CoroutineScope? = null + + // 配置列表 + binding.rv.setup { + addType(R.layout.item_game) + } + + // 监听分页 + binding.page.onRefresh { + scope = scope { + val data = Get(com.wintmain.wNet.constants.Api.GAME) { + param("search", searchText) + param("page", index) + }.await() + addData(data.list) { + itemCount < data.total + } + } + } + + // distinctUntilChanged 表示过滤掉重复结果 + binding.etInput.debounce().distinctUntilChanged().launchIn(this) { + scope?.cancel() // 发起新的请求前取消旧的请求, 避免旧数据覆盖新数据 + searchText = it + if (it.isBlank()) { + binding.rv.models = null + } else { + binding.page.showLoading() + } + } + } +} \ No newline at end of file diff --git a/app-catalog/samples/wNet/src/main/java/com/wintmain/wNet/ui/fragment/ErrorHandlerFragment.kt b/app-catalog/samples/wNet/src/main/java/com/wintmain/wNet/ui/fragment/ErrorHandlerFragment.kt new file mode 100644 index 0000000..23340ed --- /dev/null +++ b/app-catalog/samples/wNet/src/main/java/com/wintmain/wNet/ui/fragment/ErrorHandlerFragment.kt @@ -0,0 +1,40 @@ +/* + * Copyright 2023-2025 wintmain + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.wintmain.wNet.ui.fragment + +import com.drake.engine.base.EngineFragment +import com.wintmain.wNet.R +import com.wintmain.wNet.databinding.FragmentErrorHandlerBinding +import lib.wintmain.libwNet.Get +import lib.wintmain.libwNet.utils.scopeNetLife + +class ErrorHandlerFragment : + EngineFragment(R.layout.fragment_error_handler) { + + override fun initView() { + scopeNetLife { + // 该请求是错误的路径会在控制台打印出错误信息 + Get("error").await() + }.catch { + // 重写该函数后, 错误不会流到[NetConfig.onError]中的全局错误处理, 在App.kt中可以自定义该全局处理, 同时包含onStateError + binding.tvFragment.text = it.message + } + } + + override fun initData() { + } +} diff --git a/app-catalog/samples/wNet/src/main/java/com/wintmain/wNet/ui/fragment/ExceptionTraceFragment.kt b/app-catalog/samples/wNet/src/main/java/com/wintmain/wNet/ui/fragment/ExceptionTraceFragment.kt new file mode 100644 index 0000000..b2c7191 --- /dev/null +++ b/app-catalog/samples/wNet/src/main/java/com/wintmain/wNet/ui/fragment/ExceptionTraceFragment.kt @@ -0,0 +1,38 @@ +/* + * Copyright 2023-2025 wintmain + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.wintmain.wNet.ui.fragment + +import com.drake.engine.base.EngineFragment +import com.wintmain.wNet.R +import com.wintmain.wNet.databinding.FragmentExceptionTraceBinding +import lib.wintmain.libwNet.Get +import lib.wintmain.libwNet.utils.scopeNetLife + +class ExceptionTraceFragment : + EngineFragment(R.layout.fragment_exception_trace) { + + override fun initView() { + scopeNetLife { + // 这是一个错误的地址, 请查看LogCat的错误信息, 在[initNet]函数中的[onError]回调中你也可以进行自定义错误信息打印 + binding.tvFragment.text = + Get("https://githuberror.com/liangjingkanji/Net/").await() + } + } + + override fun initData() { + } +} diff --git a/app-catalog/samples/wNet/src/main/java/com/wintmain/wNet/ui/fragment/FastestFragment.kt b/app-catalog/samples/wNet/src/main/java/com/wintmain/wNet/ui/fragment/FastestFragment.kt new file mode 100644 index 0000000..ad98bf3 --- /dev/null +++ b/app-catalog/samples/wNet/src/main/java/com/wintmain/wNet/ui/fragment/FastestFragment.kt @@ -0,0 +1,71 @@ +/* + * Copyright 2023-2025 wintmain + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.wintmain.wNet.ui.fragment + +import com.drake.engine.base.EngineFragment +import com.wintmain.wNet.R +import com.wintmain.wNet.databinding.FragmentFastestBinding +import lib.wintmain.libwNet.Get +import lib.wintmain.libwNet.Post +import lib.wintmain.libwNet.utils.fastest +import lib.wintmain.libwNet.utils.scopeNetLife + +class FastestFragment : EngineFragment(R.layout.fragment_fastest) { + + override fun initView() { + scopeNetLife { + /* + 网络请求的取消本质上依靠uid来辨别,如果设置[uid]参数可以在返回最快结果后取消掉其他网络请求, 反之不会取消其他网络请求 + Tip: uid可以是任何值 + */ + + // 同时发起四个网络请求 + val deferred2 = Get(com.wintmain.wNet.constants.Api.TEXT) { setGroup("最快") } + val deferred3 = Post("navi/json") { setGroup("最快") } + val deferred = Get("api0") { setGroup("最快") } // 错误接口 + val deferred1 = Get("api1") { setGroup("最快") } // 错误接口 + + // 只返回最快的请求结果 + binding.tvFragment.text = + fastest(listOf(deferred, deferred1, deferred2, deferred3), "最快") + } + + /* + 假设并发的接口返回的数据类型不同 或者 想要监听最快请求返回的结果回调请使用 [Deferred.transform] 函数 + 具体请看文档 https://liangjingkanji.github.io/Net/fastest/ + */ + // scopeNetLife { + // + // // 同时发起四个网络请求 + // val requestList = mutableListOf>().apply { + // for (i in 0..28) { + // val request = Get(Api.BANNER).transform { + // Log.d("日志", "(FastestFragment.kt:73) it = ${it}") + // it + // } + // add(request) + // } + // } + // + // // 只返回最快的请求结果 + // binding.tvFragment.text = fastest(requestList).toString() + // } + } + + override fun initData() { + } +} \ No newline at end of file diff --git a/app-catalog/samples/wNet/src/main/java/com/wintmain/wNet/ui/fragment/HttpsCertificateFragment.kt b/app-catalog/samples/wNet/src/main/java/com/wintmain/wNet/ui/fragment/HttpsCertificateFragment.kt new file mode 100644 index 0000000..b8f3174 --- /dev/null +++ b/app-catalog/samples/wNet/src/main/java/com/wintmain/wNet/ui/fragment/HttpsCertificateFragment.kt @@ -0,0 +1,70 @@ +/* + * Copyright 2023-2025 wintmain + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.wintmain.wNet.ui.fragment + +import android.view.View +import com.drake.engine.base.EngineFragment +import com.drake.tooltip.toast +import com.wintmain.wNet.R +import com.wintmain.wNet.databinding.FragmentHttpsCertificateBinding +import lib.wintmain.libwNet.Get +import lib.wintmain.libwNet.okhttp.setSSLCertificate +import lib.wintmain.libwNet.okhttp.trustSSLCertificate +import lib.wintmain.libwNet.utils.scopeNetLife +import okhttp3.OkHttpClient + +class HttpsCertificateFragment : + EngineFragment(R.layout.fragment_https_certificate) { + + override fun initView() { + binding.btnTrustCertificate.setOnClickListener(this::trustAllCertificate) + binding.btnImportCertificate.setOnClickListener(this::importCertificate) + } + + override fun initData() { + } + + /** + * 信任全部证书 + * 大部分情况下还是建议在Application中配置一次全局的证书 + */ + private fun trustAllCertificate(view: View) { + scopeNetLife { + binding.tvResponse.text = Get("https://github.com/liangjingkanji/Net/") { + // 构建新的客户端 + okHttpClient = OkHttpClient.Builder().trustSSLCertificate().build() + }.await() + } + } + + /** + * 导入私有证书 + */ + private fun importCertificate(view: View) { + scopeNetLife { + Get("https://github.com/liangjingkanji/Net/") { + // 使用现在客户端 + setClient { + val privateCertificate = resources.assets.open("https.certificate") + setSSLCertificate(privateCertificate) + } + }.await() + }.catch { + toast("作者没有证书, 只是演示代码, O(∩_∩)O哈哈~") + } + } +} \ No newline at end of file diff --git a/app-catalog/samples/wNet/src/main/java/com/wintmain/wNet/ui/fragment/InterceptorFragment.kt b/app-catalog/samples/wNet/src/main/java/com/wintmain/wNet/ui/fragment/InterceptorFragment.kt new file mode 100644 index 0000000..0078ccc --- /dev/null +++ b/app-catalog/samples/wNet/src/main/java/com/wintmain/wNet/ui/fragment/InterceptorFragment.kt @@ -0,0 +1,38 @@ +/* + * Copyright 2023-2025 wintmain + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.wintmain.wNet.ui.fragment + +import com.drake.engine.base.EngineFragment +import com.wintmain.wNet.R +import com.wintmain.wNet.databinding.FragmentInterceptorBinding +import lib.wintmain.libwNet.Get +import lib.wintmain.libwNet.utils.scopeNetLife + +class InterceptorFragment : + EngineFragment(R.layout.fragment_interceptor) { + + override fun initView() { + scopeNetLife { + binding.tvFragment.text = Get(com.wintmain.wNet.constants.Api.TEXT) { + // 拦截器只支持全局, 无法单例, 请查看[com.wintmain.wNet.interceptor.NetInterceptor] + }.await() + } + } + + override fun initData() { + } +} diff --git a/app-catalog/samples/wNet/src/main/java/com/wintmain/wNet/ui/fragment/LimitedTimeFragment.kt b/app-catalog/samples/wNet/src/main/java/com/wintmain/wNet/ui/fragment/LimitedTimeFragment.kt new file mode 100644 index 0000000..55fd2f0 --- /dev/null +++ b/app-catalog/samples/wNet/src/main/java/com/wintmain/wNet/ui/fragment/LimitedTimeFragment.kt @@ -0,0 +1,58 @@ +/* + * Copyright 2023-2025 wintmain + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.wintmain.wNet.ui.fragment + +import android.util.Log +import android.view.View +import com.drake.engine.base.EngineFragment +import com.drake.tooltip.toast +import com.wintmain.wNet.R +import com.wintmain.wNet.databinding.FragmentLimitedTimeBinding +import kotlinx.coroutines.TimeoutCancellationException +import kotlinx.coroutines.withTimeout +import lib.wintmain.libwNet.Get +import lib.wintmain.libwNet.utils.scopeDialog + +class LimitedTimeFragment : + EngineFragment(R.layout.fragment_limited_time) { + override fun initView() { + binding.v = this + } + + override fun initData() { + } + + override fun onClick(v: View) { + scopeDialog { + // 当接口请求在100毫秒内没有完成会抛出异常TimeoutCancellationException + withTimeout(100) { + Get(com.wintmain.wNet.constants.Api.TEXT).await() + } + }.catch { + Log.e("日志", "catch", it) // catch无法接收到CancellationException异常 + }.finally { + Log.e( + "日志", + "finally", + it + ) // TimeoutCancellationException属于CancellationException子类故只会被finally接收到 + if (it is TimeoutCancellationException) { + toast("由于未在指定时间完成请求则取消请求") + } + } + } +} \ No newline at end of file diff --git a/app-catalog/samples/wNet/src/main/java/com/wintmain/wNet/ui/fragment/ParallelNetworkFragment.kt b/app-catalog/samples/wNet/src/main/java/com/wintmain/wNet/ui/fragment/ParallelNetworkFragment.kt new file mode 100644 index 0000000..c0411e4 --- /dev/null +++ b/app-catalog/samples/wNet/src/main/java/com/wintmain/wNet/ui/fragment/ParallelNetworkFragment.kt @@ -0,0 +1,47 @@ +/* + * Copyright 2023-2025 wintmain + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.wintmain.wNet.ui.fragment + +import com.drake.engine.base.EngineFragment +import com.wintmain.wNet.R +import com.wintmain.wNet.databinding.FragmentParallelNetworkBinding +import lib.wintmain.libwNet.Get +import lib.wintmain.libwNet.Post +import lib.wintmain.libwNet.Trace +import lib.wintmain.libwNet.utils.scopeNetLife + +class ParallelNetworkFragment : + EngineFragment(R.layout.fragment_parallel_network) { + + override fun initView() { + scopeNetLife { + + // 同时发起三个请求 + val deferred = Get(com.wintmain.wNet.constants.Api.TEXT) + val deferred1 = Post(com.wintmain.wNet.constants.Api.TEXT) + val deferred2 = Trace(com.wintmain.wNet.constants.Api.TEXT) + + // 同时接收三个请求数据 + deferred.await() + deferred1.await() + deferred2.await() + } + } + + override fun initData() { + } +} diff --git a/app-catalog/samples/wNet/src/main/java/com/wintmain/wNet/ui/fragment/PreviewCacheFragment.kt b/app-catalog/samples/wNet/src/main/java/com/wintmain/wNet/ui/fragment/PreviewCacheFragment.kt new file mode 100644 index 0000000..9252d8b --- /dev/null +++ b/app-catalog/samples/wNet/src/main/java/com/wintmain/wNet/ui/fragment/PreviewCacheFragment.kt @@ -0,0 +1,52 @@ +/* + * Copyright 2023-2025 wintmain + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.wintmain.wNet.ui.fragment + +import android.util.Log +import com.drake.engine.base.EngineFragment +import com.wintmain.wNet.R +import com.wintmain.wNet.databinding.FragmentReadCacheBinding +import lib.wintmain.libwNet.Get +import lib.wintmain.libwNet.cache.CacheMode +import lib.wintmain.libwNet.utils.scopeNetLife + +/** 预览缓存. 其实不仅仅是双重加载缓存/网络也可以用于回退请求, 可以执行两次作用域并且忽略preview{}内的所有错误 */ +class PreviewCacheFragment : + EngineFragment(R.layout.fragment_read_cache) { + + override fun initView() { + + // 一般用于秒开首页或者回退加载数据. 我们可以在preview{}只加载缓存. 然后再执行scopeNetLife来请求网络, 做到缓存+网络双重加载的效果 + + scopeNetLife { + // 然后执行这里(网络请求) + binding.tvFragment.text = Get(com.wintmain.wNet.constants.Api.TEXT) { + setCacheMode(CacheMode.WRITE) + }.await() + Log.d("日志", "网络请求") + }.preview(true) { + // 先执行这里(仅读缓存), 任何异常都视为读取缓存失败 + binding.tvFragment.text = Get(com.wintmain.wNet.constants.Api.TEXT) { + setCacheMode(CacheMode.READ) + }.await() + Log.d("日志", "读取缓存") + } + } + + override fun initData() { + } +} diff --git a/app-catalog/samples/wNet/src/main/java/com/wintmain/wNet/ui/fragment/PullRefreshFragment.kt b/app-catalog/samples/wNet/src/main/java/com/wintmain/wNet/ui/fragment/PullRefreshFragment.kt new file mode 100644 index 0000000..c8b736b --- /dev/null +++ b/app-catalog/samples/wNet/src/main/java/com/wintmain/wNet/ui/fragment/PullRefreshFragment.kt @@ -0,0 +1,53 @@ +/* + * Copyright 2023-2025 wintmain + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.wintmain.wNet.ui.fragment + +import com.drake.brv.utils.linear +import com.drake.brv.utils.setup +import com.drake.engine.base.EngineFragment +import com.wintmain.wNet.R +import com.wintmain.wNet.databinding.FragmentPullRefreshBinding +import com.wintmain.wNet.model.GameModel +import lib.wintmain.libwNet.Get +import lib.wintmain.libwNet.utils.scope + +class PullRefreshFragment : + EngineFragment(R.layout.fragment_pull_refresh) { + + override fun initView() { + binding.rv.linear().setup { + addType(R.layout.item_pull_list) + } + + binding.page.onRefresh { + scope { + val response = Get( + String.format( + com.wintmain.wNet.constants.Api.GAME, + index + ) + ).await() + addData(response.list) { + itemCount < response.total + } + } + }.autoRefresh() + } + + override fun initData() { + } +} diff --git a/app-catalog/samples/wNet/src/main/java/com/wintmain/wNet/ui/fragment/PushRefreshFragment.kt b/app-catalog/samples/wNet/src/main/java/com/wintmain/wNet/ui/fragment/PushRefreshFragment.kt new file mode 100644 index 0000000..ffeabd2 --- /dev/null +++ b/app-catalog/samples/wNet/src/main/java/com/wintmain/wNet/ui/fragment/PushRefreshFragment.kt @@ -0,0 +1,49 @@ +/* + * Copyright 2023-2025 wintmain + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.wintmain.wNet.ui.fragment + +import com.drake.brv.utils.linear +import com.drake.brv.utils.models +import com.drake.brv.utils.setup +import com.drake.engine.base.EngineFragment +import com.wintmain.wNet.R +import com.wintmain.wNet.databinding.FragmentPushRefreshBinding +import com.wintmain.wNet.model.GameModel +import lib.wintmain.libwNet.Get +import lib.wintmain.libwNet.utils.scope + +/** 本页面已禁用上拉加载(添加xml属性app:srlEnableLoadMore="false"), 只允许下拉刷新 */ +class PushRefreshFragment : + EngineFragment(R.layout.fragment_push_refresh) { + + override fun initView() { + binding.rv.linear().setup { + addType(R.layout.item_game) + } + + binding.page.onRefresh { + scope { + binding.rv.models = + Get(com.wintmain.wNet.constants.Api.GAME).await().list + } + // }.autoRefresh() // 首次下拉刷新 + }.showLoading() // 首次加载缺省页 + } + + override fun initData() { + } +} diff --git a/app-catalog/samples/wNet/src/main/java/com/wintmain/wNet/ui/fragment/ReadCacheFragment.kt b/app-catalog/samples/wNet/src/main/java/com/wintmain/wNet/ui/fragment/ReadCacheFragment.kt new file mode 100644 index 0000000..bd1649c --- /dev/null +++ b/app-catalog/samples/wNet/src/main/java/com/wintmain/wNet/ui/fragment/ReadCacheFragment.kt @@ -0,0 +1,45 @@ +/* + * Copyright 2023-2025 wintmain + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.wintmain.wNet.ui.fragment + +import com.drake.engine.base.EngineFragment +import com.wintmain.wNet.R +import com.wintmain.wNet.databinding.FragmentReadCacheBinding +import lib.wintmain.libwNet.Post +import lib.wintmain.libwNet.cache.CacheMode +import lib.wintmain.libwNet.utils.scopeNetLife + +/** + * 默认支持Http标准缓存协议 + * 如果需要自定义缓存模式来强制读写缓存,可以使用[CacheMode], 这会覆盖默认的Http标准缓存协议. + * 可以缓存任何数据, 包括文件. 并且遵守LRU缓存策略限制最大缓存空间 + */ +class ReadCacheFragment : EngineFragment(R.layout.fragment_read_cache) { + + override fun initView() { + scopeNetLife { + binding.tvFragment.text = + Post(com.wintmain.wNet.constants.Api.TEXT) { + setCacheMode(CacheMode.REQUEST_THEN_READ) // 请求网络失败会读取缓存, 请断网测试 + // setCacheKey("自定义缓存KEY") + }.await() + } + } + + override fun initData() { + } +} diff --git a/app-catalog/samples/wNet/src/main/java/com/wintmain/wNet/ui/fragment/SimpleRequestFragment.kt b/app-catalog/samples/wNet/src/main/java/com/wintmain/wNet/ui/fragment/SimpleRequestFragment.kt new file mode 100644 index 0000000..4073fbc --- /dev/null +++ b/app-catalog/samples/wNet/src/main/java/com/wintmain/wNet/ui/fragment/SimpleRequestFragment.kt @@ -0,0 +1,140 @@ +/* + * Copyright 2023-2025 wintmain + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +@file:Suppress("FunctionName") + +package com.wintmain.wNet.ui.fragment + +import android.view.Menu +import android.view.MenuInflater +import android.view.MenuItem +import com.drake.engine.base.EngineFragment +import com.wintmain.wNet.R +import com.wintmain.wNet.databinding.FragmentSimpleRequestBinding +import lib.wintmain.libwNet.Delete +import lib.wintmain.libwNet.Get +import lib.wintmain.libwNet.Head +import lib.wintmain.libwNet.Options +import lib.wintmain.libwNet.Patch +import lib.wintmain.libwNet.Post +import lib.wintmain.libwNet.Put +import lib.wintmain.libwNet.Trace +import lib.wintmain.libwNet.utils.scopeNetLife + +class SimpleRequestFragment : + EngineFragment(R.layout.fragment_simple_request) { + + override fun initView() { + setHasOptionsMenu(true) + } + + override fun initData() { + } + + private fun GET() { + scopeNetLife { + binding.tvFragment.text = Get(com.wintmain.wNet.constants.Api.TEXT).await() + } + } + + private fun POST() { + scopeNetLife { + binding.tvFragment.text = Post(com.wintmain.wNet.constants.Api.TEXT).await() + } + } + + private fun HEAD() { + scopeNetLife { + binding.tvFragment.text = Head(com.wintmain.wNet.constants.Api.TEXT).await() + } + } + + private fun PUT() { + scopeNetLife { + binding.tvFragment.text = Put(com.wintmain.wNet.constants.Api.TEXT).await() + } + } + + private fun PATCH() { + scopeNetLife { + binding.tvFragment.text = Patch(com.wintmain.wNet.constants.Api.TEXT).await() + } + } + + private fun DELETE() { + scopeNetLife { + binding.tvFragment.text = Delete(com.wintmain.wNet.constants.Api.TEXT).await() + } + } + + private fun TRACE() { + scopeNetLife { + binding.tvFragment.text = Trace(com.wintmain.wNet.constants.Api.TEXT).await() + } + } + + private fun OPTIONS() { + scopeNetLife { + binding.tvFragment.text = Options(com.wintmain.wNet.constants.Api.TEXT).await() + } + } + + /** + * 请求参数为JSON + */ + private fun JSON() { + val name = "金城武" + val age = 29 + val measurements = listOf(100, 100, 100) + + scopeNetLife { + + // 创建JSONObject对象 + // binding.tvFragment.text = Post(Api.BANNER) { + // json(JSONObject().run { + // put("name", name) + // put("age", age) + // put("measurements", JSONArray(measurements)) + // }) + // }.await() + + // 创建JSON + binding.tvFragment.text = Post(com.wintmain.wNet.constants.Api.TEXT) { + json("name" to name, "age" to age, "measurements" to measurements) // 同时支持Map集合 + }.await() + } + } + + override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) { + super.onCreateOptionsMenu(menu, inflater) + inflater.inflate(R.menu.menu_request_method, menu) + } + + override fun onOptionsItemSelected(item: MenuItem): Boolean { + when (item.itemId) { + R.id.get -> GET() + R.id.post -> POST() + R.id.head -> HEAD() + R.id.trace -> TRACE() + R.id.options -> OPTIONS() + R.id.delete -> DELETE() + R.id.put -> PUT() + R.id.patch -> PATCH() + R.id.json -> JSON() + } + return super.onOptionsItemSelected(item) + } +} diff --git a/app-catalog/samples/wNet/src/main/java/com/wintmain/wNet/ui/fragment/StateLayoutFragment.kt b/app-catalog/samples/wNet/src/main/java/com/wintmain/wNet/ui/fragment/StateLayoutFragment.kt new file mode 100644 index 0000000..fd912d5 --- /dev/null +++ b/app-catalog/samples/wNet/src/main/java/com/wintmain/wNet/ui/fragment/StateLayoutFragment.kt @@ -0,0 +1,38 @@ +/* + * Copyright 2023-2025 wintmain + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.wintmain.wNet.ui.fragment + +import com.drake.engine.base.EngineFragment +import com.wintmain.wNet.R +import com.wintmain.wNet.databinding.FragmentStateLayoutBinding +import lib.wintmain.libwNet.Get +import lib.wintmain.libwNet.utils.scope + +class StateLayoutFragment : + EngineFragment(R.layout.fragment_state_layout) { + + override fun initData() { + } + + override fun initView() { + binding.state.onRefresh { + scope { + binding.tvFragment.text = Get(com.wintmain.wNet.constants.Api.TEXT).await() + } + }.showLoading() + } +} diff --git a/app-catalog/samples/wNet/src/main/java/com/wintmain/wNet/ui/fragment/SuperIntervalFragment.kt b/app-catalog/samples/wNet/src/main/java/com/wintmain/wNet/ui/fragment/SuperIntervalFragment.kt new file mode 100644 index 0000000..11d5ccf --- /dev/null +++ b/app-catalog/samples/wNet/src/main/java/com/wintmain/wNet/ui/fragment/SuperIntervalFragment.kt @@ -0,0 +1,65 @@ +/* + * Copyright 2023-2025 wintmain + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.wintmain.wNet.ui.fragment + +import android.view.Menu +import android.view.MenuInflater +import android.view.MenuItem +import com.drake.engine.base.EngineFragment +import com.wintmain.wNet.R +import com.wintmain.wNet.databinding.FragmentSuperIntervalBinding +import lib.wintmain.libwNet.time.Interval +import java.util.concurrent.TimeUnit + +class SuperIntervalFragment : + EngineFragment(R.layout.fragment_super_interval) { + + private lateinit var interval: Interval // 轮询器 + + override fun initView() { + setHasOptionsMenu(true) + // 自定义计数器个数的轮询器, 当[start]]比[end]值大, 且end不等于-1时, 即为倒计时 + interval = Interval(0, 1, TimeUnit.SECONDS, 10).life(this) + // interval = Interval(1, TimeUnit.SECONDS) // 每秒回调一次, 不会自动结束 + interval.subscribe { + binding.tvFragment.text = it.toString() + }.finish { + binding.tvFragment.text = "计时完成" + }.start() + } + + override fun initData() { + } + + override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) { + super.onCreateOptionsMenu(menu, inflater) + inflater.inflate(R.menu.menu_interval, menu) + } + + override fun onOptionsItemSelected(item: MenuItem): Boolean { + when (item.itemId) { + R.id.start -> interval.start() + R.id.pause -> interval.pause() + R.id.resume -> interval.resume() + R.id.reset -> interval.reset() + R.id.switch_interval -> interval.switch() + R.id.stop -> interval.stop() + R.id.cancel -> interval.cancel() + } + return super.onOptionsItemSelected(item) + } +} diff --git a/app-catalog/samples/wNet/src/main/java/com/wintmain/wNet/ui/fragment/SwitchDispatcherFragment.kt b/app-catalog/samples/wNet/src/main/java/com/wintmain/wNet/ui/fragment/SwitchDispatcherFragment.kt new file mode 100644 index 0000000..25193a3 --- /dev/null +++ b/app-catalog/samples/wNet/src/main/java/com/wintmain/wNet/ui/fragment/SwitchDispatcherFragment.kt @@ -0,0 +1,48 @@ +/* + * Copyright 2023-2025 wintmain + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.wintmain.wNet.ui.fragment + +import com.drake.engine.base.EngineFragment +import com.wintmain.wNet.R +import com.wintmain.wNet.databinding.FragmentSwitchDispatcherBinding +import kotlinx.coroutines.launch +import lib.wintmain.libwNet.utils.scopeLife +import lib.wintmain.libwNet.utils.withIO +import lib.wintmain.libwNet.utils.withMain + +class SwitchDispatcherFragment : + EngineFragment(R.layout.fragment_switch_dispatcher) { + + override fun initView() { + scopeLife { + + // 点击函数名查看更多相关函数 + launch { + val data = withMain { + "异步调度器切换到主线程" + } + } + + val data = withIO { + "主线程切换到IO调度器" + } + } + } + + override fun initData() { + } +} diff --git a/app-catalog/samples/wNet/src/main/java/com/wintmain/wNet/ui/fragment/SyncRequestFragment.kt b/app-catalog/samples/wNet/src/main/java/com/wintmain/wNet/ui/fragment/SyncRequestFragment.kt new file mode 100644 index 0000000..5c953f0 --- /dev/null +++ b/app-catalog/samples/wNet/src/main/java/com/wintmain/wNet/ui/fragment/SyncRequestFragment.kt @@ -0,0 +1,46 @@ +/* + * Copyright 2023-2025 wintmain + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.wintmain.wNet.ui.fragment + +import com.drake.engine.base.EngineFragment +import com.wintmain.wNet.R +import com.wintmain.wNet.databinding.FragmentSyncRequestBinding +import lib.wintmain.libwNet.Net +import kotlin.concurrent.thread + +class SyncRequestFragment : + EngineFragment(R.layout.fragment_sync_request) { + + override fun initView() { + thread { // 网络请求不允许在主线程 + val result = try { + Net.post(com.wintmain.wNet.constants.Api.TEXT).execute() + } catch (e: Exception) { // 同步请求失败会导致崩溃要求捕获异常 + "请求错误 = ${e.message}" + } + + // val result = Net.post(Api.BANNER).toResult().getOrDefault("请求发生错误, 我这是默认值") + + binding.tvFragment?.post { // 这里用?号是避免界面被销毁依然赋值 + binding.tvFragment?.text = result // view要求在主线程更新 + } + } + } + + override fun initData() { + } +} \ No newline at end of file diff --git a/app-catalog/samples/wNet/src/main/java/com/wintmain/wNet/ui/fragment/TimingRequestFragment.kt b/app-catalog/samples/wNet/src/main/java/com/wintmain/wNet/ui/fragment/TimingRequestFragment.kt new file mode 100644 index 0000000..273c0b6 --- /dev/null +++ b/app-catalog/samples/wNet/src/main/java/com/wintmain/wNet/ui/fragment/TimingRequestFragment.kt @@ -0,0 +1,83 @@ +/* + * Copyright 2023-2025 wintmain + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.wintmain.wNet.ui.fragment + +import android.view.View +import com.drake.engine.base.EngineFragment +import com.wintmain.wNet.R +import com.wintmain.wNet.databinding.FragmentTimingRequestBinding +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.cancel +import kotlinx.coroutines.delay +import lib.wintmain.libwNet.Get +import lib.wintmain.libwNet.utils.scopeNetLife +import org.json.JSONObject +import kotlin.time.DurationUnit +import kotlin.time.toDuration + +class TimingRequestFragment : + EngineFragment(R.layout.fragment_timing_request) { + + private var scope: CoroutineScope? = null + + override fun initView() { + binding.v = this + } + + override fun initData() { + } + + override fun onClick(v: View) { + when (v) { + binding.btnRepeat -> repeatRequest() + binding.infinityRepeat -> infinityRequest() + binding.btnCancel -> scope?.cancel() + } + } + + /** 重复请求10次 */ + private fun repeatRequest() { + scope?.cancel() + scope = scopeNetLife { + // 每两秒请求一次, 总共执行10次 + repeat(20) { + delay(1000) + val data = + Get("http://api.k780.com/?app=life.time&appkey=10003&sign=b59bc3ef6191eb9f747dd4e83c99f2a4&format=json").await() + binding.tvContent.text = + JSONObject(data).getJSONObject("result").getString("datetime_2") + // 通过return@repeat可以终止循环 + } + } + } + + /** 无限次数请求 */ + private fun infinityRequest() { + scope?.cancel() + scope = scopeNetLife { + // 每两秒请求一次, 总共执行10次 + while (true) { + delay(1.toDuration(DurationUnit.SECONDS)) + val data = + Get("http://api.k780.com/?app=life.time&appkey=10003&sign=b59bc3ef6191eb9f747dd4e83c99f2a4&format=json").await() + binding.tvContent.text = + JSONObject(data).getJSONObject("result").getString("datetime_2") + // 通过break可以终止循环 + } + } + } +} \ No newline at end of file diff --git a/app-catalog/samples/wNet/src/main/java/com/wintmain/wNet/ui/fragment/UniqueRequestFragment.kt b/app-catalog/samples/wNet/src/main/java/com/wintmain/wNet/ui/fragment/UniqueRequestFragment.kt new file mode 100644 index 0000000..3112436 --- /dev/null +++ b/app-catalog/samples/wNet/src/main/java/com/wintmain/wNet/ui/fragment/UniqueRequestFragment.kt @@ -0,0 +1,47 @@ +/* + * Copyright 2023-2025 wintmain + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.wintmain.wNet.ui.fragment + +import android.util.Log +import com.drake.engine.base.EngineFragment +import com.wintmain.wNet.R +import com.wintmain.wNet.databinding.FragmentUniqueRequestBinding +import lib.wintmain.libwNet.Post +import lib.wintmain.libwNet.scope.AndroidScope +import lib.wintmain.libwNet.utils.scopeNetLife + +class UniqueRequestFragment : + EngineFragment(R.layout.fragment_unique_request) { + + private var scope: AndroidScope? = null + + override fun initView() { + binding.btnRequest.setOnClickListener { + binding.tvResult.text = "请求中" + scope?.cancel() // 如果存在则取消 + + scope = scopeNetLife { + val result = Post(com.wintmain.wNet.constants.Api.TEXT).await() + Log.d("日志", "请求到结果") // 你一直重复点击"发起请求"按钮会发现永远无法拿到请求结果, 因为每次发起新的请求会取消未完成的 + binding.tvResult.text = result + } + } + } + + override fun initData() { + } +} \ No newline at end of file diff --git a/app-catalog/samples/wNet/src/main/java/com/wintmain/wNet/ui/fragment/UploadFileFragment.kt b/app-catalog/samples/wNet/src/main/java/com/wintmain/wNet/ui/fragment/UploadFileFragment.kt new file mode 100644 index 0000000..ae3063b --- /dev/null +++ b/app-catalog/samples/wNet/src/main/java/com/wintmain/wNet/ui/fragment/UploadFileFragment.kt @@ -0,0 +1,98 @@ +/* + * Copyright 2023-2025 wintmain + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.wintmain.wNet.ui.fragment + +import android.app.Activity +import android.net.Uri +import com.drake.engine.base.EngineFragment +import com.wintmain.wNet.R +import com.wintmain.wNet.contract.AlbumSelectContract +import com.wintmain.wNet.databinding.FragmentUploadFileBinding +import lib.wintmain.libwNet.Post +import lib.wintmain.libwNet.component.Progress +import lib.wintmain.libwNet.interfaces.ProgressListener +import lib.wintmain.libwNet.utils.TipUtils +import lib.wintmain.libwNet.utils.scopeNetLife +import java.io.File + +class UploadFileFragment : + EngineFragment(R.layout.fragment_upload_file) { + private val albumSelectLauncher = registerForActivityResult(AlbumSelectContract()) { + when (it.code) { + Activity.RESULT_CANCELED -> TipUtils.toast("取消图片选择") + Activity.RESULT_OK -> uploadUri(it.uri) + } + } + + override fun initView() { + binding.btnFile.setOnClickListener { + uploadFile() + } + binding.btnUri.setOnClickListener { + albumSelectLauncher.launch(null) + } + } + + private fun uploadFile() { + scopeNetLife { + Post(com.wintmain.wNet.constants.Api.UPLOAD) { + param("file", getRandomFile()) + addUploadListener(object : ProgressListener() { + override fun onProgress(p: Progress) { + binding.seek.post { + binding.seek.progress = p.progress() + binding.tvProgress.text = + "上传进度: ${p.progress()}% 上传速度: ${p.speedSize()} " + "\n\n文件大小: ${p.totalSize()} 已上传: ${p.currentSize()} 剩余大小: ${p.remainSize()}" + "\n\n已使用时间: ${p.useTime()} 剩余时间: ${p.remainTime()}" + } + } + }) + }.await() + } + } + + private fun uploadUri(uri: Uri?) { + scopeNetLife { + Post(com.wintmain.wNet.constants.Api.UPLOAD) { + param("file", uri) + addUploadListener(object : ProgressListener() { + override fun onProgress(p: Progress) { + binding.seek.post { + binding.seek.progress = p.progress() + binding.tvProgress.text = + "上传进度: ${p.progress()}% 上传速度: ${p.speedSize()} " + "\n\n文件大小: ${p.totalSize()} 已上传: ${p.currentSize()} 剩余大小: ${p.remainSize()}" + "\n\n已使用时间: ${p.useTime()} 剩余时间: ${p.remainTime()}" + } + } + }) + }.await() + } + } + + /** 生成指定大小的随机文件 */ + private fun getRandomFile(): File { + val file = File(requireContext().filesDir, "uploadFile.apk") + // 本演示项目的Mock服务不支持太大的文件, 可能会OOM溢出, 实际接口请求不存在 + com.wintmain.wNet.utils.RandomFileUtils.createRandomFile( + file, + 30, + com.wintmain.wNet.utils.RandomFileUtils.FileSizeUnit.MB + ) + return file + } + + override fun initData() { + } +} diff --git a/app-catalog/samples/wNet/src/main/java/com/wintmain/wNet/ui/fragment/ViewModelRequestFragment.kt b/app-catalog/samples/wNet/src/main/java/com/wintmain/wNet/ui/fragment/ViewModelRequestFragment.kt new file mode 100644 index 0000000..406e03f --- /dev/null +++ b/app-catalog/samples/wNet/src/main/java/com/wintmain/wNet/ui/fragment/ViewModelRequestFragment.kt @@ -0,0 +1,53 @@ +/* + * Copyright 2023-2025 wintmain + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.wintmain.wNet.ui.fragment + +import androidx.fragment.app.viewModels +import com.drake.engine.base.EngineFragment +import com.wintmain.wNet.R +import com.wintmain.wNet.databinding.FragmentViewModelRequestBinding +import com.wintmain.wNet.utils.HttpUtils +import com.wintmain.wNet.vm.UserViewModel +import lib.wintmain.libwNet.utils.scopeNetLife + +class ViewModelRequestFragment : + EngineFragment(R.layout.fragment_view_model_request) { + + private val userViewModel: UserViewModel by viewModels() // 创建ViewModel + + override fun initView() { + + // 直接将用户信息绑定到视图上 + binding.lifecycleOwner = this + binding.m = userViewModel + + // 动作开始拉取服务器数据 + binding.btnFetchUserinfo.setOnClickListener { + userViewModel.fetchUserInfo() + } + } + + override fun initData() { + + scopeNetLife { + val configAsync = HttpUtils.getConfigAsync(this) + // 经常使用的请求可以封装函数 + val userInfo = HttpUtils.getUser() + configAsync.await() // 实际上在getUser之前就发起请求, 此处只是等待结果, 这就是并发请求 + } + } +} \ No newline at end of file diff --git a/app-catalog/samples/wNet/src/main/java/com/wintmain/wNet/ui/fragment/converter/BaseConvertFragment.kt b/app-catalog/samples/wNet/src/main/java/com/wintmain/wNet/ui/fragment/converter/BaseConvertFragment.kt new file mode 100644 index 0000000..7cd6fe3 --- /dev/null +++ b/app-catalog/samples/wNet/src/main/java/com/wintmain/wNet/ui/fragment/converter/BaseConvertFragment.kt @@ -0,0 +1,47 @@ +/* + * Copyright 2023-2025 wintmain + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.wintmain.wNet.ui.fragment.converter + +import android.os.Bundle +import android.view.Menu +import android.view.MenuInflater +import android.view.MenuItem +import androidx.annotation.LayoutRes +import androidx.databinding.ViewDataBinding +import androidx.navigation.fragment.findNavController +import androidx.navigation.ui.onNavDestinationSelected +import com.drake.engine.base.EngineFragment +import com.wintmain.wNet.R + +abstract class BaseConvertFragment(@LayoutRes contentLayoutId: Int = 0) : + EngineFragment(contentLayoutId) { + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setHasOptionsMenu(true) + } + + override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) { + super.onCreateOptionsMenu(menu, inflater) + inflater.inflate(R.menu.menu_converter, menu) + } + + override fun onOptionsItemSelected(item: MenuItem): Boolean { + item.onNavDestinationSelected(findNavController()) + return true + } +} diff --git a/app-catalog/samples/wNet/src/main/java/com/wintmain/wNet/ui/fragment/converter/FastJsonConvertFragment.kt b/app-catalog/samples/wNet/src/main/java/com/wintmain/wNet/ui/fragment/converter/FastJsonConvertFragment.kt new file mode 100644 index 0000000..dad28a0 --- /dev/null +++ b/app-catalog/samples/wNet/src/main/java/com/wintmain/wNet/ui/fragment/converter/FastJsonConvertFragment.kt @@ -0,0 +1,44 @@ +/* + * Copyright 2023-2025 wintmain + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.wintmain.wNet.ui.fragment.converter + +import com.wintmain.wNet.R +import com.wintmain.wNet.converter.FastJsonConverter +import com.wintmain.wNet.databinding.FragmentCustomConvertBinding +import com.wintmain.wNet.model.GameModel +import lib.wintmain.libwNet.Get +import lib.wintmain.libwNet.utils.scopeNetLife + +class FastJsonConvertFragment : + BaseConvertFragment(R.layout.fragment_custom_convert) { + + override fun initView() { + binding.tvConvertTip.text = """ + 1. 阿里巴巴出品的Json解析库 + 2. 引入kotlin-reflect库可以支持kotlin默认值 + """.trimIndent() + + scopeNetLife { + binding.tvFragment.text = Get(com.wintmain.wNet.constants.Api.GAME) { + converter = FastJsonConverter() // 单例转换器, 此时会忽略全局转换器 + }.await().list[0].name + } + } + + override fun initData() { + } +} diff --git a/app-catalog/samples/wNet/src/main/java/com/wintmain/wNet/ui/fragment/converter/GsonConvertFragment.kt b/app-catalog/samples/wNet/src/main/java/com/wintmain/wNet/ui/fragment/converter/GsonConvertFragment.kt new file mode 100644 index 0000000..f74efad --- /dev/null +++ b/app-catalog/samples/wNet/src/main/java/com/wintmain/wNet/ui/fragment/converter/GsonConvertFragment.kt @@ -0,0 +1,46 @@ +/* + * Copyright 2023-2025 wintmain + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.wintmain.wNet.ui.fragment.converter + +import com.wintmain.wNet.R +import com.wintmain.wNet.converter.GsonConverter +import com.wintmain.wNet.databinding.FragmentCustomConvertBinding +import com.wintmain.wNet.model.GameModel +import lib.wintmain.libwNet.Get +import lib.wintmain.libwNet.utils.scopeNetLife + +class GsonConvertFragment : + BaseConvertFragment(R.layout.fragment_custom_convert) { + + override fun initView() { + binding.tvConvertTip.text = """ + 1. Google官方出品 + 2. Json解析库Java上的老牌解析库 + 3. 不支持Kotlin构造参数默认值 + 4. 支持动态解析 + """.trimIndent() + + scopeNetLife { + binding.tvFragment.text = Get(com.wintmain.wNet.constants.Api.GAME) { + converter = GsonConverter() // 单例转换器, 此时会忽略全局转换器, 在Net中可以直接解析List等嵌套泛型数据 + }.await().list[0].name + } + } + + override fun initData() { + } +} diff --git a/app-catalog/samples/wNet/src/main/java/com/wintmain/wNet/ui/fragment/converter/MoshiConvertFragment.kt b/app-catalog/samples/wNet/src/main/java/com/wintmain/wNet/ui/fragment/converter/MoshiConvertFragment.kt new file mode 100644 index 0000000..a5c72f6 --- /dev/null +++ b/app-catalog/samples/wNet/src/main/java/com/wintmain/wNet/ui/fragment/converter/MoshiConvertFragment.kt @@ -0,0 +1,47 @@ +/* + * Copyright 2023-2025 wintmain + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.wintmain.wNet.ui.fragment.converter + +import com.wintmain.wNet.R +import com.wintmain.wNet.converter.MoshiConverter +import com.wintmain.wNet.databinding.FragmentCustomConvertBinding +import com.wintmain.wNet.model.GameModel +import lib.wintmain.libwNet.Get +import lib.wintmain.libwNet.utils.scopeNetLife + +class MoshiConvertFragment : + BaseConvertFragment(R.layout.fragment_custom_convert) { + + override fun initView() { + binding.tvConvertTip.text = """ + 1. Square出品的JSON解析库 + 2. 支持Kotlin构造默认值 + 3. 具备注解和反射两种使用方式 + 4. 非可选类型反序列化时赋值Null会抛出异常 + 5, 不支持动态解析 + """.trimIndent() + + scopeNetLife { + binding.tvFragment.text = Get(com.wintmain.wNet.constants.Api.GAME) { + converter = MoshiConverter() // 单例转换器, 此时会忽略全局转换器 + }.await().list[0].name + } + } + + override fun initData() { + } +} diff --git a/app-catalog/samples/wNet/src/main/java/com/wintmain/wNet/ui/fragment/converter/SerializationConvertFragment.kt b/app-catalog/samples/wNet/src/main/java/com/wintmain/wNet/ui/fragment/converter/SerializationConvertFragment.kt new file mode 100644 index 0000000..1eb231c --- /dev/null +++ b/app-catalog/samples/wNet/src/main/java/com/wintmain/wNet/ui/fragment/converter/SerializationConvertFragment.kt @@ -0,0 +1,51 @@ +/* + * Copyright 2023-2025 wintmain + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.wintmain.wNet.ui.fragment.converter + +import com.wintmain.wNet.R +import com.wintmain.wNet.converter.SerializationConverter +import com.wintmain.wNet.databinding.FragmentCustomConvertBinding +import com.wintmain.wNet.model.GameModel +import lib.wintmain.libwNet.Get +import lib.wintmain.libwNet.utils.scopeNetLife + +class SerializationConvertFragment : + BaseConvertFragment(R.layout.fragment_custom_convert) { + + override fun initView() { + binding.tvConvertTip.text = """ + 1. kotlin官方出品, 推荐使用 + 2. kotlinx.serialization 是Kotlin上是最完美的序列化工具 + 3. 支持多种反序列化数据类型Pair/枚举/Map... + 4. 多配置选项 + 5. 支持动态解析 + 6. 支持ProtoBuf/CBOR/JSON等数据 + """.trimIndent() + + scopeNetLife { + val data = Get(com.wintmain.wNet.constants.Api.GAME) { + // 该转换器直接解析JSON中的data字段, 而非返回的整个JSON字符串 + converter = SerializationConverter() // 单例转换器, 此时会忽略全局转换器 + }.await() + + binding.tvFragment.text = data.list[0].name + } + } + + override fun initData() { + } +} diff --git a/app-catalog/samples/wNet/src/main/java/com/wintmain/wNet/utils/AESUtils.kt b/app-catalog/samples/wNet/src/main/java/com/wintmain/wNet/utils/AESUtils.kt new file mode 100644 index 0000000..ff8bf43 --- /dev/null +++ b/app-catalog/samples/wNet/src/main/java/com/wintmain/wNet/utils/AESUtils.kt @@ -0,0 +1,50 @@ +/* + * Copyright 2023-2025 wintmain + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.wintmain.wNet.utils + +import okio.ByteString +import okio.ByteString.Companion.decodeHex +import javax.crypto.Cipher +import javax.crypto.spec.SecretKeySpec + +object AESUtils { + + private const val KEY = "123456789" + private const val IV = "123456789" + + fun encrypt(data: String): String { + val key = KEY.decodeHex() + val iv = IV.decodeHex() + val cipher = Cipher.getInstance("AES/CBC/PKCS5Padding") + val keySpec = SecretKeySpec(key.toByteArray(), "AES") + val ivSpec = javax.crypto.spec.IvParameterSpec(iv.toByteArray()) + cipher.init(Cipher.ENCRYPT_MODE, keySpec, ivSpec) + val encrypted = cipher.doFinal(data.toByteArray()) + return ByteString.of(*encrypted).hex() + } + + fun decrypt(data: String): String { + val key = KEY.decodeHex() + val iv = IV.decodeHex() + val cipher = Cipher.getInstance("AES/CBC/PKCS5Padding") + val keySpec = SecretKeySpec(key.toByteArray(), "AES") + val ivSpec = javax.crypto.spec.IvParameterSpec(iv.toByteArray()) + cipher.init(Cipher.DECRYPT_MODE, keySpec, ivSpec) + val encrypted = cipher.doFinal(data.decodeHex().toByteArray()) + return String(encrypted) + } +} \ No newline at end of file diff --git a/app-catalog/samples/wNet/src/main/java/com/wintmain/wNet/utils/HttpUtils.kt b/app-catalog/samples/wNet/src/main/java/com/wintmain/wNet/utils/HttpUtils.kt new file mode 100644 index 0000000..31762e3 --- /dev/null +++ b/app-catalog/samples/wNet/src/main/java/com/wintmain/wNet/utils/HttpUtils.kt @@ -0,0 +1,49 @@ +/* + * Copyright 2023-2025 wintmain + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.wintmain.wNet.utils + +import com.wintmain.wNet.model.ConfigModel +import com.wintmain.wNet.model.UserInfoModel +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.coroutineScope +import lib.wintmain.libwNet.Get + +/** + * 常用的请求方法建议写到一个工具类中 + */ +object HttpUtils { + + /** + * 获取配置信息 + * + * 本方法需要再调用await()才会返回结果, 属于异步方法 + */ + fun getConfigAsync(scope: CoroutineScope) = + scope.Get(com.wintmain.wNet.constants.Api.CONFIG) + + /** + * 获取用户信息 + * 阻塞返回可直接返回结果 + * + * @param userId 如果为空表示请求自身用户信息 + */ + suspend fun getUser(userId: String? = null) = coroutineScope { + Get(com.wintmain.wNet.constants.Api.USER_INFO) { + param("userId", userId) + }.await() + } +} \ No newline at end of file diff --git a/app-catalog/samples/wNet/src/main/java/com/wintmain/wNet/utils/RandomFileUtils.kt b/app-catalog/samples/wNet/src/main/java/com/wintmain/wNet/utils/RandomFileUtils.kt new file mode 100644 index 0000000..ffb5f1d --- /dev/null +++ b/app-catalog/samples/wNet/src/main/java/com/wintmain/wNet/utils/RandomFileUtils.kt @@ -0,0 +1,84 @@ +/* + * Copyright 2023-2025 wintmain + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.wintmain.wNet.utils + +import okhttp3.internal.closeQuietly +import java.io.File +import java.io.FileOutputStream +import java.io.IOException +import java.nio.ByteBuffer + +object RandomFileUtils { + + /** + * 创建指定大小随机内容的文件, 创建失败返回false + * 如果已经存在指定大小且同名的文件将不再创建 + */ + fun createRandomFile( + file: File, + size: Long, + unit: com.wintmain.wNet.utils.RandomFileUtils.FileSizeUnit + ): Boolean { + val fileLength = unit.getBytes(size) + var fos: FileOutputStream? = null + try { + // 已经存在指定大小且同名的文件结束函数 + if (!file.createNewFile() && file.length() == fileLength) { + return false + } + var batchSize = + fileLength.coerceAtMost(com.wintmain.wNet.utils.RandomFileUtils.FileSizeUnit.KB.length()) + batchSize = + batchSize.coerceAtMost(com.wintmain.wNet.utils.RandomFileUtils.FileSizeUnit.MB.length()) + val count = fileLength / batchSize + val last = fileLength % batchSize + fos = FileOutputStream(file) + val fileChannel = fos.channel + for (i in 0 until count) { + val buffer = ByteBuffer.allocate(batchSize.toInt()) + fileChannel.write(buffer) + } + if (last != 0L) { + val buffer = ByteBuffer.allocate(last.toInt()) + fileChannel.write(buffer) + } + fos.close() + return true + } catch (e: IOException) { + e.printStackTrace() + } finally { + fos?.closeQuietly() + } + return false + } + + enum class FileSizeUnit { + KB, MB, GB; + + fun getBytes(size: Long): Long { + return size * length() + } + + fun length(): Long { + return when (this) { + com.wintmain.wNet.utils.RandomFileUtils.FileSizeUnit.KB -> 1024 + com.wintmain.wNet.utils.RandomFileUtils.FileSizeUnit.MB -> 1024 * 1024 + com.wintmain.wNet.utils.RandomFileUtils.FileSizeUnit.GB -> 1024 * 1024 * 1024 + } + } + } +} \ No newline at end of file diff --git a/app-catalog/samples/wNet/src/main/java/com/wintmain/wNet/vm/UserViewModel.kt b/app-catalog/samples/wNet/src/main/java/com/wintmain/wNet/vm/UserViewModel.kt new file mode 100644 index 0000000..ae38cb7 --- /dev/null +++ b/app-catalog/samples/wNet/src/main/java/com/wintmain/wNet/vm/UserViewModel.kt @@ -0,0 +1,55 @@ +/* + * Copyright 2023-2025 wintmain + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.wintmain.wNet.vm + +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.ViewModel +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.coroutineScope +import lib.wintmain.libwNet.Get +import lib.wintmain.libwNet.scopeNetLife + +/** + * 不要将请求结果抛来抛去, 增加代码复杂度 + */ +class UserViewModel : ViewModel() { + + // 用户信息 + var userInfo: MutableLiveData = MutableLiveData() + + /** + * 使用LiveData接受请求结果, 将该liveData直接使用DataBinding绑定到页面上, 会在请求成功自动更新视图 + */ + fun fetchUserInfo() = scopeNetLife { + userInfo.value = Get(com.wintmain.wNet.constants.Api.GAME).await() + } + + /** + * 开始非阻塞异步任务 + * 返回Deferred, 调用await()才会返回结果 + */ + fun fetchList(scope: CoroutineScope) = scope.Get(com.wintmain.wNet.constants.Api.TEXT) + + /** + * 开始阻塞异步任务 + * 直接返回结果 + */ + suspend fun fetchPrecessData() = coroutineScope { + val response = Get(com.wintmain.wNet.constants.Api.TEXT).await() + response + "处理数据" + } +} \ No newline at end of file diff --git a/app-catalog/samples/wNet/src/main/res/drawable/bg_card.xml b/app-catalog/samples/wNet/src/main/res/drawable/bg_card.xml new file mode 100644 index 0000000..3f6cd2e --- /dev/null +++ b/app-catalog/samples/wNet/src/main/res/drawable/bg_card.xml @@ -0,0 +1,25 @@ + + + + + + + + + \ No newline at end of file diff --git a/app-catalog/samples/wNet/src/main/res/drawable/bg_empty.webp b/app-catalog/samples/wNet/src/main/res/drawable/bg_empty.webp new file mode 100644 index 0000000000000000000000000000000000000000..4b69ddc52b4897e914dc4faa217cf5c611417743 GIT binary patch literal 17000 zcmV)rK$*W%Nk&FsLI40)MM6+kP&iCeLI40S9YeAZ^@@YGjRa}UANK#wb-Dy1f`Qw% zjnuuJT<#OjFX41kM5=tHOwHH`n)y6%?4F|Gubn)1L4`XbTDql&LPR@B^FY$^rN-aN z@ZA2}8Rw5iY=0;$1^b7)I++G1d8@$ttdSF8jU2wPTZR8GsY*3Aw(DU54JaOVgoJf0hFIgZ`NG`;M z2w5Nsg^NNCB2oy7;6fxxL<*5W5QHMRh!oXDmP`~QE`y*m;`GqX0g_kKV19clNRcUJoFJ`dMg?aaL8%&S`t)gTB)36>tm z4xPsiSP5e}MB!3~-Va4ImvqNC^iTweDG?F?05qlMwQ!eh+g;XWd&+iWnwREfd&`>b zE!$qUF5`^u(}HcgZrj{-HFaJN$5q@|1VP{e{o(&*4vNfP$v$NL{f&&+-^>R4pJ$`` z61@Ci)-LPrL*s1s&-3zvp=#E+v6Oyirp?O;GS@zKV`&+oAIQrFvbc=8F_A2VmkIQP zHYU}N=4AnM$kmA>qs-d!G62mmyRb32Y)dn9_%|E5QN(Nl2Y*lZcMC=TWR{CVzwF{h zF>@gt`t@rHM*j;3ewrpbC>R-D!#M04@NN_}E5l*mf2tLZfl26Z5?nYk4-WhKW`(1F zj>En=`NA=)%VD2pEgawBz%QFE_;`1n1HUY-V9b?<|90~QW7dI#|4I7oLXT`l({b>Z zSrv+B*~k8wF-0c6sfMQIfFS!(;L-13AB%0{^%!cJWVojq5}J5rOOwABFTdxWW=w9H zrfK5s9#2TI~8UGTU3?t^QGVS2OZ*LNIIg?Ebi)kd5^p%yF}?g^OTjCDsp` zO`I#v-p!TFCcTwOvBw3CAc6lb9?$(pk0Z_1IZ{xutic}ZPUmh={fdGTy%5x?ayFBvZ_l3EV)lG4&5xx!)I zZlUC`q{j_^%D{nMqLWOO@`#&yVjS#UyGuU}Pol<1;}-upj8E+$iTh}+p_nr<4=b&IY}MNfrA4i|`VfRub*6h|!no+;PpQy0^P z_B6E`FbeI3@*G4DNOm|!&Qroue)bFP`FiKUD{-$Xz zO9a?b7YTQp+cr9lzCX9e`{~h7X?E{-r`onl=|0l592oTkzi|l%+p4S6-EQ0Nn`ybR z{{@>m+nv7jJ1=16Wdc*WH?-ZusdGym%3s5#v)eZ6{6l757Vw>Dr|qs?q%gtJMBz8L z-H*!xmqh9_QAZo?Um!ox@mz4NZM%`Y3?PbV+sI+U1L{_{_x_NBzrTlfx7v3mJ)#5O zZl{Vk_?r%DFQ5FR{8UWa#ZPl`=odA$(d)?%>GgZtpK;*#rz~A^7wVoba8QTZ=o1Hi z(`Ic%Jy7U{f-Z^!zlYmsrN}{b4cfNH&SBs0CALvlk%PKWyltoDIP8m_w6_*Hs2TpY zy-ML?k(^74Fp>*&+&GKlmmkQcd4*RC{+IC8?z(I9m+h~acziT6GI~7>q zprT)GRLh}X^r4OZO@2uGo@^s`4*j~$Z5wG!d`MAp+ueI5{9jfQO}3{eJ)(;xB3wJ2 z#NpqRW7~GGPdISpsI`qYmjy0q@7A@KUu-qe;dBezHge(R0Izo^+6cQc!P`+m+fJqM z@_-qUXtBNdrKRQ}^IW&FZKJAXgZoflAQTO@?a8T&J7bRH2k&;#Z9A2*!^;N#9zLCY zbK5==WnKBjMU1b$J&lNhe|k;Ze*1G?RuE}K-y>=}&JAzHT>clDiI*X0Zgf>e&<#fb?Fl!q z4a(I$M83+)2Q>XN@B>d}NpOI#Rgf0sWdX0M09=v@A@x-5unf?v%{&4|0V+bxVYFuF zun+GbQ3`q-Z>(~-w+WR}0-VVmIn>h(@KeeGzmzzKc)X4cQkKV+H-~ueOiF}xnw5jQ zf!Ru#AviTs3huRq!(}NKV$hSrI+Y})!*qQnsJG~XV=3P$&Bo!JikB+D|6Dqp{}!+y zm4N*PBM#&~mWm)Dl)wAIW2wrDy2n8rbV+rf^wki4G0C=62)}4X4&Kg5r5@8<9J&qd zq*^$o#5i!n3sNy$58bW3&_k*Qd-J*+v?(8{oI>YT9I~lEsot??IABvDVgRJ501nrV ziV0TWG6!q9oR|S`hU;EXUJx@39&o6Ji(&{&=Rghqh$&Q&rU}uR83)GR=|8 zg4TlRQS?-y7<~kjF;^8{^)*&wef|bMd;uTv)lKjc3oz^a;E{^MTNnwKR4{rCZ9!W} zgo+-J&``{gzjaMMFptQcZhS=*Kz2FWJo4LPT(iw< zh*9pjk1equ-7pVEj*6vTdI+OH+m{Q7keJTY#G~=o^y&@)xMjQXa&Kb3I00he8(ht$*lTtf@9}7wnRM@;YJoATrHHhc zGqlri_LiBlMYTVP_u2Q>0U}(AtijJ;c&XEi zWNLBr6xjDh3LiN5&7FtDF>vYRyk>n3#^>VTH#H0!0dkHX!PAjiU5yW6)WxaUT5*uO zemGL9rNJYWH5cb$B#M*3_&Xp}LxWX_xHumXM|lB0=T4fob_PS0lM`FSS#bRmC0;jU z0|s22$6pY4!BhB!7guX#Fp2=m#ff?1G%uaZW$R=x8Y02L`M{RA4N|YabToFgG5Gcs z2PftLo?F`xuU|V(I<+`@s^gBu^H=z*1c2O|O8f(HpO;jeJFkfEImT@%AWnjy(`INX7HX)MTWw%m7B+`0QjFe&5u?d^vSnv~c*jdu(_se-C z|NP&p@x1z3x8`f&TvyB^*i}-q^hb2MmFzNYoW>2u#6(xP7`4RHX4#`~rD zhQkg^&2&P%;)TJ(dbZIB9#tLgO7s-pfbLw1>zKFUm$MSSig(~iJsVc=hxuDaWiESE zJmhZ8!8MGgg)%oKl!%w$>VhfPFzcA=qTG)J@sp#22eoXXm7hB(Zxe5U?_V=X?I`iw z3NB;j%kb%X?1cerfp6HL3NaR3Mv5OAHafo-T$A&IE#3<O1yM%I2g@1dDz@Yfrrh!qAZMiJIWGS55e28QtHckL z_cifwc)^5?pb}k?n)!F)LpNe16VHZ9Dz`_5_fVKkaLNt2c}hG9DromE1{McOAB5*E zn#Mt6EE5r&F26Uz)#t>Uu<=pCy+Ag2Tt)Myn-99_UIf1>dvM~Ku>jazo>5bLS4yx+5r>lF#Om|@HW+|VH zt`IN7ENV3tM$f80ao4A{h7-M(aCoybh;kXMeI+|5~>q8rq7iontw|i zkhzu6-BxbR#DSS24#*z}-3bzi)7b0anz$h0mk^#H`5mT=yF?t2n*M~49+GkgCJgKW z$OXAzBE*OIfjN`KQz9E{{a1190OUP44M0Vt;PHsMG_v`~?$)3K0k7S5g@CC$jhy-jMQMq#Zop;|fCbd<~OADsPEw zeo&4KoJ68Da0D}$44_LWB`5L$a%L4`C& z{6O}D&{pVVsB_gnky08wqT-nZ zo)9wmR^*^{CG>RwIs6>jF$uu7P)SjKuCr0Ulz9-Dg;$bH0rv})RQ0A)-jq!mKr$TC zj8s@b5*m43Z#E?-ucSdFP!&>sVpDoL8?}=~8b|^W?0?sNN~W_cy66X#Pmz%V`yc$K z)BDmQ0r#Yo29q2jK=%(AD?~Dpjs8$UAPJ@tU;qI^Bp=FN?_%g74JcV5zy-YD5*itc ze4{->8BBufzN&R9L3>{B>-<4o!fqN^lE3Q!uc2fYp$`&%kKLkLU*1atOjL;X*98i( zLL=KuI{5yYOw$096S*vV9+~=jtD~=KZbTjeE<`3x=leMU3zak_L}`{(e(_8jE7$~{ z`hI#UxFSR{?Mry0-k5bP@ogG#f)vp5es#>L$xq7 z{Hj<3%mjV(Ymt2=4M6#x_wOD;Cf$yRWbNK3=g3W&K{NZib7fRXgCi_GrS-e@?rokyHG+_K>aD#C0dynwO;4b0AQNNFl30tH-tCe?SGki^K^?Om@j!Ip2 zh()CIWokl>1n_r~t}n6+;;j)kxEk*D5=g+Y)MLXu@{JiKV-?3f2UnyX`)MA`SR6h9 zLY^CvFQXP$*=vru7i*{c-Fo30xK8`Ra9WkB_+~@&wJ!tVFmyo_E71p%Ut#0WppJZ^4vcS$n` zuXL#=>M5{_ybC7e%3>eA%kP;RKcRUmqBo6#u}lC9#`}d*#!dCE4t0l0%KSwUl3xCg zA_O;PVB+V3DV3K1<*1;2K>raENpwI4z{u-DW!(>%o zI}6)nn3!%f#Mklell$UxH&rEA|E z*k`65C++;rifDpA5q(LH#%E!+c4zbrBO)#qtvM6 zk58=ZC=d%NCQ7fuNHWXp0j$+GLg5bBtO1F}7SQo@H3_S!Nr@mfe}NS+7Da~gTND`_ zOb_lXxThnjO6(`Vc(niX;iRPyQa1Vr7oYZBOL8BjMMd&a--^awx4kG9kz~JVoKq*t z`(S6OqW~P$GKzlC05ssAZSkm9jIf4G49DGD8H&inn)!cXOy&7X(`QMarZ=#OouoQ- zWLl|^6fw94HtIk*xRA@=>eg+&DHt&w4F7p;(J28obO0^fhy7x-Y5M}PX;)sa550CG zk~lH9-!4o$xxqXDZsdmY^l%Pu_}|8}z7nU@8M3XpJAf86Gk!7ps=ipHxrCaS_eF*- zjN7dS9#xVG0>%7o3cG~UHVyU0|7p1i6_&Z&AsEvE#Gq*nUcCEdB4IOz+(bZRBHsON>Fp5-`27GXt+!MKrg zvf1Hx)!2#A-!CD^v8C!z3%co%AwVR?U0TE7OwpsxbmwBg!R=CKs$Vwl z3FDF|1rUZSW);YMr!@<4BjU(lrK=)>oJRD0GWS!!klWuho}H8i^nwY~L~eD~te@T1 zBYQVeH%ePXkTWH~;cQREYL}K84RCtiJ%R-tKoq&WNw+oJ>-jtyIx}UV?h2f&UxR(> zTe52vVDr=;{{W6=PD*VGwlytTsvT@lapN;trm4msxHB>AGiYW#n}VajK&Q$Psk4V_ z>_C+gyTl%XFjZ(;bFSkRDeDnCB9|szBtX~dLcI#0*O!R2X%}j(_z-w@)0H2TFkPr% zBpMS3z02%&>P)GCT$VmG;sq(H-_oeFm+-Uvu1cuzUCJzk7aD$K$_zA%dRN)i)Sa>h zZ8GkGAuq^BLXSqBeWm)ihpN5gDn%R8RIe6=%~-IGq0ID#3hJptrJUdlKmeo6dQ(P# z(XF<}&SJW&+A^5Ul%Zip6kEF^O})#2o7AQ95yk)n>?1+zf(5XfvOzy*TW1wwQI~&JNld0y=c)Fbx~H+5xmNQZS-*R5sNr&5dgXWdLFff2x15>%n*cw%BFL{qVH| zXaggqdgoBucx@~fm!kkS08v&N^4(GJCyTHxl>~TpnQ^f#4eJ2XFk)=tMf9b>Q8E|j zg{?LKp)ll2trYMjJdZh}-yYpvt~!JPh0HF7TqF&E$CDjA-KaLM zqA&mfY2D%pIeoP{dkxrX7uI%(EVVjkh6$kyg#n05U=t47>g@lc6f5Rt+&G$!*XXjU zC8U{~t-=5Va6NOPQfJ?C3A@akJBUyPI16Pc3_z504f)p8>g*Bd>(GI6oT&m+g*0k7 zmr-(~>%v@ZL8WBo_rbiB{F+y_8&H&qf;u%8o%T_v(x|gT&gl*&UhOV&1Qwiz;?av~ zLc_^Zp#N`T2%4WhsnpqTfvs#BiW`%kQ98_Lnjp}ex1Tc1W2~pn9_9NvH1^zpy{vB; z)q;g-Ld6MvloJGIdd?L#Dh>EyN-WC@v}BIcmq;PXMrS0^99jklEcCXn^9)T>Ap;!L zQSVL)ecck+6bhLm9&0Va%Lj^R-%NGX*&%JlHVFq{BM6yvAp#61hh5JxJ^QwR5c5EgDC30kP2)7LWUaZ?2xY3h9JE01atMjRr{Yl^O>dB zaEdG#4y_#YJ_W9a5;CvP+YaW{>KlwFpJmEM<~5ldP6)9SMKo;xU^v7l%~vxFt8xg3 zrImjj#K~h9JvMuVrlHp)>2o@L4(eni{@2bg5SuAYMMyx9;jTsD9_Npe5PGRso`>xYpa#4l1e0$7BAa5p zr0aWq?#?l<76)8}%K4NSPL8xS9}@$V3B}Z+3m*sdY$DNfcZYe^U*cYY6v{AVBLg!g z2Ivx+`FPXLZS{k1pOGaR84p?>E0D!tKK3kCPzMmhSs|pcs+Wo_8NnOk)YCwnDELFOq-;^{^^|!OWqPj>nf7E&f8yia4 zu;*1f)57SyP*VQ%J#v`&(8Ro||IKSlE1%)QXGL;wtpjKQV}z0v79ql#wxW8LyYmzh zu?y_vC^1%Mkd~E<%E6tkuB)h?%>LqOq{xpw{}eSY*QKNx*z4k&o}zlv`7^;QwOgKT zL2gS6#)Og-9(c4A)syN=PbZ_0oo|J^nG_BQC2d61UY{#%LC7Zcq_SaWkN(EUv|&8>zie9p6i_2t5+5fgScOSd!FKvs?fpHDG`z6WYpl{ z>!8^-F|Ls_LD{<6PtvcZV4&&Nff4t_uW>p9Vn~b8k~jv^+#e}SR1(}#S&_Jf5Oh= zNUIk_^oBvt{k*{4`(mYJY>a849uva-o^Q+B)b0pP@|nNwv$x{Ityn2BAH6G5?2Lio z5J3J`>(Lxp`n?+U3F2YhO>ILGFG|BG$H=Fr^6vmbzzy-W=`5o=k(y171U+j^vi_pE ziYC=guD5F(Zq}9Uxvj`mRnb4n1)ropwynogM--;@KYP$uh*M)T` z0q5&Fj!ClE5%;5Yuk( z`(hfDEA-I;bO2gDZs3Tqwmu$tuYmWXG0r7(a(eRPqW@E3_`BY1;E0h)eo|5DfXAlD z5rdDf7yp4xO6=np&o*$x3|Bq8^d1fGUq0YQo3FWgQ#=PTOb5zWJZYQ5%{caV_ugy4 z`}-Yod)agAAcN!%paZz#^9GKka?5*dc;70Q(9}quAw<)8yyNc%j?wM^Iv*AYGl{%#(ksgYiCkam;s z`ljo4g5!SK^4(`WPtVL|6t-el#dz&M{5v_ z)JQ_?xS;$^pn!njXa|Ck8acFMGI%aPfF?K=@4dGa-rpAkiyBFY`|4m#V1R(&Xb*ys z8cB#fBMRjK0Rn=fO$bJ6BpNUoKxrU=fZ%8sf{_{tS{*T zUNk8DZ|FUtl9K}nj*Q?^YGiFABJD#LEMG#nB)KbM*g=hC zlL5}QDRQisD1F6a9Y6;tQo`Q}Xx0lQT=FM4vVuFQk>@=oZI|haBxG3lMu}2BN^#Tq zX#xqbV}aXpns3Z6;NGLSdUisp|jCjEimyG{Ldvy>sDxjU6?zs!^<;oqqUb z7)g<_=~oaAAUN`cBdL+KOeYecg|-DvQtSq5_)uLqfZ)g%j-*BowCcmuleln-UIq&) z3;qO0&Tu3(@}!lw>aBL}g{0U&j4H9R;7)Ml411}O)?N6N^rll{-N+WNB*lstR2HlW zj_lz`YGfh@V>eTgh-M~8eOD?7#so(Pz>U<%nr5c;8%;VM>~{bqJm{9FJ?Il09RSOz zkus}Ugtcy=V+*B%WPIV2DE2i7j==-({q38`qce*_$3{{un96tcp_Hs|a{(aMuV4v| ze1sp+)X1hK#!X`>>JAkYOr>4{R7HhIiY)vl8!W+*lW+u@8o9CJdqjHKdFmFO1#dGc zlzo!ElH|p*YX8!^iAb;Ic?nPW3U1ImdqFsCW@e`G6Wv6((UWxZ);>IYE4FS{V2O+> zG%U7)4vz!8rJ25Y#gkjD<5DD%zCSiX_@w-cc@Dk9*~Xhi5Gjjspy5wBJ$=uW@w0-J zMpbgwBFh4*JW=<$Vd>p8WVj71R0yohR1JTRvPOHiMlIw=lUAKn?G{| z&$dx#v^}%mBa-l2jr*vmfGYgh7h<&oYVSsN8zi;d#3I8b$Se&-B7FuKIrbLuDnM4cX=2AuTlv4- zdkJ>+L79MC+fc7X4=tC>nWfuC&wBM{cNpIca?kpFHYW;ui4?5deMD1qaQJ(WAb*KS z^emfSu>rHTF)Q62H31(7!x_ck^cFc*HwN{^I<=f4Q7w%%8agA+aQbCeBh_kK zA+HNsk{30T%W2jZk*rx=c4_VlxDKF(Gxqh>A1n4ez4d(>BdjAdZ(WS1NRT8hl$PbC z&a{2pFgu+^IAim22ap4<*u7%2ie>J=C?_>SOu}Z4#p7UsZ5w-bdJvriE1Ep~=BoP? z*^SB;nQAQo`2efj`hb{~e7LtC9NYFDQH$5CxIl60%{Mwv_u`1A-FhY(lEd$(FA+lr zVWVX`7__Zriw-(SM_0ectZ=OuFPHBCa!>(ci%;}csd1whqF7UdG;AkqZ+odd;8%vW zap~_MYABcbb{!+g`g;_Z`-k=X_)IauqPwdi6i-&)Sq+|TdrXO(vzOkVhs;tChRF_~ zhqsYp=@K!+=6Uz}moP2F$&H*Yt%dDv6UbdGiW;Pr`>Yb>#KsOF2pG4a3=yIFlD>Bb z{xXh9I@n!DFr;BO;jy97HkFJ;6pJ(X8LLmRi3tMAENmaYxdcyKB%_L{1Vb8*mm^ET zwQW@8+Orh#)4tmDb-$s=Lcp26i%>E4qDr^$Vp~W;NBq?w4ci6_+YWQo=2h>ZbGRWe zkj`ca4Zx)XNc|I~HcN0?B1Odn3zeWr!(IDeY1_o|mEa{jZ2ro|oFT}%Ng%eRb_U4t}i4{U4O5akB1;~#N|f(CvOWc@v`$D*Bu5TzHq1Asg5iEeKtAPw6C zd)ub{-P6}#&|3h0(jzg~;P&yztCN?ZVtEg#gn%?`5$tN)oS?FMj(2IA7UmD-XG#0` zura!Lu?tVI&C44Y8H)6zYl{eGaBA;OK-cyr? zd10i{wt3%%s^2?DW=kO;&dzHTK73{0>XAPqA?IAkNy9e4aNBi@ z{3^Eg;GGu3^Fi0LsA-zyH88Dq(AKpqPGhiS{9tykm}@(C8xq+vraHacdb748vq!WwUxK8RWd&Mw8>XH%kL zy<0#UHUd*^TN?5V^wuQh`k-k8$cnz@|GsU%;h**zq+v7Q+_uINiOR=?{DU>EahqbN zyUd+7?e=l_%Dx{%yE6F%i(%4m)U^$xrz3WB%xcFbqapZKLH{s)gTQrb(_469V5oGE zY#|LB0BzePTiZr0aF;}BA^*T@b}b*lfoR{ff;7Bq83A$I_6w+hQ!L~k=d3FKWRGgk zw1qTm14dtJw9P`m7^t^}{G+v~uBKJ0{n94VumL!sZJfJ=V_LvJFx#G`nLS$WUaeIO z5$9bm>EVF3(e4t81^nYF;}=_qe1gR~(l9mnw~cp~kWCBr2WvQDEm>5B{$D%;N!uvW zumRko+dkOewwAkuRZ91SZ+0y5R>mb(pKnm4VNz(coiJ!y+g-xk;-qUyOC|;;SZpT^ z6M%Eu1nv@&Z<0Ngq(e@y*iITIzcjPZ};g z(W7)|dH!-AgSSQ()f1;oKQ~WxPQ-&WjE6?sE)cK)^nVYD#YxVM;oDI7`X&j|hX-jm z!H|X-kB%8}m(YAh^qfbSU$^-9Mt@i2N*cZ|_F1)Ufl1$MHi@2U4j=<5clTrJdCGaH zMH=pQkfUQ825lR3m+-96JTckGVajA`OAK1G6%0wkPHY>ugxbv`Bu@ryJ9gn(Unc}h zhNNN5w%sF-3oxHunx~pKcXuGF2CL9{7;q#Fvu)c3cL|-8^@E!00Jm);=E}Jq6Vf(G zh?JM!U2?gH{^hm>`6HJycC~fO>Z^^#lV>Ul(lDOG>O!wa>LC|1E}kWI>NZx453K-SIHHT z{GuGj|CMXx7CB+sY&fK(WVonl={n+Y#K35C>$!<~R`g5K?Pi?kU3ByRKxN3_tk~e( zLB$xrO1g*>rN2AnGUs=(8!JinwXc6Nu30iX#UUafCgjd*_`RGgvP8k40uBA1gQ|*o zc83Ht_2Cp0)9~#12x@%x$-6-+kvOz~WWHYdO)ZFITlZ+I$c)W-)iv&@;DWbW2c(Pf}N|X`KoLEQB z9U?Djb2^;}D|W};JoY!|)o}~*ui5X74 zWWan>Zc9FgpFtsH0stzp1AOi^YlDG3A!V0Hl+nBGTWqZU1OWVAHfls#9d;|cUkeJp zn<#?FWS3%XzfpHFj$PLQUWfmj5h9ywtC^3P`ff2xY$oteCxR}TQPIVKufE@h$~H%A zwLPIW4vM!*Eml^4lFhRVEH}gZyX_bo#c;)T_r;TX3ZgW%m-!fCBBSitYxM`~yvNmm zBhBpnJw7dl)4caJT61;psovS7!#iLe=b4Q=H`e50d{`sl=3Bg3I5CYxq&nXAlkW|fHm^BfS~cUKnK6WRgiao37ltPu6q zaAe-dzbcR=UpZFkAtxvQ42D zV@rAY2YWRSDYo(LM{Wr3YeU>E{7o)P;N0q8FM%^RjST8mBU4s?43#}@Y*!q}RTjwS zNC|UzcL9#aUJ2GeZ^yN1d0{w&S>UW;Ocr>@DdGLJ zL}^b5eVu=&fL`c_M{`zxnl~fw&A=&3H?8>)iS8;GuS10L$?bDFP0yt9As4rX--rw#~_Jrt|k14F?H1S^Zt;m4YA2aNMt3DUK5+ts707WSY z#5gB>tC*qe2~ja06$gopepsysdaFN%J3d{jC;cSOLe!%8Y3#^qZ+y) zONZCAR(}l2UH!FYb;`p}6h&!T>~m0fkF+O5!hBRrrkn`E!EYnBY1cOwI;R&IXHE+5Un1=ZX)_-cgT+;fR6D{u8mm7B zxZ+{y;nGGZG^OFKlLBu~NR#=fxZHmRg106FR)0*eE{W_1Ol}!4ni`lP)kUFUPe_yb zs6dCtVe|zZmOBlrKSmg^F){~VJAkaUB)Ih z>Mi$FkEqY#jXllx=y3;yjy<882J;aEk`&RR$m;d~fDfU|K)qW-hXPqx|7$ki$MvC& zK02Gp-p%@DoL(5W2~yn?TK0ryf7f6>UN~K&asFHuElH|GMzFc*l{mGCl|$A3w~V?m zMn;~k&!l`XLt*!XzC9r%^HG%>-2+XSGZt3?ou%#fp{*L&bVP41kK53Gks_RS>F@xFUkaxoEHA?4;Xtw80Mq$ zWUcN!{acYuF*5P=8=~%YfCJ1K_m4Iv(%%>;yT4m4*93_@Aq?}87Cyq<`$Jvt9S_7i zVB?cyQrX_dg!+qf+|F<3g*}c5I(tGxL?1kmxnH}^Fjjx85~e<<0A>9S6x1yJiH(W% zTb$qg?8+^-1erY{kokyl@{=^xN47l)o#2&YlqFuQP`V-p5=p@tOE9|8u-(WIx#p zFx#2o{nG-M`AQEe9td;6L@(pN%zhV*tc<;26Pw)-wDyFuwJ$^<4_KK0QCT$lkJ%42 zFRr*Vy#LdzUwTJ1$NnK?B#XIVB46@fW)VeW){#Bo@S4$Kr%)y2flJ2}U>6X~sKp+A zv_CfVit=@Pynu`VF&9kKWFE|{r&wg~e6pS;PLBs?(Ld>*V=uu>g9}!h%x(UnleHOm8IR~>v1#+f>}8ORi?gT%p<8gKpE>hhL<@*`~o&RK<;It(t@}sV%9IRXKg)5Jc67(FZo~k zc22uLEAClmqB;j#hOaA8l6wpWT)x0w<~Sr}58Y(d87WLA0YS+7BJ z0c4ea@02^o2mUZwp&lF?zY;R5n%jX6AS-fBwx~$a{7hD;Z`^RJ5zF(NJXrNbN5uce z6dcpHORAOBIf9W)S7>P7Xv7EVT{*PLG{cG&{*=qyd9x8=YX=Y(BF!7tW$i)zMZ>dw zU{D&u=?3+VyyB`GRS5|i&V;pKj2IJ(wPbBMSb(dKmrRSNRCe3lbwpPSCMRrIQM+(C zawqPKefQp7hIJi4TWE0B*cqQ&m-Hau zbjM!Z;ORu8cuH;)>|^4ZN5Hn2=(9a0_C&X#Rc>k&Psw!x2TWa8;)=P8U1?WNsX#Zk zvZP-3yV&Ko!OxVRF|-^k6HLm^jzT)iG>WI>M9~~fUYZX^Os!W&36Zk1NsV4$ z1n%C#T8+8>F1Y%F>FYMtY3|ZKT2$#oV2Jn8x>}9#DxT6&F_DsF0@KXK7DRu$!4#aq zA-J2z%!;SWf>|!GIVANlazvxoz~SyINq64(Qq~i)%GDS=$|+ z&llsmYEr>~GHS$eOlAuvdrhttnFR(@fYIKOtv7m4X#0(<^V$*vWYDabYS5+&xS8o} zq=-v4mEx{H=87&dThxX1wHkw$OP?#uWz5;g-k8ONcAv>3kULk!a8i&!UvhG-#*jMM zFu`+cm=rUh7gL&sL8al9PQ)*3nkiV5E$YO6t3PX#{>j@|RPkOkoJo&YF=gy;YkP+&cTfproaoa4*A|Q`nG$dL)%9J;?V0=*OpV_Q8C!&{ zh10b*oaoa4zdTS?U;NN5QgTgvY5>iSb>p5X_H>vs(#y8C#UfrmysAuxun5#BQChZ1P|u&)wa#Xn`g_r0Qgg`2~gDhT6s-T22(W9be&HF zB&vIrmkR#@%qnTvkY`7#TpR2h7`M0|#&?*452Oh!fedji4EVWFJ8#4v7R{p)%V;2R z;uWqFt{iv9-lh&y&~Tbh10)X~nKG6!%?J^N$9{MJNR8`-b-1d~7F1zd&tM8zARL$Z zG=RyU7&R0vUJtG`JC;n^sqq4RC?7Nq?vJ(RH2c z2o*5yVnvHOOj%>#ET0CdW29o_7n%!3pWweG??=1^-_T=+YYHupznl2U$smv>MG0gG z)`5hjPlLTGY~9?hW9JbnXz=)D)d>r@`q8_%^5d7`>CjHYxz5P#C!Sr4-GMZHc%_1y zd>UYl=HoCx!&JiJLA=}4VM_l-Q34r#p9UNSXO$&~14;rui}7mjVFGDV;%|7L20Z=v z4<2e52L-vqgbQ+Kdmv38tieJ`==d~XDe&KvGJ7@^r7gCjfk zs;xL?9Y|mxciQOzX~TVktz+7Ul_^)rrvD{kuMz%0m`UjmnbX{4 zr>UEg!o5|>}Vev>)M))QD7z7V$jtRo8XO-to-$L>V=r?cHtE%PyD`kZ zxP7-0mnn5U9QXXh7BkHq3fRSc228!{Ca*5-~NZvPANj76`CY3|+ zN#ikzdubp0RAXObqNIaMliX`86VD!EN{I~`pGopd``D+q8YAud3sx>myw=zzbzw@` zYJ6s z1~}RHBhJ!3^iO}YG0`hrn)FL!nS|7nW>WcUw)lJFE2nNM@ngTsBHnMzll=R8I5U^k zk-KfL6`4KMRF3v*8}ewzM?hm4Z+C$(`vspE-zel@1p^u8YF z0ISE8sPEFU#UF}r5cb@EdG5bF_g@mx^kNcKt<*D>;%Yqb;hnY6qubu=Y&Xr=XTW$l z|ALjv_ooaqsr#C`=yc0&#>(bX(!k~Gr8PCu8|*Wwlo_MgY{e>b&@<<<#d1xpl$x>6ykSQFfE7$_ zG_G!Hq~x#P<|%v$NAH6Pcbn-6R?&@V{I;o$RucjCoSxCl%_O^8uO*fhebTz=sqMPJ LhMn$zP+w9b`I5R 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 + + + + + + + \ No newline at end of file diff --git a/app-catalog/samples/wNet/src/main/res/drawable/ic_async_task.xml b/app-catalog/samples/wNet/src/main/res/drawable/ic_async_task.xml new file mode 100644 index 0000000..ec21f89 --- /dev/null +++ b/app-catalog/samples/wNet/src/main/res/drawable/ic_async_task.xml @@ -0,0 +1,25 @@ + + + + + diff --git a/app-catalog/samples/wNet/src/main/res/drawable/ic_callback_request.xml b/app-catalog/samples/wNet/src/main/res/drawable/ic_callback_request.xml new file mode 100644 index 0000000..6a3d062 --- /dev/null +++ b/app-catalog/samples/wNet/src/main/res/drawable/ic_callback_request.xml @@ -0,0 +1,26 @@ + + + + + diff --git a/app-catalog/samples/wNet/src/main/res/drawable/ic_config_dialog.xml b/app-catalog/samples/wNet/src/main/res/drawable/ic_config_dialog.xml new file mode 100644 index 0000000..84605e2 --- /dev/null +++ b/app-catalog/samples/wNet/src/main/res/drawable/ic_config_dialog.xml @@ -0,0 +1,25 @@ + + + + + diff --git a/app-catalog/samples/wNet/src/main/res/drawable/ic_convert.xml b/app-catalog/samples/wNet/src/main/res/drawable/ic_convert.xml new file mode 100644 index 0000000..c4f8a7d --- /dev/null +++ b/app-catalog/samples/wNet/src/main/res/drawable/ic_convert.xml @@ -0,0 +1,25 @@ + + + + + diff --git a/app-catalog/samples/wNet/src/main/res/drawable/ic_debounce.xml b/app-catalog/samples/wNet/src/main/res/drawable/ic_debounce.xml new file mode 100644 index 0000000..b1d90ec --- /dev/null +++ b/app-catalog/samples/wNet/src/main/res/drawable/ic_debounce.xml @@ -0,0 +1,27 @@ + + + + + diff --git a/app-catalog/samples/wNet/src/main/res/drawable/ic_download_file.xml b/app-catalog/samples/wNet/src/main/res/drawable/ic_download_file.xml new file mode 100644 index 0000000..2c5af43 --- /dev/null +++ b/app-catalog/samples/wNet/src/main/res/drawable/ic_download_file.xml @@ -0,0 +1,25 @@ + + + + + diff --git a/app-catalog/samples/wNet/src/main/res/drawable/ic_error_handler.xml b/app-catalog/samples/wNet/src/main/res/drawable/ic_error_handler.xml new file mode 100644 index 0000000..617373f --- /dev/null +++ b/app-catalog/samples/wNet/src/main/res/drawable/ic_error_handler.xml @@ -0,0 +1,25 @@ + + + + + diff --git a/app-catalog/samples/wNet/src/main/res/drawable/ic_exception_trace.xml b/app-catalog/samples/wNet/src/main/res/drawable/ic_exception_trace.xml new file mode 100644 index 0000000..9740071 --- /dev/null +++ b/app-catalog/samples/wNet/src/main/res/drawable/ic_exception_trace.xml @@ -0,0 +1,25 @@ + + + + + diff --git a/app-catalog/samples/wNet/src/main/res/drawable/ic_fastest.xml b/app-catalog/samples/wNet/src/main/res/drawable/ic_fastest.xml new file mode 100644 index 0000000..dc91048 --- /dev/null +++ b/app-catalog/samples/wNet/src/main/res/drawable/ic_fastest.xml @@ -0,0 +1,26 @@ + + + + + diff --git a/app-catalog/samples/wNet/src/main/res/drawable/ic_https.xml b/app-catalog/samples/wNet/src/main/res/drawable/ic_https.xml new file mode 100644 index 0000000..87b4907 --- /dev/null +++ b/app-catalog/samples/wNet/src/main/res/drawable/ic_https.xml @@ -0,0 +1,26 @@ + + + + + diff --git a/app-catalog/samples/wNet/src/main/res/drawable/ic_interceptor.xml b/app-catalog/samples/wNet/src/main/res/drawable/ic_interceptor.xml new file mode 100644 index 0000000..f2994c0 --- /dev/null +++ b/app-catalog/samples/wNet/src/main/res/drawable/ic_interceptor.xml @@ -0,0 +1,25 @@ + + + + + diff --git a/app-catalog/samples/wNet/src/main/res/drawable/ic_interval.xml b/app-catalog/samples/wNet/src/main/res/drawable/ic_interval.xml new file mode 100644 index 0000000..afd9003 --- /dev/null +++ b/app-catalog/samples/wNet/src/main/res/drawable/ic_interval.xml @@ -0,0 +1,25 @@ + + + + + diff --git a/app-catalog/samples/wNet/src/main/res/drawable/ic_launcher_background.xml b/app-catalog/samples/wNet/src/main/res/drawable/ic_launcher_background.xml new file mode 100644 index 0000000..11548f4 --- /dev/null +++ b/app-catalog/samples/wNet/src/main/res/drawable/ic_launcher_background.xml @@ -0,0 +1,187 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app-catalog/samples/wNet/src/main/res/drawable/ic_menu.xml b/app-catalog/samples/wNet/src/main/res/drawable/ic_menu.xml new file mode 100644 index 0000000..6fd7aa6 --- /dev/null +++ b/app-catalog/samples/wNet/src/main/res/drawable/ic_menu.xml @@ -0,0 +1,25 @@ + + + + + diff --git a/app-catalog/samples/wNet/src/main/res/drawable/ic_parallel_network.xml b/app-catalog/samples/wNet/src/main/res/drawable/ic_parallel_network.xml new file mode 100644 index 0000000..c137b9d --- /dev/null +++ b/app-catalog/samples/wNet/src/main/res/drawable/ic_parallel_network.xml @@ -0,0 +1,25 @@ + + + + + diff --git a/app-catalog/samples/wNet/src/main/res/drawable/ic_preview_cache.xml b/app-catalog/samples/wNet/src/main/res/drawable/ic_preview_cache.xml new file mode 100644 index 0000000..88a263d --- /dev/null +++ b/app-catalog/samples/wNet/src/main/res/drawable/ic_preview_cache.xml @@ -0,0 +1,28 @@ + + + + + diff --git a/app-catalog/samples/wNet/src/main/res/drawable/ic_pull_refresh.xml b/app-catalog/samples/wNet/src/main/res/drawable/ic_pull_refresh.xml new file mode 100644 index 0000000..b24ae59 --- /dev/null +++ b/app-catalog/samples/wNet/src/main/res/drawable/ic_pull_refresh.xml @@ -0,0 +1,25 @@ + + + + + diff --git a/app-catalog/samples/wNet/src/main/res/drawable/ic_push_refresh.xml b/app-catalog/samples/wNet/src/main/res/drawable/ic_push_refresh.xml new file mode 100644 index 0000000..67ebad3 --- /dev/null +++ b/app-catalog/samples/wNet/src/main/res/drawable/ic_push_refresh.xml @@ -0,0 +1,25 @@ + + + + + diff --git a/app-catalog/samples/wNet/src/main/res/drawable/ic_read_cache.xml b/app-catalog/samples/wNet/src/main/res/drawable/ic_read_cache.xml new file mode 100644 index 0000000..cc8d0da --- /dev/null +++ b/app-catalog/samples/wNet/src/main/res/drawable/ic_read_cache.xml @@ -0,0 +1,25 @@ + + + + + diff --git a/app-catalog/samples/wNet/src/main/res/drawable/ic_scope.xml b/app-catalog/samples/wNet/src/main/res/drawable/ic_scope.xml new file mode 100644 index 0000000..c9dc29b --- /dev/null +++ b/app-catalog/samples/wNet/src/main/res/drawable/ic_scope.xml @@ -0,0 +1,25 @@ + + + + + diff --git a/app-catalog/samples/wNet/src/main/res/drawable/ic_simple_request.xml b/app-catalog/samples/wNet/src/main/res/drawable/ic_simple_request.xml new file mode 100644 index 0000000..15cd5d2 --- /dev/null +++ b/app-catalog/samples/wNet/src/main/res/drawable/ic_simple_request.xml @@ -0,0 +1,25 @@ + + + + + diff --git a/app-catalog/samples/wNet/src/main/res/drawable/ic_state_layout.xml b/app-catalog/samples/wNet/src/main/res/drawable/ic_state_layout.xml new file mode 100644 index 0000000..ed25180 --- /dev/null +++ b/app-catalog/samples/wNet/src/main/res/drawable/ic_state_layout.xml @@ -0,0 +1,25 @@ + + + + + diff --git a/app-catalog/samples/wNet/src/main/res/drawable/ic_switch_dispatcher.xml b/app-catalog/samples/wNet/src/main/res/drawable/ic_switch_dispatcher.xml new file mode 100644 index 0000000..e8b0d8a --- /dev/null +++ b/app-catalog/samples/wNet/src/main/res/drawable/ic_switch_dispatcher.xml @@ -0,0 +1,25 @@ + + + + + diff --git a/app-catalog/samples/wNet/src/main/res/drawable/ic_sync_request.xml b/app-catalog/samples/wNet/src/main/res/drawable/ic_sync_request.xml new file mode 100644 index 0000000..c5876f3 --- /dev/null +++ b/app-catalog/samples/wNet/src/main/res/drawable/ic_sync_request.xml @@ -0,0 +1,26 @@ + + + + + diff --git a/app-catalog/samples/wNet/src/main/res/drawable/ic_unique.xml b/app-catalog/samples/wNet/src/main/res/drawable/ic_unique.xml new file mode 100644 index 0000000..148bbb4 --- /dev/null +++ b/app-catalog/samples/wNet/src/main/res/drawable/ic_unique.xml @@ -0,0 +1,26 @@ + + + + + diff --git a/app-catalog/samples/wNet/src/main/res/drawable/ic_upload_file.xml b/app-catalog/samples/wNet/src/main/res/drawable/ic_upload_file.xml new file mode 100644 index 0000000..4b8f855 --- /dev/null +++ b/app-catalog/samples/wNet/src/main/res/drawable/ic_upload_file.xml @@ -0,0 +1,25 @@ + + + + + diff --git a/app-catalog/samples/wNet/src/main/res/drawable/ic_view_model.xml b/app-catalog/samples/wNet/src/main/res/drawable/ic_view_model.xml new file mode 100644 index 0000000..347558b --- /dev/null +++ b/app-catalog/samples/wNet/src/main/res/drawable/ic_view_model.xml @@ -0,0 +1,26 @@ + + + + + diff --git a/app-catalog/samples/wNet/src/main/res/layout/activity_main.xml b/app-catalog/samples/wNet/src/main/res/layout/activity_main.xml new file mode 100644 index 0000000..6c1a03d --- /dev/null +++ b/app-catalog/samples/wNet/src/main/res/layout/activity_main.xml @@ -0,0 +1,62 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app-catalog/samples/wNet/src/main/res/layout/fragment_async_task.xml b/app-catalog/samples/wNet/src/main/res/layout/fragment_async_task.xml new file mode 100644 index 0000000..1c5ef15 --- /dev/null +++ b/app-catalog/samples/wNet/src/main/res/layout/fragment_async_task.xml @@ -0,0 +1,37 @@ + + + + + + + + + + + + + \ No newline at end of file diff --git a/app-catalog/samples/wNet/src/main/res/layout/fragment_auto_dialog.xml b/app-catalog/samples/wNet/src/main/res/layout/fragment_auto_dialog.xml new file mode 100644 index 0000000..b2e19c2 --- /dev/null +++ b/app-catalog/samples/wNet/src/main/res/layout/fragment_auto_dialog.xml @@ -0,0 +1,38 @@ + + + + + + + + + + + + + \ No newline at end of file diff --git a/app-catalog/samples/wNet/src/main/res/layout/fragment_callback_request.xml b/app-catalog/samples/wNet/src/main/res/layout/fragment_callback_request.xml new file mode 100644 index 0000000..190e058 --- /dev/null +++ b/app-catalog/samples/wNet/src/main/res/layout/fragment_callback_request.xml @@ -0,0 +1,36 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/app-catalog/samples/wNet/src/main/res/layout/fragment_coroutine_scope.xml b/app-catalog/samples/wNet/src/main/res/layout/fragment_coroutine_scope.xml new file mode 100644 index 0000000..049d35f --- /dev/null +++ b/app-catalog/samples/wNet/src/main/res/layout/fragment_coroutine_scope.xml @@ -0,0 +1,37 @@ + + + + + + + + + + + + + \ No newline at end of file diff --git a/app-catalog/samples/wNet/src/main/res/layout/fragment_custom_convert.xml b/app-catalog/samples/wNet/src/main/res/layout/fragment_custom_convert.xml new file mode 100644 index 0000000..cdf8900 --- /dev/null +++ b/app-catalog/samples/wNet/src/main/res/layout/fragment_custom_convert.xml @@ -0,0 +1,47 @@ + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app-catalog/samples/wNet/src/main/res/layout/fragment_download_file.xml b/app-catalog/samples/wNet/src/main/res/layout/fragment_download_file.xml new file mode 100644 index 0000000..c22aba7 --- /dev/null +++ b/app-catalog/samples/wNet/src/main/res/layout/fragment_download_file.xml @@ -0,0 +1,57 @@ + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app-catalog/samples/wNet/src/main/res/layout/fragment_edit_debounce.xml b/app-catalog/samples/wNet/src/main/res/layout/fragment_edit_debounce.xml new file mode 100644 index 0000000..7aeb980 --- /dev/null +++ b/app-catalog/samples/wNet/src/main/res/layout/fragment_edit_debounce.xml @@ -0,0 +1,53 @@ + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app-catalog/samples/wNet/src/main/res/layout/fragment_error_handler.xml b/app-catalog/samples/wNet/src/main/res/layout/fragment_error_handler.xml new file mode 100644 index 0000000..dba3173 --- /dev/null +++ b/app-catalog/samples/wNet/src/main/res/layout/fragment_error_handler.xml @@ -0,0 +1,37 @@ + + + + + + + + + + + + + \ No newline at end of file diff --git a/app-catalog/samples/wNet/src/main/res/layout/fragment_exception_trace.xml b/app-catalog/samples/wNet/src/main/res/layout/fragment_exception_trace.xml new file mode 100644 index 0000000..fcd49d2 --- /dev/null +++ b/app-catalog/samples/wNet/src/main/res/layout/fragment_exception_trace.xml @@ -0,0 +1,37 @@ + + + + + + + + + + + + + \ No newline at end of file diff --git a/app-catalog/samples/wNet/src/main/res/layout/fragment_fastest.xml b/app-catalog/samples/wNet/src/main/res/layout/fragment_fastest.xml new file mode 100644 index 0000000..f42b78d --- /dev/null +++ b/app-catalog/samples/wNet/src/main/res/layout/fragment_fastest.xml @@ -0,0 +1,37 @@ + + + + + + + + + + + + + \ No newline at end of file diff --git a/app-catalog/samples/wNet/src/main/res/layout/fragment_https_certificate.xml b/app-catalog/samples/wNet/src/main/res/layout/fragment_https_certificate.xml new file mode 100644 index 0000000..6fa768e --- /dev/null +++ b/app-catalog/samples/wNet/src/main/res/layout/fragment_https_certificate.xml @@ -0,0 +1,53 @@ + + + + + + + + +