Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d364c6d908 | ||
|
|
6997a65c96 | ||
|
|
e29ed0a2c8 | ||
|
|
1f9a8b67a2 | ||
|
|
7b1852d9a6 | ||
|
|
3b089e2205 |
No files matched your search
@@ -33,7 +33,7 @@ fun requireSigningProperty(name: String): String {
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "com.jinzhi.app"
|
||||
namespace = "com.jinzhi.butler"
|
||||
compileSdk = flutter.compileSdkVersion
|
||||
ndkVersion = flutter.ndkVersion
|
||||
|
||||
@@ -56,10 +56,9 @@ android {
|
||||
}
|
||||
|
||||
defaultConfig {
|
||||
// TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
|
||||
// Temporary U-Verify test identity: the Yanting AppKey/login secret
|
||||
// loaded from assets/env/.env is registered against this package.
|
||||
applicationId = "com.yanting.read"
|
||||
// Keep the registered Jinzhi application identity. One-key login and
|
||||
// push providers validate the package-and-certificate pair.
|
||||
applicationId = "com.jinzhi.butler"
|
||||
// You can update the following values to match your application needs.
|
||||
// For more information, see: https://flutter.dev/to/review-gradle-config.
|
||||
minSdk = flutter.minSdkVersion
|
||||
|
||||
Binary file not shown.
@@ -3,6 +3,9 @@
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
|
||||
<uses-permission android:name="android.permission.READ_PHONE_STATE" />
|
||||
<uses-permission android:name="android.permission.CAMERA" />
|
||||
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
||||
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
|
||||
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
<application
|
||||
@@ -55,6 +58,16 @@
|
||||
android:name="UMENG_MESSAGE_SECRET"
|
||||
android:value="${UMENG_MESSAGE_SECRET}" />
|
||||
|
||||
<provider
|
||||
android:name="androidx.core.content.FileProvider"
|
||||
android:authorities="${applicationId}.fileprovider"
|
||||
android:exported="false"
|
||||
android:grantUriPermissions="true">
|
||||
<meta-data
|
||||
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||
android:resource="@xml/web_capture_paths" />
|
||||
</provider>
|
||||
|
||||
</application>
|
||||
<!-- Required to query activities that can process text, see:
|
||||
https://developer.android.com/training/package-visibility and
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
package com.jinzhi.app
|
||||
|
||||
import com.umeng.commonsdk.UMConfigure
|
||||
import io.flutter.embedding.android.FlutterActivity
|
||||
import io.flutter.embedding.engine.FlutterEngine
|
||||
import io.flutter.plugin.common.MethodChannel
|
||||
|
||||
class MainActivity : FlutterActivity() {
|
||||
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
|
||||
super.configureFlutterEngine(flutterEngine)
|
||||
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, "jinzhi/privacy")
|
||||
.setMethodCallHandler { call, result ->
|
||||
if (call.method == "submitUmengPrivacyGranted") {
|
||||
UMConfigure.submitPolicyGrantResult(applicationContext, true)
|
||||
result.success(null)
|
||||
} else {
|
||||
result.notImplemented()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
package com.jinzhi.app
|
||||
package com.jinzhi.butler
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.pm.PackageManager
|
||||
@@ -0,0 +1,173 @@
|
||||
package com.jinzhi.butler
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.ActivityNotFoundException
|
||||
import android.content.ClipData
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.provider.MediaStore
|
||||
import androidx.core.content.FileProvider
|
||||
import com.umeng.commonsdk.UMConfigure
|
||||
import io.flutter.embedding.android.FlutterActivity
|
||||
import io.flutter.embedding.engine.FlutterEngine
|
||||
import io.flutter.plugin.common.MethodCall
|
||||
import io.flutter.plugin.common.MethodChannel
|
||||
import java.io.File
|
||||
|
||||
class MainActivity : FlutterActivity() {
|
||||
companion object {
|
||||
private const val WEB_FILE_PICKER_CHANNEL = "com.jinzhi.butler/web_file_picker"
|
||||
private const val WEB_FILE_PICKER_REQUEST_CODE = 9814
|
||||
}
|
||||
|
||||
private var pendingFilePickerResult: MethodChannel.Result? = null
|
||||
private var pendingCapturedFile: File? = null
|
||||
|
||||
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
|
||||
super.configureFlutterEngine(flutterEngine)
|
||||
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, "jinzhi/privacy")
|
||||
.setMethodCallHandler { call, result ->
|
||||
if (call.method == "submitUmengPrivacyGranted") {
|
||||
UMConfigure.submitPolicyGrantResult(applicationContext, true)
|
||||
result.success(null)
|
||||
} else {
|
||||
result.notImplemented()
|
||||
}
|
||||
}
|
||||
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, WEB_FILE_PICKER_CHANNEL)
|
||||
.setMethodCallHandler { call, result ->
|
||||
if (call.method != "pickFiles") {
|
||||
result.notImplemented()
|
||||
return@setMethodCallHandler
|
||||
}
|
||||
openWebFilePicker(call, result)
|
||||
}
|
||||
}
|
||||
|
||||
private fun openWebFilePicker(call: MethodCall, result: MethodChannel.Result) {
|
||||
if (pendingFilePickerResult != null) {
|
||||
result.error("picker_in_progress", "A file picker is already open.", null)
|
||||
return
|
||||
}
|
||||
val acceptTypes = call.argument<List<String>>("acceptTypes")
|
||||
.orEmpty().map(String::trim).filter(String::isNotEmpty).distinct()
|
||||
val allowMultiple = call.argument<Boolean>("allowMultiple") ?: false
|
||||
val captureEnabled = call.argument<Boolean>("captureEnabled") ?: false
|
||||
val isQiyuSession = call.argument<Boolean>("isQiyuSession") ?: false
|
||||
|
||||
if (captureEnabled && !allowMultiple) {
|
||||
captureIntentFor(acceptTypes)?.let { intent ->
|
||||
pendingFilePickerResult = result
|
||||
launchWebFilePicker(intent, result)
|
||||
return
|
||||
}
|
||||
}
|
||||
if (isQiyuSession && captureEnabled && isImageOrVideoRequest(acceptTypes)) {
|
||||
val captureIntent = captureIntentFor(acceptTypes)
|
||||
if (captureIntent != null) {
|
||||
pendingFilePickerResult = result
|
||||
val chooser = Intent.createChooser(
|
||||
fileContentIntent(acceptTypes, allowMultiple),
|
||||
"选择操作",
|
||||
).putExtra(Intent.EXTRA_INITIAL_INTENTS, arrayOf(captureIntent))
|
||||
launchWebFilePicker(chooser, result)
|
||||
return
|
||||
}
|
||||
}
|
||||
pendingFilePickerResult = result
|
||||
launchWebFilePicker(
|
||||
Intent.createChooser(fileContentIntent(acceptTypes, allowMultiple), "选择文件"),
|
||||
result,
|
||||
)
|
||||
}
|
||||
|
||||
private fun fileContentIntent(acceptTypes: List<String>, allowMultiple: Boolean) =
|
||||
Intent(Intent.ACTION_GET_CONTENT)
|
||||
.addCategory(Intent.CATEGORY_OPENABLE)
|
||||
.putExtra(Intent.EXTRA_ALLOW_MULTIPLE, allowMultiple)
|
||||
.apply {
|
||||
if (acceptTypes.size == 1) type = acceptTypes.first()
|
||||
else {
|
||||
type = "*/*"
|
||||
if (acceptTypes.isNotEmpty()) {
|
||||
putExtra(Intent.EXTRA_MIME_TYPES, acceptTypes.toTypedArray())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun isImageOrVideoRequest(acceptTypes: List<String>): Boolean {
|
||||
if (acceptTypes.isEmpty()) return false
|
||||
val onlyImages = acceptTypes.all {
|
||||
it.equals("image/*", ignoreCase = true) || it.startsWith("image/", ignoreCase = true)
|
||||
}
|
||||
val onlyVideos = acceptTypes.all {
|
||||
it.equals("video/*", ignoreCase = true) || it.startsWith("video/", ignoreCase = true)
|
||||
}
|
||||
return onlyImages || onlyVideos
|
||||
}
|
||||
|
||||
private fun captureIntentFor(acceptTypes: List<String>): Intent? {
|
||||
val acceptsVideo = acceptTypes.any {
|
||||
it.equals("video/*", ignoreCase = true) || it.startsWith("video/", ignoreCase = true)
|
||||
}
|
||||
val acceptsImage = acceptTypes.isEmpty() || acceptTypes.any {
|
||||
it.equals("image/*", ignoreCase = true) || it.startsWith("image/", ignoreCase = true)
|
||||
}
|
||||
val action = when {
|
||||
acceptsVideo && !acceptsImage -> MediaStore.ACTION_VIDEO_CAPTURE
|
||||
acceptsImage -> MediaStore.ACTION_IMAGE_CAPTURE
|
||||
else -> return null
|
||||
}
|
||||
val directory = File(cacheDir, "web_captures").apply { mkdirs() }
|
||||
val suffix = if (action == MediaStore.ACTION_VIDEO_CAPTURE) ".mp4" else ".jpg"
|
||||
val output = File.createTempFile("web_", suffix, directory)
|
||||
val uri: Uri = FileProvider.getUriForFile(this, "$packageName.fileprovider", output)
|
||||
pendingCapturedFile = output
|
||||
return Intent(action)
|
||||
.putExtra(MediaStore.EXTRA_OUTPUT, uri)
|
||||
.addFlags(Intent.FLAG_GRANT_WRITE_URI_PERMISSION or Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||
.apply { clipData = ClipData.newRawUri("web_capture", uri) }
|
||||
}
|
||||
|
||||
private fun launchWebFilePicker(intent: Intent, result: MethodChannel.Result) {
|
||||
try {
|
||||
startActivityForResult(intent, WEB_FILE_PICKER_REQUEST_CODE)
|
||||
} catch (_: ActivityNotFoundException) {
|
||||
pendingFilePickerResult = null
|
||||
pendingCapturedFile?.delete()
|
||||
pendingCapturedFile = null
|
||||
result.success(emptyList<String>())
|
||||
}
|
||||
}
|
||||
|
||||
@Deprecated("Deprecated in Android, retained for FlutterActivity compatibility")
|
||||
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
|
||||
super.onActivityResult(requestCode, resultCode, data)
|
||||
if (requestCode != WEB_FILE_PICKER_REQUEST_CODE) return
|
||||
val result = pendingFilePickerResult ?: return
|
||||
pendingFilePickerResult = null
|
||||
if (resultCode != Activity.RESULT_OK) {
|
||||
pendingCapturedFile?.delete()
|
||||
pendingCapturedFile = null
|
||||
result.success(emptyList<String>())
|
||||
return
|
||||
}
|
||||
val capturedFile = pendingCapturedFile
|
||||
val hasCapturedFile = capturedFile?.length()?.let { it > 0L } == true
|
||||
val uris = buildList {
|
||||
if (hasCapturedFile) {
|
||||
add(FileProvider.getUriForFile(
|
||||
this@MainActivity, "$packageName.fileprovider", capturedFile!!,
|
||||
).toString())
|
||||
} else {
|
||||
data?.data?.let { add(it.toString()) }
|
||||
data?.clipData?.let { clip ->
|
||||
for (index in 0 until clip.itemCount) add(clip.getItemAt(index).uri.toString())
|
||||
}
|
||||
}
|
||||
}.distinct()
|
||||
if (!hasCapturedFile) capturedFile?.delete()
|
||||
pendingCapturedFile = null
|
||||
result.success(uris)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<paths xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<cache-path name="web_captures" path="web_captures/" />
|
||||
</paths>
|
||||
@@ -0,0 +1,6 @@
|
||||
<svg preserveAspectRatio="none" overflow="visible" style="display: block;" width="20" height="26.9924" viewBox="0 0 20 26.9924" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g id="装饰星星">
|
||||
<path id="Vector" d="M7.7027 26.5261L8.41064 24.9055C9.02993 23.4731 10.1634 22.3247 11.5875 21.6867L13.5356 20.8223C14.1548 20.5473 14.1548 19.6467 13.5356 19.3709L11.6488 18.534C10.1882 17.879 9.03458 16.6885 8.42596 15.2079L7.70835 13.4808C7.65191 13.3389 7.55418 13.2173 7.42782 13.1315C7.30147 13.0458 7.15229 13 6.9996 13C6.84691 13 6.69773 13.0458 6.57137 13.1315C6.44501 13.2173 6.34729 13.3389 6.29085 13.4808L5.57404 15.2079C4.96542 16.6885 3.81184 17.879 2.35121 18.534L0.464436 19.3717C-0.154812 19.6459 -0.154812 20.5473 0.464436 20.8223L2.41168 21.6867C3.83568 22.3245 4.9691 23.4726 5.58855 24.9047L6.29649 26.5253C6.35471 26.6638 6.45248 26.7819 6.57756 26.865C6.70263 26.9481 6.84944 26.9924 6.9996 26.9924C7.14975 26.9924 7.29656 26.9481 7.42164 26.865C7.54671 26.7819 7.64448 26.6646 7.7027 26.5261Z" fill="white"/>
|
||||
<path id="Vector_2" d="M16.4666 7.6867L16.7133 7.12119C17.1465 6.12115 17.9381 5.31928 18.9324 4.87314L19.6918 4.53443C19.7838 4.49211 19.8617 4.4243 19.9163 4.33903C19.971 4.25376 20 4.15462 20 4.05335C20 3.95208 19.971 3.85294 19.9163 3.76767C19.8617 3.6824 19.7838 3.61459 19.6918 3.57226L18.9754 3.25354C17.9551 2.79573 17.1494 1.9639 16.7243 0.929556L16.4706 0.319085C16.433 0.224921 16.3681 0.144177 16.2842 0.0872928C16.2003 0.0304083 16.1013 0 16 0C15.8986 0 15.7996 0.0304083 15.7157 0.0872928C15.6318 0.144177 15.5669 0.224921 15.5294 0.319085L15.2756 0.928557C14.8507 1.96328 14.045 2.7955 13.0245 3.25354L12.3072 3.57327C12.2154 3.61571 12.1378 3.68351 12.0833 3.76867C12.0289 3.85383 12 3.95278 12 4.05385C12 4.15491 12.0289 4.25387 12.0833 4.33903C12.1378 4.42419 12.2154 4.49199 12.3072 4.53443L13.0665 4.87214C14.0614 5.31832 14.8533 6.1206 15.2866 7.12119L15.5334 7.6867C15.7132 8.10034 16.2867 8.10034 16.4666 7.6867Z" fill="white"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.0 KiB |
@@ -0,0 +1,7 @@
|
||||
<svg preserveAspectRatio="none" overflow="visible" style="display: block;" width="72" height="64" viewBox="0 0 72 64" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g id="Frame">
|
||||
<path id="Vector" d="M52 35.0088V49.0088C52 49.5393 51.7893 50.048 51.4142 50.4231C51.0391 50.7981 50.5304 51.0088 50 51.0088H22C21.4696 51.0088 20.9609 50.7981 20.5858 50.4231C20.2107 50.048 20 49.5393 20 49.0088V35.0088H52ZM41 13.0088C42.181 13.0086 43.3429 13.3072 44.3775 13.8768C45.4121 14.4464 46.2858 15.2685 46.9171 16.2666C47.5485 17.2647 47.9171 18.4063 47.9886 19.5851C48.0601 20.764 47.8322 21.9418 47.326 23.0088H54C54.5304 23.0088 55.0391 23.2196 55.4142 23.5946C55.7893 23.9697 56 24.4784 56 25.0088V31.0088C56 31.5393 55.7893 32.048 55.4142 32.4231C55.0391 32.7981 54.5304 33.0088 54 33.0088H18C17.4696 33.0088 16.9609 32.7981 16.5858 32.4231C16.2107 32.048 16 31.5393 16 31.0088V25.0088C16 24.4784 16.2107 23.9697 16.5858 23.5946C16.9609 23.2196 17.4696 23.0088 18 23.0088H24.674C23.9615 21.5122 23.8033 19.8113 24.2274 18.2088C24.6515 16.6064 25.6306 15.2065 26.9902 14.2583C28.3498 13.3102 30.0018 12.8752 31.6521 13.031C33.3023 13.1867 34.8438 13.923 36.002 15.1088C36.6522 14.4423 37.4296 13.9129 38.2881 13.5522C39.1466 13.1915 40.0688 13.0067 41 13.0088ZM31 17.0088C30.2294 17.0087 29.4883 17.3051 28.9303 17.8366C28.3723 18.3681 28.0403 19.094 28.003 19.8637C27.9657 20.6334 28.226 21.3879 28.73 21.9709C29.234 22.5538 29.943 22.9205 30.71 22.9948L31 23.0088H34V20.0088C34 19.2631 33.7222 18.5441 33.2208 17.992C32.7194 17.44 32.0303 17.0944 31.288 17.0228L31 17.0088ZM41 17.0088L40.71 17.0228C40.0178 17.0901 39.3704 17.3957 38.8786 17.8875C38.3869 18.3793 38.0812 19.0266 38.014 19.7188L38 20.0088V23.0088H41L41.288 22.9948C42.0301 22.9229 42.7187 22.5772 43.2198 22.0252C43.7209 21.4732 43.9985 20.7544 43.9985 20.0088C43.9985 19.2633 43.7209 18.5445 43.2198 17.9925C42.7187 17.4404 42.0301 17.0948 41.288 17.0228L41 17.0088Z" fill="black"/>
|
||||
<path id="Vector_2" d="M7.36867 18.1835L7.954 16.8435C8.46603 15.6592 9.40317 14.7097 10.5807 14.1822L12.1913 13.4675C12.7033 13.2402 12.7033 12.4955 12.1913 12.2675L10.6313 11.5755C9.42367 11.034 8.46988 10.0497 7.96667 8.82555L7.37333 7.39755C7.32667 7.28024 7.24587 7.17964 7.1414 7.10877C7.03692 7.03789 6.91358 7 6.78733 7C6.66109 7 6.53775 7.03789 6.43327 7.10877C6.3288 7.17964 6.248 7.28024 6.20133 7.39755L5.60867 8.82555C5.10546 10.0497 4.15166 11.034 2.944 11.5755L1.384 12.2682C0.872 12.4949 0.872 13.2402 1.384 13.4675L2.994 14.1822C4.17138 14.7096 5.1085 15.6588 5.62067 16.8429L6.206 18.1829C6.25414 18.2973 6.33497 18.395 6.43839 18.4637C6.5418 18.5324 6.66319 18.569 6.78733 18.569C6.91148 18.569 7.03287 18.5324 7.13628 18.4637C7.23969 18.395 7.32053 18.298 7.36867 18.1835Z" fill="black"/>
|
||||
<path id="Vector_3" d="M65.9606 51.2578L66.2899 50.5031C66.868 49.1686 67.9243 48.0985 69.2512 47.5031L70.2646 47.0512C70.3873 46.9947 70.4914 46.9042 70.5643 46.7904C70.6372 46.6766 70.6759 46.5443 70.6759 46.4092C70.6759 46.274 70.6372 46.1417 70.5643 46.0279C70.4914 45.9141 70.3873 45.8236 70.2646 45.7671L69.3086 45.3418C67.947 44.7309 66.8718 43.6208 66.3046 42.2405L65.9659 41.4258C65.9158 41.3002 65.8292 41.1924 65.7173 41.1165C65.6053 41.0406 65.4732 41 65.3379 41C65.2026 41 65.0705 41.0406 64.9585 41.1165C64.8466 41.1924 64.76 41.3002 64.7099 41.4258L64.3712 42.2391C63.8043 43.62 62.729 44.7306 61.3672 45.3418L60.4099 45.7685C60.2875 45.8251 60.1839 45.9156 60.1112 46.0292C60.0386 46.1429 60 46.2749 60 46.4098C60 46.5447 60.0386 46.6767 60.1112 46.7904C60.1839 46.904 60.2875 46.9945 60.4099 47.0512L61.4232 47.5018C62.7509 48.0972 63.8077 49.1679 64.3859 50.5031L64.7152 51.2578C64.9552 51.8098 65.7206 51.8098 65.9606 51.2578Z" fill="black"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.6 KiB |
@@ -0,0 +1,3 @@
|
||||
<svg preserveAspectRatio="none" overflow="visible" style="display: block;" width="18" height="16" viewBox="0 0 18 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path id="Vector 1" d="M8.74286 16L0 0H18L8.74286 16Z" fill="white"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 243 B |
@@ -300,10 +300,14 @@
|
||||
inputFileListPaths = (
|
||||
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist",
|
||||
);
|
||||
inputPaths = (
|
||||
);
|
||||
name = "[CP] Embed Pods Frameworks";
|
||||
outputFileListPaths = (
|
||||
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist",
|
||||
);
|
||||
outputPaths = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
shellPath = /bin/sh;
|
||||
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n";
|
||||
@@ -355,10 +359,14 @@
|
||||
inputFileListPaths = (
|
||||
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-input-files.xcfilelist",
|
||||
);
|
||||
inputPaths = (
|
||||
);
|
||||
name = "[CP] Copy Pods Resources";
|
||||
outputFileListPaths = (
|
||||
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-output-files.xcfilelist",
|
||||
);
|
||||
outputPaths = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
shellPath = /bin/sh;
|
||||
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources.sh\"\n";
|
||||
@@ -491,14 +499,14 @@
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
|
||||
DEVELOPMENT_TEAM = F38D68G8T6;
|
||||
DEVELOPMENT_TEAM = 3QMFZJWPLS;
|
||||
ENABLE_BITCODE = NO;
|
||||
INFOPLIST_FILE = Runner/Info.plist;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.yanting.read;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.jinzhi.butler;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
|
||||
@@ -678,14 +686,14 @@
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
|
||||
DEVELOPMENT_TEAM = F38D68G8T6;
|
||||
DEVELOPMENT_TEAM = 3QMFZJWPLS;
|
||||
ENABLE_BITCODE = NO;
|
||||
INFOPLIST_FILE = Runner/Info.plist;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.yanting.read;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.jinzhi.butler;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
|
||||
@@ -705,14 +713,14 @@
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
|
||||
DEVELOPMENT_TEAM = F38D68G8T6;
|
||||
DEVELOPMENT_TEAM = 3QMFZJWPLS;
|
||||
ENABLE_BITCODE = NO;
|
||||
INFOPLIST_FILE = Runner/Info.plist;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.yanting.read;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.jinzhi.butler;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
|
||||
|
||||
@@ -42,6 +42,10 @@
|
||||
<key>NSAllowsArbitraryLoadsInWebContent</key>
|
||||
<true/>
|
||||
</dict>
|
||||
<key>NSCameraUsageDescription</key>
|
||||
<string>用于在商城客服中拍摄图片或视频并发送给客服。</string>
|
||||
<key>NSMicrophoneUsageDescription</key>
|
||||
<string>用于在商城客服中录制语音或视频并发送给客服。</string>
|
||||
<key>NSUserTrackingUsageDescription</key>
|
||||
<string>用于衡量广告投放效果并提供更相关的内容。</string>
|
||||
<key>UIApplicationSupportsIndirectInputEvents</key>
|
||||
|
||||
+64
-1
@@ -1,7 +1,9 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:app_links/app_links.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:google_fonts/google_fonts.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:shadcn_ui/shadcn_ui.dart';
|
||||
|
||||
@@ -14,6 +16,11 @@ import '../common/theme/spacing.dart';
|
||||
import '../common/theme/theme_controller.dart';
|
||||
import '../features/ads/application/ad_config_foreground_refresher.dart';
|
||||
import '../features/settings/application/update_foreground_service.dart';
|
||||
import '../features/web/application/mall_web_host_controller.dart';
|
||||
import '../features/auth/application/auth_controller.dart';
|
||||
import '../features/auth/application/auth_session_coordinator.dart';
|
||||
import '../features/auth/data/auth_models.dart';
|
||||
import '../features/assets/application/asset_portfolio_controller.dart';
|
||||
import '../common/router/deep_link_service.dart';
|
||||
import '../common/services/device_header_service.dart';
|
||||
|
||||
@@ -27,14 +34,43 @@ class JinzhiApp extends ConsumerStatefulWidget {
|
||||
class _JinzhiAppState extends ConsumerState<JinzhiApp>
|
||||
with WidgetsBindingObserver {
|
||||
late final DevicePushRegistrar _pushRegistrar;
|
||||
ProviderSubscription<AsyncValue<AuthState>>? _authSubscription;
|
||||
StreamSubscription<Uri>? _appLinkSubscription;
|
||||
late final SessionEndListener _sessionEndListener;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
WidgetsBinding.instance.addObserver(this);
|
||||
_pushRegistrar = DevicePushRegistrar(ref.read)..start();
|
||||
_sessionEndListener = (reason) {
|
||||
if (reason == SessionEndReason.accountDeleted) {
|
||||
ref.read(assetPortfolioControllerProvider.notifier).clearHoldings();
|
||||
}
|
||||
};
|
||||
ref.read(authSessionCoordinatorProvider).addListener(_sessionEndListener);
|
||||
_authSubscription = ref.listenManual<AsyncValue<AuthState>>(authProvider, (
|
||||
previous,
|
||||
next,
|
||||
) {
|
||||
final oldUser = previous?.valueOrNull is LoggedInAuthState
|
||||
? (previous!.valueOrNull! as LoggedInAuthState).user.id
|
||||
: null;
|
||||
final newUser = next.valueOrNull is LoggedInAuthState
|
||||
? (next.valueOrNull! as LoggedInAuthState).user.id
|
||||
: null;
|
||||
if (oldUser != newUser) _pushRegistrar.forceRegisterCurrentToken();
|
||||
DeepLinkService.notifyAuthChanged();
|
||||
});
|
||||
UmengPushService.configureNotificationOpen(DeepLinkService.handle);
|
||||
unawaited(_startAppLinks());
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
DeepLinkService.attachRouter((location) {
|
||||
final context = rootNavigatorKey.currentContext;
|
||||
if (context == null) return false;
|
||||
context.go(location);
|
||||
return true;
|
||||
}, authGuard: (_) => !ref.read(authProvider).isLoading);
|
||||
// 首次 iOS 启动在欢迎页点击进入后才注册 APNs,确保系统弹窗不被启动页
|
||||
// 或其他 Overlay 吞掉;后续启动和 Android 保持原有自动初始化。
|
||||
if (!DeviceHeaderService.to.iosStartupRequired) {
|
||||
@@ -43,9 +79,29 @@ class _JinzhiAppState extends ConsumerState<JinzhiApp>
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _startAppLinks() async {
|
||||
try {
|
||||
final appLinks = AppLinks();
|
||||
final initial = await appLinks.getInitialLink();
|
||||
if (initial != null) DeepLinkService.handleUri(initial);
|
||||
_appLinkSubscription = appLinks.uriLinkStream.listen(
|
||||
DeepLinkService.handleUri,
|
||||
onError: (_) {},
|
||||
);
|
||||
} catch (_) {
|
||||
// Desktop tests and unsupported platforms may not register the plugin.
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
unawaited(_pushRegistrar.stop());
|
||||
_authSubscription?.close();
|
||||
ref
|
||||
.read(authSessionCoordinatorProvider)
|
||||
.removeListener(_sessionEndListener);
|
||||
unawaited(_appLinkSubscription?.cancel());
|
||||
DeepLinkService.detachRouter();
|
||||
WidgetsBinding.instance.removeObserver(this);
|
||||
super.dispose();
|
||||
}
|
||||
@@ -54,7 +110,14 @@ class _JinzhiAppState extends ConsumerState<JinzhiApp>
|
||||
void didChangeAppLifecycleState(AppLifecycleState state) {
|
||||
if (state == AppLifecycleState.resumed) {
|
||||
unawaited(_pushRegistrar.refresh());
|
||||
unawaited(ref.read(adConfigForegroundRefresherProvider).refresh());
|
||||
unawaited(
|
||||
ref
|
||||
.read(adConfigForegroundRefresherProvider)
|
||||
.refresh(
|
||||
refreshMallTabConfig:
|
||||
!MallWebHostController.instance.state.value.visible,
|
||||
),
|
||||
);
|
||||
final context = rootNavigatorKey.currentContext;
|
||||
if (context != null) {
|
||||
unawaited(UpdateForegroundService.check(context, ref));
|
||||
|
||||
@@ -7,7 +7,6 @@ import 'package:flutter/widgets.dart';
|
||||
import '../common/services/device_header_service.dart';
|
||||
import '../common/config/shorebird_service.dart';
|
||||
import '../common/config/umeng_config.dart';
|
||||
import '../common/storage/secure_identity_storage.dart';
|
||||
import '../common/storage/storage_service.dart';
|
||||
import '../common/widgets/easy_refresh_localization.dart';
|
||||
import 'app.dart';
|
||||
@@ -17,9 +16,6 @@ Future<Widget> bootstrap() async {
|
||||
configureEasyRefreshChineseLocalization();
|
||||
await UmengConfig.load();
|
||||
await StorageService.instance;
|
||||
await SecureIdentityStorage(
|
||||
StorageService.to.preferences,
|
||||
).restoreAccessToken();
|
||||
if (!kIsWeb) {
|
||||
if (Platform.isAndroid) {
|
||||
// Before consent this only initializes app version and channel. The
|
||||
|
||||
@@ -4,7 +4,11 @@ class AppConfig {
|
||||
factory AppConfig.fromEnvironment() {
|
||||
const url = String.fromEnvironment(
|
||||
'JINZHI_API_BASE_URL',
|
||||
defaultValue: 'https://yanting.api.hnsiderui.cn',
|
||||
// defaultValue: 'http://localhost:8088',
|
||||
// defaultValue: 'https://jince.api.xianlin88.top',
|
||||
defaultValue: 'https://jinzhi.api.yqzhice.top',
|
||||
|
||||
// defaultValue: 'http://8.153.17.57',可测试ip
|
||||
);
|
||||
return const AppConfig(apiBaseUrl: url);
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ class AppH5Urls {
|
||||
|
||||
static const baseUrl = String.fromEnvironment(
|
||||
'JINZHI_H5_BASE_URL',
|
||||
defaultValue: 'https://yanting.h5.hnsiderui.cn',
|
||||
defaultValue: 'https://jinzhi.h5.yqzhice.top',
|
||||
);
|
||||
|
||||
static String get privacyUrl => '$baseUrl/beginner/privacyPolicy.html';
|
||||
|
||||
@@ -6,9 +6,12 @@ class UmengConfig {
|
||||
static String _androidAppKey =
|
||||
'6a27b1f46f259537c7b616be'; // Default placeholder
|
||||
static String _iosAppKey = '6a27b24e6f259537c7b617a8'; // Default placeholder
|
||||
static String _androidPushSecret = 'xxx'; // Default placeholder
|
||||
static String _androidLoginSecret = '';
|
||||
static String _iosLoginSecret = '';
|
||||
static String _androidPushSecret =
|
||||
'261fc7656f0c34f4cf122a3c2c81f704'; // Default placeholder
|
||||
static String _androidLoginSecret =
|
||||
'oNaEhNEwZGQHvngXI4kyxR90tavJRDHMyDDQhkQouty+SAUb66szdNQivqE99ZIQO+QyNabsyyTJ7CN8iA3vb8EgPwLF/iSUHMWpGVacHpukG1sqySo1cwbRmDpOFcdNzaO8PavGfqkkCse/oRPNFBW40LhCjSJaI/roAEVNGpepMXvufJtjnyB4OX6sav+isHU/Lsz84bcdwnzD6fFyDSyE+YqN9gjUoUOv8Ghcy3H1FXUMvw4iD9nD+tE4jODLXvvzlpVbHYknn6Y2UAj5dvGvNUszocXqGAr4j3z8naKPBhz7Liofog==';
|
||||
static String _iosLoginSecret =
|
||||
'vcutZmDFJ2HBdFk7herCz2M9dPHuoa303NPGrm8c9gkzu/Lr5njFa85y33w7htgp0cx9JCMbsk3ecrREhVd8yeq73kFFiVqgkk/BMzCQ6VG55gSATEoiRaLnqXkKvuv6X4kcnSqANMI07up5rTPdAtVQ5JGPh9ts7LUBzRZWQRnDvdX8pjPy8LKxXJKoNagz4sHd5m9MbX0o0uep2OaFFFBTOyI+g4vjK1oZkpaUnIKGU8cTH+wvL8qwp5BLtkc6XCXe5PXqClU=';
|
||||
|
||||
static Future<void> load() async {
|
||||
await dotenv.load(
|
||||
|
||||
@@ -1,20 +1,24 @@
|
||||
import 'package:dio/dio.dart';
|
||||
|
||||
import 'api_exception.dart';
|
||||
|
||||
String? kratosReason(Object e) {
|
||||
if (e is! DioException) return null;
|
||||
final body = e.response?.data;
|
||||
if (body is Map && body['reason'] is String) return body['reason'] as String;
|
||||
if (e is DioException || e is ApiException || e is FormatException) {
|
||||
return ApiException.from(e).reason;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
String kratosDisplayMessage(Object e, {required String fallback}) {
|
||||
if (kratosReason(e) == 'INTERNAL_ERROR') return fallback;
|
||||
if (e is DioException) {
|
||||
final body = e.response?.data;
|
||||
if (body is Map && body['message'] is String) {
|
||||
final m = body['message'] as String;
|
||||
if (m.isNotEmpty) return m;
|
||||
}
|
||||
final exception = ApiException.from(e);
|
||||
if (exception.serverMessage case final message? when message.isNotEmpty) {
|
||||
return message;
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
abstract final class ErrorMessageMapper {
|
||||
static String message(Object error, {required String fallback}) =>
|
||||
kratosDisplayMessage(error, fallback: fallback);
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
import 'package:dio/dio.dart';
|
||||
|
||||
import '../../models/pb/errors/errors.pb.dart' as kerrors;
|
||||
|
||||
enum ApiExceptionKind { transport, http, backend, decode, cancelled }
|
||||
|
||||
/// The single network failure shape exposed to application code.
|
||||
class ApiException implements Exception {
|
||||
const ApiException({
|
||||
required this.kind,
|
||||
this.httpStatus,
|
||||
this.backendCode,
|
||||
this.reason,
|
||||
this.serverMessage,
|
||||
this.requestOptions,
|
||||
this.cause,
|
||||
});
|
||||
|
||||
final ApiExceptionKind kind;
|
||||
final int? httpStatus;
|
||||
final int? backendCode;
|
||||
final String? reason;
|
||||
final String? serverMessage;
|
||||
final RequestOptions? requestOptions;
|
||||
final Object? cause;
|
||||
|
||||
bool get isUnauthorized =>
|
||||
httpStatus == 401 ||
|
||||
const {'INVALID_TOKEN', 'TOKEN_EXPIRED'}.contains(reason?.toUpperCase());
|
||||
|
||||
bool get isRetryable {
|
||||
if (kind == ApiExceptionKind.transport) return true;
|
||||
final status = httpStatus;
|
||||
return status == 408 || status == 429 || (status != null && status >= 500);
|
||||
}
|
||||
|
||||
int? get sessionGeneration =>
|
||||
requestOptions?.extra[authSessionGenerationExtra] as int?;
|
||||
|
||||
static ApiException from(Object error, {StackTrace? stackTrace}) {
|
||||
if (error is ApiException) return error;
|
||||
if (error is DioException) {
|
||||
if (error.error is ApiException) return error.error! as ApiException;
|
||||
return fromDio(error);
|
||||
}
|
||||
if (error is FormatException) {
|
||||
return ApiException(kind: ApiExceptionKind.decode, cause: error);
|
||||
}
|
||||
return ApiException(kind: ApiExceptionKind.decode, cause: error);
|
||||
}
|
||||
|
||||
static ApiException fromDio(DioException error) {
|
||||
final response = error.response;
|
||||
final status = response?.statusCode;
|
||||
final kratos = parseKratosError(response?.data);
|
||||
if (error.type == DioExceptionType.cancel) {
|
||||
return ApiException(
|
||||
kind: ApiExceptionKind.cancelled,
|
||||
httpStatus: status,
|
||||
requestOptions: error.requestOptions,
|
||||
cause: error,
|
||||
);
|
||||
}
|
||||
final kind = kratos != null
|
||||
? ApiExceptionKind.backend
|
||||
: status != null
|
||||
? ApiExceptionKind.http
|
||||
: ApiExceptionKind.transport;
|
||||
return ApiException(
|
||||
kind: kind,
|
||||
httpStatus: status,
|
||||
backendCode: kratos?.code,
|
||||
reason: _normalized(kratos?.reason),
|
||||
serverMessage: _normalized(kratos?.message),
|
||||
requestOptions: error.requestOptions,
|
||||
cause: error,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() =>
|
||||
'ApiException($kind, status: $httpStatus, code: $backendCode, '
|
||||
'reason: $reason, message: $serverMessage)';
|
||||
}
|
||||
|
||||
const authSessionGenerationExtra = 'authSessionGeneration';
|
||||
|
||||
kerrors.Status? parseKratosError(Object? raw) {
|
||||
if (raw is List<int> && raw.isNotEmpty) {
|
||||
try {
|
||||
final status = kerrors.Status.fromBuffer(raw);
|
||||
if (status.code != 0 || status.reason.isNotEmpty) return status;
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
if (raw is Map) {
|
||||
final code = raw['code'];
|
||||
final reason = '${raw['reason'] ?? ''}';
|
||||
final message = '${raw['message'] ?? ''}';
|
||||
if (code is num || reason.isNotEmpty || message.isNotEmpty) {
|
||||
return kerrors.Status(
|
||||
code: code is num ? code.toInt() : 0,
|
||||
reason: reason,
|
||||
message: message,
|
||||
);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
String? _normalized(String? value) {
|
||||
final result = value?.trim() ?? '';
|
||||
return result.isEmpty ? null : result;
|
||||
}
|
||||
|
||||
class ApiExceptionInterceptor extends Interceptor {
|
||||
@override
|
||||
void onError(DioException err, ErrorInterceptorHandler handler) {
|
||||
final apiException = ApiException.fromDio(err);
|
||||
handler.next(err.copyWith(error: apiException));
|
||||
}
|
||||
}
|
||||
@@ -2,10 +2,12 @@ import 'package:dio/dio.dart';
|
||||
|
||||
import '../services/device_header_service.dart';
|
||||
import '../storage/secure_identity_storage.dart';
|
||||
import '../storage/preferences_storage.dart';
|
||||
import '../storage/storage_service.dart';
|
||||
import 'api_exception.dart';
|
||||
|
||||
final _defaultIdentityStorage = SecureIdentityStorage(
|
||||
StorageService.to.preferences,
|
||||
PreferencesStorage(StorageService.to.preferences),
|
||||
);
|
||||
|
||||
Future<void> saveJwt(String token) => _defaultIdentityStorage.saveJwt(token);
|
||||
@@ -13,22 +15,51 @@ Future<void> clearJwt() => _defaultIdentityStorage.clearJwt();
|
||||
Future<String?> loadJwt() => _defaultIdentityStorage.readJwt();
|
||||
|
||||
class AuthInterceptor extends Interceptor {
|
||||
AuthInterceptor(this._identityStorage);
|
||||
AuthInterceptor(this._sessionStorage, {required Uri backendOrigin})
|
||||
: _backendOrigin = backendOrigin;
|
||||
|
||||
final SecureIdentityStorage _identityStorage;
|
||||
final AuthSessionStorage _sessionStorage;
|
||||
final Uri _backendOrigin;
|
||||
|
||||
@override
|
||||
Future<void> onRequest(
|
||||
RequestOptions options,
|
||||
RequestInterceptorHandler handler,
|
||||
) async {
|
||||
if (!_sameOrigin(options.uri, _backendOrigin)) {
|
||||
_stripSensitiveHeaders(options.headers);
|
||||
handler.next(options);
|
||||
return;
|
||||
}
|
||||
await DeviceHeaderService.to.ensureInitialized();
|
||||
options.headers.addAll(DeviceHeaderService.to.commonHeaders());
|
||||
options.headers.addAll(DeviceHeaderService.to.platformIdentityHeaders());
|
||||
final jwt = await _identityStorage.readJwt();
|
||||
final jwt = _sessionStorage.accessToken ?? await _sessionStorage.restore();
|
||||
if (jwt != null && jwt.isNotEmpty) {
|
||||
options.headers['Authorization'] = 'Bearer $jwt';
|
||||
}
|
||||
options.extra[authSessionGenerationExtra] = _sessionStorage.generation;
|
||||
handler.next(options);
|
||||
}
|
||||
}
|
||||
|
||||
bool _sameOrigin(Uri request, Uri backend) =>
|
||||
request.scheme.toLowerCase() == backend.scheme.toLowerCase() &&
|
||||
request.host.toLowerCase() == backend.host.toLowerCase() &&
|
||||
request.port == backend.port;
|
||||
|
||||
void _stripSensitiveHeaders(Map<String, dynamic> headers) {
|
||||
const sensitive = {
|
||||
'authorization',
|
||||
'x-device-id',
|
||||
'user-devicetoken',
|
||||
'user-android-id',
|
||||
'user-android-oaid',
|
||||
'user-android-imei',
|
||||
'user-android-mac',
|
||||
'user-ios-idfv',
|
||||
'user-ios-idfa',
|
||||
'user-ios-paid',
|
||||
};
|
||||
headers.removeWhere((key, _) => sensitive.contains(key.toLowerCase()));
|
||||
}
|
||||
@@ -6,6 +6,8 @@ import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
|
||||
import '../config/app_config.dart';
|
||||
import '../storage/secure_identity_storage.dart';
|
||||
import '../../features/auth/application/auth_session_coordinator.dart';
|
||||
import 'api_exception.dart';
|
||||
import 'auth_interceptor.dart';
|
||||
import 'kratos_error_interceptor.dart';
|
||||
|
||||
@@ -26,7 +28,9 @@ AppConfig appConfig(Ref ref) => AppConfig.fromEnvironment();
|
||||
@Riverpod(keepAlive: true)
|
||||
Dio dio(Ref ref) {
|
||||
final config = ref.watch(appConfigProvider);
|
||||
final identityStorage = ref.watch(secureIdentityStorageProvider);
|
||||
final sessionStorage = ref.watch(authSessionStorageProvider);
|
||||
final coordinator = ref.watch(authSessionCoordinatorProvider);
|
||||
final backendOrigin = Uri.parse(config.apiBaseUrl);
|
||||
final d = Dio(
|
||||
BaseOptions(
|
||||
baseUrl: config.apiBaseUrl,
|
||||
@@ -40,11 +44,57 @@ Dio dio(Ref ref) {
|
||||
},
|
||||
),
|
||||
);
|
||||
d.interceptors.add(AuthInterceptor(identityStorage));
|
||||
d.interceptors.add(
|
||||
AuthInterceptor(sessionStorage, backendOrigin: backendOrigin),
|
||||
);
|
||||
d.interceptors.add(KratosErrorInterceptor());
|
||||
d.interceptors.add(_SessionExpiryInterceptor(coordinator));
|
||||
if (!kReleaseMode) {
|
||||
d.interceptors.add(LogInterceptor(requestBody: false, responseBody: false));
|
||||
d.interceptors.add(FancyDioInterceptor());
|
||||
}
|
||||
d.interceptors.add(FancyDioInterceptor());
|
||||
return d;
|
||||
}
|
||||
|
||||
/// Explicit name for new code. [dioProvider] remains as a compatibility alias
|
||||
/// while existing feature clients are migrated incrementally.
|
||||
final backendDioProvider = dioProvider;
|
||||
|
||||
@Riverpod(keepAlive: true)
|
||||
Dio publicDio(Ref ref) => Dio(
|
||||
BaseOptions(
|
||||
connectTimeout: const Duration(seconds: 15),
|
||||
receiveTimeout: const Duration(seconds: 30),
|
||||
),
|
||||
);
|
||||
|
||||
class _SessionExpiryInterceptor extends Interceptor {
|
||||
_SessionExpiryInterceptor(this._coordinator);
|
||||
|
||||
final AuthSessionCoordinator _coordinator;
|
||||
|
||||
@override
|
||||
Future<void> onError(
|
||||
DioException err,
|
||||
ErrorInterceptorHandler handler,
|
||||
) async {
|
||||
final exception = ApiException.from(err);
|
||||
if (exception.isUnauthorized) {
|
||||
// Network code only emits the session event. Navigation and presentation
|
||||
// observe AuthState elsewhere.
|
||||
try {
|
||||
await _coordinator.expireIfNeeded(exception);
|
||||
} catch (error, stackTrace) {
|
||||
FlutterError.reportError(
|
||||
FlutterErrorDetails(
|
||||
exception: error,
|
||||
stack: stackTrace,
|
||||
library: 'jinzhi network',
|
||||
context: ErrorDescription('while expiring an unauthorized session'),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
handler.next(err);
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,16 @@
|
||||
import 'package:dio/dio.dart';
|
||||
|
||||
import 'api_exception.dart';
|
||||
|
||||
class KratosErrorInterceptor extends Interceptor {
|
||||
@override
|
||||
void onError(DioException err, ErrorInterceptorHandler handler) {
|
||||
handler.next(err);
|
||||
if (err.error is ApiException) {
|
||||
handler.next(err);
|
||||
return;
|
||||
}
|
||||
// Preserve response.data for diagnostics and retries. The parsed error is
|
||||
// carried as DioException.error and normalized by ApiException.from().
|
||||
handler.next(err.copyWith(error: ApiException.fromDio(err)));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import 'dart:convert';
|
||||
|
||||
/// Minimal reader retained only for endpoints that do not have an
|
||||
/// authoritative generated protobuf model yet.
|
||||
class ProtobufWireMessage {
|
||||
ProtobufWireMessage(this.bytes);
|
||||
|
||||
final List<int> bytes;
|
||||
Map<int, List<Object>>? _cache;
|
||||
|
||||
Map<int, List<Object>> get _fields => _cache ??= _read();
|
||||
int integer(int tag) => (_fields[tag]?.lastOrNull as int?) ?? 0;
|
||||
|
||||
String string(int tag) {
|
||||
final value = _fields[tag]?.lastOrNull;
|
||||
return value is List<int> ? utf8.decode(value, allowMalformed: true) : '';
|
||||
}
|
||||
|
||||
Map<int, List<Object>> _read() {
|
||||
final result = <int, List<Object>>{};
|
||||
var index = 0;
|
||||
|
||||
int varint() {
|
||||
var value = 0;
|
||||
var shift = 0;
|
||||
while (index < bytes.length) {
|
||||
final byte = bytes[index++];
|
||||
value |= (byte & 127) << shift;
|
||||
if (byte < 128) return value;
|
||||
shift += 7;
|
||||
}
|
||||
throw const FormatException('Invalid protobuf');
|
||||
}
|
||||
|
||||
while (index < bytes.length) {
|
||||
final key = varint();
|
||||
final tag = key >> 3;
|
||||
final Object field;
|
||||
if ((key & 7) == 0) {
|
||||
field = varint();
|
||||
} else if ((key & 7) == 2) {
|
||||
final length = varint();
|
||||
final end = index + length;
|
||||
if (end > bytes.length) throw const FormatException('Invalid protobuf');
|
||||
field = bytes.sublist(index, end);
|
||||
index = end;
|
||||
} else {
|
||||
throw const FormatException('Unsupported protobuf');
|
||||
}
|
||||
(result[tag] ??= []).add(field);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
extension on List<Object> {
|
||||
Object? get lastOrNull => isEmpty ? null : last;
|
||||
}
|
||||
@@ -1,12 +1,12 @@
|
||||
import 'dart:async';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:package_info_plus/package_info_plus.dart';
|
||||
|
||||
import '../network/dio_provider.dart';
|
||||
import '../../models/pb/jinzhi/v1/push.pb.dart' as pb;
|
||||
import '../network/api_exception.dart';
|
||||
import 'notification_permission.dart';
|
||||
import 'push_api_client.dart';
|
||||
import 'umeng_push_service.dart';
|
||||
|
||||
class DevicePushRegistrar {
|
||||
@@ -15,81 +15,52 @@ class DevicePushRegistrar {
|
||||
StreamSubscription<String>? _subscription;
|
||||
String? _lastToken;
|
||||
bool? _lastPermission;
|
||||
int _attempt = 0;
|
||||
Future<void> _registrationQueue = Future.value();
|
||||
|
||||
void start() {
|
||||
_subscription ??= UmengPushService.onDeviceToken.listen(_register);
|
||||
_subscription ??= UmengPushService.onDeviceToken.listen(_enqueue);
|
||||
UmengPushService.emitCachedToken();
|
||||
}
|
||||
|
||||
Future<void> stop() async => _subscription?.cancel();
|
||||
Future<void> refresh() => UmengPushService.refreshDeviceToken();
|
||||
void forceRegisterCurrentToken() {
|
||||
// BLOCKED(server contract): guest registration is intentionally preserved.
|
||||
// Do not add a client-side unbind call until the backend confirms whether
|
||||
// guest registration reliably clears the previous user binding.
|
||||
_lastToken = null;
|
||||
_lastPermission = null;
|
||||
UmengPushService.emitCachedToken();
|
||||
}
|
||||
|
||||
void _enqueue(String token) {
|
||||
_registrationQueue = _registrationQueue
|
||||
.catchError((_) {})
|
||||
.then((_) => _register(token));
|
||||
}
|
||||
|
||||
Future<void> _register(String token) async {
|
||||
final permission = await NotificationPermission.isGranted();
|
||||
if (token == _lastToken && permission == _lastPermission) return;
|
||||
try {
|
||||
final package = await PackageInfo.fromPlatform();
|
||||
await _read(dioProvider).post<List<int>>(
|
||||
'/api/v1/push/devices',
|
||||
data: _PushRegisterRequest(
|
||||
token: token,
|
||||
platform: defaultTargetPlatform == TargetPlatform.iOS ? 1 : 2,
|
||||
version: package.version,
|
||||
permission: permission,
|
||||
).toBytes(),
|
||||
options: protobufOptions(),
|
||||
);
|
||||
_lastToken = token;
|
||||
_lastPermission = permission;
|
||||
_attempt = 0;
|
||||
} catch (_) {
|
||||
if (_attempt++ >= 3) return;
|
||||
await Future<void>.delayed(Duration(seconds: 1 << (_attempt - 1)));
|
||||
await _register(token);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class _PushRegisterRequest {
|
||||
const _PushRegisterRequest({
|
||||
required this.token,
|
||||
required this.platform,
|
||||
required this.version,
|
||||
required this.permission,
|
||||
});
|
||||
final String token;
|
||||
final int platform;
|
||||
final String version;
|
||||
final bool permission;
|
||||
Uint8List toBytes() {
|
||||
final bytes = BytesBuilder(copy: false);
|
||||
void putInt(int value) {
|
||||
while (value > 0x7f) {
|
||||
bytes.addByte((value & 0x7f) | 0x80);
|
||||
value >>= 7;
|
||||
final package = await PackageInfo.fromPlatform();
|
||||
final request = pb.RegisterPushDeviceRequest(
|
||||
umengDeviceToken: token,
|
||||
platform: defaultTargetPlatform == TargetPlatform.iOS
|
||||
? pb.Platform.PLATFORM_IOS
|
||||
: pb.Platform.PLATFORM_ANDROID,
|
||||
appVersion: package.version,
|
||||
permissionGranted: permission,
|
||||
);
|
||||
for (var attempt = 0; attempt < 4; attempt++) {
|
||||
try {
|
||||
await _read(pushApiClientProvider).registerDevice(request);
|
||||
_lastToken = token;
|
||||
_lastPermission = permission;
|
||||
return;
|
||||
} catch (error) {
|
||||
if (attempt == 3 || !ApiException.from(error).isRetryable) return;
|
||||
await Future<void>.delayed(Duration(seconds: 1 << attempt));
|
||||
}
|
||||
bytes.addByte(value);
|
||||
}
|
||||
|
||||
void putString(int tag, String value) {
|
||||
final raw = value.codeUnits;
|
||||
putInt((tag << 3) | 2);
|
||||
putInt(raw.length);
|
||||
bytes.add(raw);
|
||||
}
|
||||
|
||||
putString(1, token);
|
||||
putInt(2 << 3);
|
||||
putInt(platform);
|
||||
putString(3, version);
|
||||
putInt(4 << 3);
|
||||
putInt(permission ? 1 : 0);
|
||||
return bytes.toBytes();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import 'package:dio/dio.dart';
|
||||
// ignore: unused_import — compute() is referenced by generated Retrofit code.
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:retrofit/retrofit.dart';
|
||||
|
||||
import '../../models/pb/jinzhi/v1/push.pb.dart';
|
||||
import '../network/dio_provider.dart';
|
||||
|
||||
part 'push_api_client.g.dart';
|
||||
|
||||
@RestApi()
|
||||
abstract class PushApiClient {
|
||||
factory PushApiClient(Dio dio, {String? baseUrl}) = _PushApiClient;
|
||||
|
||||
@POST('/api/v1/push/devices')
|
||||
Future<RegisterPushDeviceReply> registerDevice(
|
||||
@Body() RegisterPushDeviceRequest request,
|
||||
);
|
||||
}
|
||||
|
||||
final pushApiClientProvider = Provider<PushApiClient>(
|
||||
(ref) => PushApiClient(ref.watch(backendDioProvider)),
|
||||
);
|
||||
@@ -12,11 +12,12 @@ import '../storage/storage_keys.dart';
|
||||
import '../storage/storage_service.dart';
|
||||
|
||||
abstract final class UmengPushService {
|
||||
static const _maxPendingNotificationOpens = 20;
|
||||
static final _tokens = StreamController<String>.broadcast();
|
||||
static Future<void>? _starting;
|
||||
static bool _started = false;
|
||||
static void Function(String payload)? _onNotificationOpen;
|
||||
static String? _pendingNotificationOpen;
|
||||
static final List<String> _pendingNotificationOpens = [];
|
||||
|
||||
static Stream<String> get onDeviceToken => _tokens.stream;
|
||||
|
||||
@@ -24,10 +25,10 @@ abstract final class UmengPushService {
|
||||
void Function(String payload) callback,
|
||||
) {
|
||||
_onNotificationOpen = callback;
|
||||
final pending = _pendingNotificationOpen;
|
||||
if (pending != null) {
|
||||
_pendingNotificationOpen = null;
|
||||
callback(pending);
|
||||
final pending = List<String>.of(_pendingNotificationOpens);
|
||||
_pendingNotificationOpens.clear();
|
||||
for (final payload in pending) {
|
||||
callback(payload);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,7 +57,10 @@ abstract final class UmengPushService {
|
||||
UmengPushSdk.setNotificationCallback((_) {}, (payload) {
|
||||
final callback = _onNotificationOpen;
|
||||
if (callback == null) {
|
||||
_pendingNotificationOpen = payload;
|
||||
if (_pendingNotificationOpens.length == _maxPendingNotificationOpens) {
|
||||
_pendingNotificationOpens.removeAt(0);
|
||||
}
|
||||
_pendingNotificationOpens.add(payload);
|
||||
} else {
|
||||
callback(payload);
|
||||
}
|
||||
|
||||
@@ -1,33 +1,177 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:go_router/go_router.dart';
|
||||
enum DeepLinkSource { push, appLink, universalLink, customScheme }
|
||||
|
||||
import 'app_router.dart';
|
||||
typedef DeepLinkNavigator = bool Function(String location);
|
||||
typedef DeepLinkAuthGuard = bool Function(String location);
|
||||
|
||||
/// 统一将 Scheme、Universal Link 和推送 payload 收敛为应用内路由。
|
||||
class DeepLinkEvent {
|
||||
const DeepLinkEvent({
|
||||
required this.raw,
|
||||
required this.source,
|
||||
required this.receivedAt,
|
||||
this.eventId,
|
||||
});
|
||||
|
||||
final String raw;
|
||||
final DeepLinkSource source;
|
||||
final DateTime receivedAt;
|
||||
final String? eventId;
|
||||
}
|
||||
|
||||
/// Source adapters feed one parser and a bounded FIFO. Navigation starts only
|
||||
/// after GoRouter has a context and the auth guard accepts the target.
|
||||
abstract final class DeepLinkService {
|
||||
static void handle(String raw) {
|
||||
final target = _route(raw);
|
||||
if (target != null) rootNavigatorKey.currentContext?.go(target);
|
||||
static const maxPending = 20;
|
||||
static const expiry = Duration(minutes: 15);
|
||||
static const _environmentHosts = String.fromEnvironment(
|
||||
'JINZHI_DEEP_LINK_HOSTS',
|
||||
);
|
||||
|
||||
static final List<DeepLinkEvent> _pending = [];
|
||||
static final Set<String> _pendingEventIds = {};
|
||||
static final Map<String, DateTime> _seenEventIds = {};
|
||||
static final Set<String> _configuredHttpsHosts = _normalizedHosts(
|
||||
_environmentHosts.split(','),
|
||||
);
|
||||
static Set<String> _trustedHttpsHosts = {..._configuredHttpsHosts};
|
||||
static DeepLinkNavigator? _navigator;
|
||||
static DeepLinkAuthGuard _authGuard = (_) => true;
|
||||
|
||||
static int get pendingCount => _pending.length;
|
||||
|
||||
static void handle(String raw) => enqueue(raw, source: DeepLinkSource.push);
|
||||
|
||||
static void handleUri(Uri uri) => enqueue(
|
||||
uri.toString(),
|
||||
source: uri.scheme.toLowerCase() == 'jinzhi'
|
||||
? DeepLinkSource.customScheme
|
||||
: DeepLinkSource.universalLink,
|
||||
);
|
||||
|
||||
static void enqueue(
|
||||
String raw, {
|
||||
required DeepLinkSource source,
|
||||
DateTime? receivedAt,
|
||||
}) {
|
||||
final normalized = raw.trim();
|
||||
if (normalized.isEmpty) return;
|
||||
final event = DeepLinkEvent(
|
||||
raw: normalized,
|
||||
source: source,
|
||||
receivedAt: receivedAt ?? DateTime.now(),
|
||||
eventId: _eventId(normalized),
|
||||
);
|
||||
_discardExpired(event.receivedAt);
|
||||
_seenEventIds.removeWhere(
|
||||
(_, seenAt) => event.receivedAt.difference(seenAt) > expiry,
|
||||
);
|
||||
if (event.eventId case final id? when _seenEventIds.containsKey(id)) return;
|
||||
if (_pending.length == maxPending) _removeAt(0);
|
||||
_pending.add(event);
|
||||
if (event.eventId case final id?) {
|
||||
_pendingEventIds.add(id);
|
||||
_seenEventIds[id] = event.receivedAt;
|
||||
}
|
||||
_drain();
|
||||
}
|
||||
|
||||
static void attachRouter(
|
||||
DeepLinkNavigator navigator, {
|
||||
DeepLinkAuthGuard? authGuard,
|
||||
}) {
|
||||
_navigator = navigator;
|
||||
_authGuard = authGuard ?? (_) => true;
|
||||
_drain();
|
||||
}
|
||||
|
||||
static void notifyAuthChanged() => _drain();
|
||||
|
||||
static void detachRouter() => _navigator = null;
|
||||
|
||||
/// Universal/App Link domains are supplied by the release environment. An
|
||||
/// empty allowlist intentionally disables HTTPS deep links until the native
|
||||
/// association domain is confirmed.
|
||||
static void configureTrustedHttpsHosts(Iterable<String> hosts) {
|
||||
_trustedHttpsHosts = _normalizedHosts(hosts);
|
||||
}
|
||||
|
||||
static void resetForTest() {
|
||||
_pending.clear();
|
||||
_pendingEventIds.clear();
|
||||
_seenEventIds.clear();
|
||||
_trustedHttpsHosts = {..._configuredHttpsHosts};
|
||||
_navigator = null;
|
||||
_authGuard = (_) => true;
|
||||
}
|
||||
|
||||
static void _drain() {
|
||||
final navigator = _navigator;
|
||||
if (navigator == null) return;
|
||||
_discardExpired(DateTime.now());
|
||||
while (_pending.isNotEmpty) {
|
||||
final event = _pending.first;
|
||||
final target = _route(event.raw);
|
||||
if (target == null) {
|
||||
_removeAt(0);
|
||||
continue;
|
||||
}
|
||||
if (!_authGuard(target) || !navigator(target)) return;
|
||||
_removeAt(0);
|
||||
}
|
||||
}
|
||||
|
||||
static void _discardExpired(DateTime now) {
|
||||
while (_pending.isNotEmpty &&
|
||||
now.difference(_pending.first.receivedAt) > expiry) {
|
||||
_removeAt(0);
|
||||
}
|
||||
}
|
||||
|
||||
static void _removeAt(int index) {
|
||||
final removed = _pending.removeAt(index);
|
||||
if (removed.eventId case final id?) _pendingEventIds.remove(id);
|
||||
}
|
||||
|
||||
static String? _eventId(String raw) {
|
||||
try {
|
||||
final decoded = jsonDecode(raw);
|
||||
if (decoded is Map) {
|
||||
final id = '${decoded['event_id'] ?? decoded['eventId'] ?? ''}'.trim();
|
||||
if (id.isNotEmpty) return id;
|
||||
}
|
||||
} catch (_) {}
|
||||
final uri = Uri.tryParse(raw);
|
||||
final id = uri?.queryParameters['event_id']?.trim();
|
||||
return id == null || id.isEmpty ? null : id;
|
||||
}
|
||||
|
||||
static String? _route(String raw) {
|
||||
var source = raw.trim();
|
||||
try {
|
||||
final json = jsonDecode(source);
|
||||
if (json is Map) {
|
||||
source = '${json['deep_link'] ?? json['url'] ?? json['link'] ?? ''}';
|
||||
final decoded = jsonDecode(source);
|
||||
if (decoded is Map) {
|
||||
source =
|
||||
'${decoded['deep_link'] ?? decoded['url'] ?? decoded['link'] ?? ''}';
|
||||
}
|
||||
} catch (_) {}
|
||||
final uri = Uri.tryParse(source);
|
||||
if (uri == null) return null;
|
||||
if (uri.scheme == 'jinzhi') {
|
||||
final path = uri.path.isEmpty ? '/assets' : uri.path;
|
||||
if (uri.scheme.toLowerCase() == 'jinzhi') {
|
||||
final path = uri.path.isNotEmpty
|
||||
? uri.path
|
||||
: uri.host.isNotEmpty
|
||||
? '/${uri.host}'
|
||||
: '/assets';
|
||||
return _allowed(path)
|
||||
? '$path${uri.hasQuery ? '?${uri.query}' : ''}'
|
||||
: null;
|
||||
}
|
||||
if ((uri.scheme == 'https' || uri.scheme == 'http') && _allowed(uri.path)) {
|
||||
if (uri.scheme.toLowerCase() == 'https' &&
|
||||
uri.port == 443 &&
|
||||
uri.userInfo.isEmpty &&
|
||||
_trustedHttpsHosts.contains(uri.host.toLowerCase()) &&
|
||||
_allowed(uri.path)) {
|
||||
return '${uri.path}${uri.hasQuery ? '?${uri.query}' : ''}';
|
||||
}
|
||||
return null;
|
||||
@@ -40,4 +184,9 @@ abstract final class DeepLinkService {
|
||||
'/profile',
|
||||
'/settings',
|
||||
}.contains(path);
|
||||
|
||||
static Set<String> _normalizedHosts(Iterable<String> hosts) => {
|
||||
for (final host in hosts)
|
||||
if (host.trim().isNotEmpty) host.trim().toLowerCase(),
|
||||
};
|
||||
}
|
||||
+841
-229
File diff suppressed because it is too large.
Load diff
@@ -5,7 +5,9 @@ import 'package:flutter_cache_manager/flutter_cache_manager.dart';
|
||||
import 'package:flutter/foundation.dart' show kIsWeb;
|
||||
import 'package:package_info_plus/package_info_plus.dart';
|
||||
|
||||
import '../../features/web/application/mall_web_host_controller.dart';
|
||||
import '../storage/storage_service.dart';
|
||||
import '../storage/preferences_storage.dart';
|
||||
|
||||
/// Clears only disposable presentation data. Credentials and user preferences
|
||||
/// deliberately remain outside this service.
|
||||
@@ -14,6 +16,7 @@ class CacheClearService {
|
||||
|
||||
static Future<void> clear() async {
|
||||
await DefaultCacheManager().emptyCache();
|
||||
await MallWebHostController.instance.clear();
|
||||
await SplashAdControllerPlaceholder.clear();
|
||||
}
|
||||
|
||||
@@ -32,7 +35,8 @@ class CacheClearService {
|
||||
/// in SharedPreferences and is removed here without touching user data.
|
||||
abstract final class SplashAdControllerPlaceholder {
|
||||
static Future<void> clear() async {
|
||||
await StorageService.to.preferences.remove('ads.splash.entry');
|
||||
await StorageService.to.preferences.remove('ads.splash.daily');
|
||||
final preferences = PreferencesStorage(StorageService.to.preferences);
|
||||
await preferences.remove('ads.splash.entry');
|
||||
await preferences.remove('ads.splash.daily');
|
||||
}
|
||||
}
|
||||
@@ -5,13 +5,14 @@ import 'package:android_oaid/android_oaid.dart';
|
||||
import 'package:device_info_plus/device_info_plus.dart';
|
||||
import 'package:flutter/foundation.dart' show defaultTargetPlatform, kIsWeb;
|
||||
import 'package:flutter_paid/flutter_paid.dart';
|
||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||
import 'package:package_info_plus/package_info_plus.dart';
|
||||
import 'package:platform_metadata/platform_metadata.dart';
|
||||
import 'package:uuid/uuid.dart';
|
||||
|
||||
import '../storage/storage_keys.dart';
|
||||
import '../storage/storage_service.dart';
|
||||
import '../storage/preferences_storage.dart';
|
||||
import '../storage/secure_identity_storage.dart';
|
||||
import '../privacy/privacy_gate.dart';
|
||||
|
||||
/// 请求公共 + 平台身份 header 来源(完全对齐研值契约)。
|
||||
@@ -26,8 +27,6 @@ import '../privacy/privacy_gate.dart';
|
||||
class DeviceHeaderService {
|
||||
DeviceHeaderService._();
|
||||
static final DeviceHeaderService to = DeviceHeaderService._();
|
||||
static const _storage = FlutterSecureStorage();
|
||||
static const _kDeviceIdKey = 'device_id';
|
||||
// static const _platformMetadataChannel = MethodChannel('platform_metadata');
|
||||
|
||||
// 渠道:优先编译期 --dart-define=UMENG_CHANNEL=xxx;Android 其次读 manifest,
|
||||
@@ -48,6 +47,9 @@ class DeviceHeaderService {
|
||||
Future<void>? _baseInitialization;
|
||||
Future<void>? _identityInitialization;
|
||||
|
||||
SecureIdentityStorage get _identityStorage =>
|
||||
SecureIdentityStorage(PreferencesStorage(StorageService.to.preferences));
|
||||
|
||||
Future<void> ensureInitialized() async {
|
||||
_baseInitialization ??= _initializeBase();
|
||||
await _baseInitialization;
|
||||
@@ -89,14 +91,14 @@ class DeviceHeaderService {
|
||||
/// X-Device-Id:iOS 优先 IDFV(per-vendor 稳定),否则 UUIDv4;
|
||||
/// 首次生成后写入 secure_storage,后续复用。
|
||||
Future<String> _resolveDeviceId() async {
|
||||
final stored = await _storage.read(key: _kDeviceIdKey);
|
||||
final stored = await _identityStorage.readDeviceId();
|
||||
if (stored != null && stored.isNotEmpty) return stored;
|
||||
var id = '';
|
||||
if (!kIsWeb && Platform.isIOS) {
|
||||
id = await _resolveIdfv();
|
||||
}
|
||||
if (id.isEmpty) id = const Uuid().v4();
|
||||
await _storage.write(key: _kDeviceIdKey, value: id);
|
||||
await _identityStorage.saveDeviceId(id);
|
||||
return id;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
import 'storage_service.dart';
|
||||
|
||||
/// Typed boundary for non-sensitive preferences and display caches.
|
||||
///
|
||||
/// Feature code depends on this class instead of constructing or retaining a
|
||||
/// [SharedPreferences] instance.
|
||||
class PreferencesStorage {
|
||||
const PreferencesStorage(this._preferences);
|
||||
|
||||
final SharedPreferences _preferences;
|
||||
|
||||
bool getBool(String key, {bool defaultValue = false}) =>
|
||||
_preferences.getBool(key) ?? defaultValue;
|
||||
String? getString(String key) => _preferences.getString(key);
|
||||
bool containsKey(String key) => _preferences.containsKey(key);
|
||||
|
||||
Future<void> setBool(String key, bool value) =>
|
||||
_preferences.setBool(key, value);
|
||||
Future<void> setString(String key, String value) =>
|
||||
_preferences.setString(key, value);
|
||||
Future<void> remove(String key) => _preferences.remove(key);
|
||||
}
|
||||
|
||||
final preferencesStorageProvider = Provider<PreferencesStorage>(
|
||||
(ref) => PreferencesStorage(StorageService.to.preferences),
|
||||
);
|
||||
@@ -1,9 +1,8 @@
|
||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
import 'preferences_storage.dart';
|
||||
import 'storage_keys.dart';
|
||||
import 'storage_service.dart';
|
||||
|
||||
abstract interface class SecureKeyValueStorage {
|
||||
Future<String?> read(String key);
|
||||
@@ -33,25 +32,33 @@ class SecureIdentityStorage {
|
||||
SecureKeyValueStorage secureStorage = const FlutterSecureKeyValueStorage(),
|
||||
}) : _storage = secureStorage;
|
||||
|
||||
static const _accessTokenKey = 'access_token';
|
||||
static const _legacySecureJwtKey = 'jwt_token';
|
||||
|
||||
final SharedPreferences _preferences;
|
||||
final PreferencesStorage _preferences;
|
||||
final SecureKeyValueStorage _storage;
|
||||
|
||||
Future<String?> restoreAccessToken() async {
|
||||
final current = _normalized(await _storage.read(_accessTokenKey));
|
||||
final legacyPreferences = _normalized(
|
||||
_preferences.getString(storageLegacyAccessToken),
|
||||
);
|
||||
final hasInstallSession = _preferences.getBool(storageAuthSessionActive);
|
||||
|
||||
// A plaintext token proves that this installation predates secure storage
|
||||
// migration. A Keychain token by itself does not: iOS may retain it after
|
||||
// uninstall while SharedPreferences (including this marker) is reset.
|
||||
if (!hasInstallSession && legacyPreferences == null) {
|
||||
await _deleteAllAuthCredentials();
|
||||
await _preferences.remove(storageAuthSessionActive);
|
||||
return null;
|
||||
}
|
||||
|
||||
final current = _normalized(await _storage.read(secureAccessToken));
|
||||
final legacySecure = current == null
|
||||
? _normalized(await _storage.read(_legacySecureJwtKey))
|
||||
? _normalized(await _storage.read(secureLegacyJwtToken))
|
||||
: null;
|
||||
final token = current ?? legacyPreferences ?? legacySecure;
|
||||
if (current == null && token != null) {
|
||||
await _storage.write(_accessTokenKey, token);
|
||||
await _storage.write(secureAccessToken, token);
|
||||
}
|
||||
await _storage.delete(_legacySecureJwtKey);
|
||||
await _storage.delete(secureLegacyJwtToken);
|
||||
await _preferences.remove(storageLegacyAccessToken);
|
||||
if (token == null) {
|
||||
await _preferences.remove(storageAuthSessionActive);
|
||||
@@ -62,10 +69,12 @@ class SecureIdentityStorage {
|
||||
}
|
||||
|
||||
Future<String?> readJwt() async {
|
||||
final token = _normalized(await _storage.read(_accessTokenKey));
|
||||
if (token == null || _preferences.containsKey(storageLegacyAccessToken)) {
|
||||
if (!_preferences.getBool(storageAuthSessionActive) ||
|
||||
_preferences.containsKey(storageLegacyAccessToken)) {
|
||||
return restoreAccessToken();
|
||||
}
|
||||
final token = _normalized(await _storage.read(secureAccessToken));
|
||||
if (token == null) await _preferences.remove(storageAuthSessionActive);
|
||||
return token;
|
||||
}
|
||||
|
||||
@@ -74,22 +83,100 @@ class SecureIdentityStorage {
|
||||
if (normalized == null) {
|
||||
throw ArgumentError.value(token, 'token', 'must not be empty');
|
||||
}
|
||||
await _storage.write(_accessTokenKey, normalized);
|
||||
await _storage.delete(_legacySecureJwtKey);
|
||||
await _storage.write(secureAccessToken, normalized);
|
||||
await _storage.delete(secureLegacyJwtToken);
|
||||
await _preferences.remove(storageLegacyAccessToken);
|
||||
await _preferences.setBool(storageAuthSessionActive, true);
|
||||
}
|
||||
|
||||
Future<void> clearJwt() async {
|
||||
await _storage.delete(_accessTokenKey);
|
||||
await _storage.delete(_legacySecureJwtKey);
|
||||
await _deleteAllAuthCredentials();
|
||||
await _preferences.remove(storageLegacyAccessToken);
|
||||
await _preferences.remove(storageAuthSessionActive);
|
||||
}
|
||||
|
||||
Future<void> clearSessionCredentials() async {
|
||||
await clearJwt();
|
||||
await _storage.delete(secureMallCredential);
|
||||
await _storage.delete(secureRefreshToken);
|
||||
}
|
||||
|
||||
Future<void> clearAccountCredentials() async {
|
||||
await clearSessionCredentials();
|
||||
await _storage.delete(secureBiometricCredential);
|
||||
}
|
||||
|
||||
Future<String?> readDeviceId() => _storage.read(secureDeviceId);
|
||||
|
||||
Future<void> saveDeviceId(String deviceId) =>
|
||||
_storage.write(secureDeviceId, deviceId);
|
||||
|
||||
Future<void> _deleteAllAuthCredentials() async {
|
||||
await _storage.delete(secureAccessToken);
|
||||
await _storage.delete(secureLegacyJwtToken);
|
||||
}
|
||||
}
|
||||
|
||||
final secureIdentityStorageProvider = Provider<SecureIdentityStorage>(
|
||||
(ref) => SecureIdentityStorage(StorageService.to.preferences),
|
||||
(ref) => SecureIdentityStorage(ref.watch(preferencesStorageProvider)),
|
||||
);
|
||||
|
||||
/// Process-memory session snapshot. Interceptors read this synchronously;
|
||||
/// secure storage is touched only when a session starts, restores, or ends.
|
||||
class AuthSessionStorage {
|
||||
AuthSessionStorage(this._identityStorage);
|
||||
|
||||
final SecureIdentityStorage _identityStorage;
|
||||
String? _accessToken;
|
||||
int _generation = 0;
|
||||
Future<String?>? _restoreFuture;
|
||||
Future<void> _operationTail = Future<void>.value();
|
||||
|
||||
String? get accessToken => _accessToken;
|
||||
int get generation => _generation;
|
||||
|
||||
Future<String?> restore() => _restoreFuture ??= _serialize(_restore);
|
||||
|
||||
Future<String?> _restore() async {
|
||||
_accessToken = await _identityStorage.restoreAccessToken();
|
||||
_generation++;
|
||||
return _accessToken;
|
||||
}
|
||||
|
||||
Future<void> save(String token) async {
|
||||
await _serialize(() async {
|
||||
await _identityStorage.saveJwt(token);
|
||||
_accessToken = token.trim();
|
||||
_generation++;
|
||||
_restoreFuture = Future<String?>.value(_accessToken);
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> clear({bool accountDeleted = false}) async {
|
||||
await _serialize(() async {
|
||||
if (accountDeleted) {
|
||||
await _identityStorage.clearAccountCredentials();
|
||||
} else {
|
||||
await _identityStorage.clearSessionCredentials();
|
||||
}
|
||||
_accessToken = null;
|
||||
_generation++;
|
||||
_restoreFuture = Future<String?>.value();
|
||||
});
|
||||
}
|
||||
|
||||
Future<T> _serialize<T>(Future<T> Function() operation) {
|
||||
final result = _operationTail.then((_) => operation());
|
||||
_operationTail = result.then<void>(
|
||||
(_) {},
|
||||
onError: (Object _, StackTrace _) {},
|
||||
);
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
final authSessionStorageProvider = Provider<AuthSessionStorage>(
|
||||
(ref) => AuthSessionStorage(ref.watch(secureIdentityStorageProvider)),
|
||||
);
|
||||
|
||||
String? _normalized(String? value) {
|
||||
|
||||
@@ -2,18 +2,33 @@ const storageThemeMode = 'theme_mode';
|
||||
const storagePrivacyAgreed = 'privacy_agreed'; // Android 首启隐私同意(设备采集门控)
|
||||
const storageIosStartupSeen = 'ios_startup_seen'; // iOS 首次启动页已展示
|
||||
const storageJinzhiHoldings = 'jinzhi_holdings';
|
||||
const storageJinzhiHoldingsDeleted = 'jinzhi_holdings_deleted';
|
||||
const storageJinzhiAmountHidden = 'jinzhi_amount_hidden';
|
||||
const storageJinzhiRecycleDiscounts = 'jinzhi_recycle_discounts';
|
||||
const storageJinzhiPurityPresets = 'jinzhi_purity_presets';
|
||||
const storageJinzhiPriceColorMode = 'jinzhi_price_color_mode';
|
||||
const storageJinzhiMockLoggedIn = 'jinzhi_mock_logged_in';
|
||||
const storageUserId = 'jinzhi_user_id';
|
||||
const storageUserPhone = 'jinzhi_user_phone';
|
||||
const storageUserCreatedAt = 'jinzhi_user_created_at';
|
||||
const storagePushPermissionPrompted = 'push_permission_prompted';
|
||||
const storageMallEntryPromoOpened = 'jinzhi_mall_entry_promo_opened';
|
||||
const storageMallHomeBadgeSeen = 'jinzhi_mall_home_badge_seen';
|
||||
|
||||
// Previous builds could persist credentials in SharedPreferences. These keys
|
||||
// exist only for one-time migration into secure storage.
|
||||
const storageLegacyAccessToken = 'access_token';
|
||||
const storageAuthSessionActive = 'auth_session_active';
|
||||
|
||||
// Account-scoped secure values. They are intentionally listed even before
|
||||
// every feature is implemented so account deletion has one complete cleanup
|
||||
// boundary. Access tokens must never be reused as biometric credentials.
|
||||
const secureAccessToken = 'access_token';
|
||||
const secureLegacyJwtToken = 'jwt_token';
|
||||
const secureDeviceId = 'device_id';
|
||||
const secureMallCredential = 'mall_credential';
|
||||
const secureRefreshToken = 'refresh_token';
|
||||
const secureBiometricCredential = 'biometric_credential';
|
||||
|
||||
// 设备/广告标识。由各自的 SDK/ATT 流程写入,DeviceHeaderService 读出注入
|
||||
// header;未写入则为空、注入时被过滤。
|
||||
const storageDeviceToken = 'device_token'; // 推送 token
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_remix/flutter_remix.dart';
|
||||
import 'package:not_static_icons/not_static_icons.dart' as nsi;
|
||||
|
||||
// flutter_remix 0.0.3: icon class is FlutterRemix
|
||||
class AppIcons {
|
||||
@@ -13,3 +17,183 @@ class AppIcons {
|
||||
static const arrowRight = FlutterRemix.arrow_right_s_line;
|
||||
static const arrowLeft = FlutterRemix.arrow_left_s_line;
|
||||
}
|
||||
|
||||
enum AppBottomTabSymbol { assets, market, news, profile }
|
||||
|
||||
class AppBottomTabIcon extends StatefulWidget {
|
||||
const AppBottomTabIcon({
|
||||
required this.symbol,
|
||||
required this.selected,
|
||||
required this.unselectedColor,
|
||||
required this.selectedColor,
|
||||
this.size = 24,
|
||||
super.key,
|
||||
});
|
||||
|
||||
final AppBottomTabSymbol symbol;
|
||||
final bool selected;
|
||||
final Color unselectedColor;
|
||||
final Color selectedColor;
|
||||
final double size;
|
||||
|
||||
@override
|
||||
State<AppBottomTabIcon> createState() => _AppBottomTabIconState();
|
||||
}
|
||||
|
||||
class _AppBottomTabIconState extends State<AppBottomTabIcon>
|
||||
with TickerProviderStateMixin {
|
||||
static const _iconMotionDuration = Duration(milliseconds: 420);
|
||||
static const _selectionDuration = Duration(milliseconds: 140);
|
||||
static const _popDuration = Duration(milliseconds: 260);
|
||||
static const _scalePeak = 1.06;
|
||||
|
||||
final nsi.AnimatedIconController _iconController =
|
||||
nsi.AnimatedIconController();
|
||||
late final AnimationController _selectionController = AnimationController(
|
||||
vsync: this,
|
||||
duration: _selectionDuration,
|
||||
reverseDuration: _selectionDuration,
|
||||
value: widget.selected ? 1 : 0,
|
||||
);
|
||||
late final AnimationController _popController = AnimationController(
|
||||
vsync: this,
|
||||
duration: _popDuration,
|
||||
);
|
||||
late final CurvedAnimation _colorProgress = CurvedAnimation(
|
||||
parent: _selectionController,
|
||||
curve: Curves.easeOutCubic,
|
||||
reverseCurve: Curves.easeOutCubic,
|
||||
);
|
||||
late final Animation<double> _scaleAnimation = TweenSequence<double>([
|
||||
TweenSequenceItem(
|
||||
tween: Tween(
|
||||
begin: 1.0,
|
||||
end: _scalePeak,
|
||||
).chain(CurveTween(curve: Curves.easeOutBack)),
|
||||
weight: 55,
|
||||
),
|
||||
TweenSequenceItem(
|
||||
tween: Tween(
|
||||
begin: _scalePeak,
|
||||
end: 1.0,
|
||||
).chain(CurveTween(curve: Curves.easeOutCubic)),
|
||||
weight: 45,
|
||||
),
|
||||
]).animate(_popController);
|
||||
|
||||
bool get _reduceMotion => MediaQuery.disableAnimationsOf(context);
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
if (widget.selected) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted && !_reduceMotion) _iconController.animate();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant AppBottomTabIcon oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (widget.selected == oldWidget.selected) return;
|
||||
if (widget.selected) {
|
||||
if (_reduceMotion) {
|
||||
_selectionController.value = 1;
|
||||
_popController.value = 0;
|
||||
} else {
|
||||
unawaited(_selectionController.forward(from: 0));
|
||||
unawaited(_popController.forward(from: 0));
|
||||
_iconController.animate();
|
||||
}
|
||||
} else {
|
||||
if (_reduceMotion) {
|
||||
_selectionController.value = 0;
|
||||
} else {
|
||||
unawaited(_selectionController.reverse());
|
||||
}
|
||||
_popController.value = 0;
|
||||
_iconController.reset();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_colorProgress.dispose();
|
||||
_selectionController.dispose();
|
||||
_popController.dispose();
|
||||
_iconController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final reduceMotion = _reduceMotion;
|
||||
return SizedBox.square(
|
||||
dimension: widget.size,
|
||||
child: AnimatedBuilder(
|
||||
animation: Listenable.merge([_selectionController, _popController]),
|
||||
builder: (context, _) {
|
||||
final color = Color.lerp(
|
||||
widget.unselectedColor,
|
||||
widget.selectedColor,
|
||||
reduceMotion ? (widget.selected ? 1 : 0) : _colorProgress.value,
|
||||
)!;
|
||||
return Transform.scale(
|
||||
scale: reduceMotion ? 1 : _scaleAnimation.value,
|
||||
child: _buildBottomTabIcon(
|
||||
widget.symbol,
|
||||
size: widget.size,
|
||||
color: color,
|
||||
controller: _iconController,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Widget _buildBottomTabIcon(
|
||||
AppBottomTabSymbol symbol, {
|
||||
required double size,
|
||||
required Color color,
|
||||
required nsi.AnimatedIconController controller,
|
||||
}) {
|
||||
const strokeWidth = 2.05;
|
||||
const duration = _AppBottomTabIconState._iconMotionDuration;
|
||||
return switch (symbol) {
|
||||
AppBottomTabSymbol.assets => nsi.CoinsIcon(
|
||||
size: size,
|
||||
color: color,
|
||||
animationDuration: duration,
|
||||
strokeWidth: strokeWidth,
|
||||
interactive: false,
|
||||
controller: controller,
|
||||
),
|
||||
AppBottomTabSymbol.market => nsi.ChartCandlestickIcon(
|
||||
size: size,
|
||||
color: color,
|
||||
animationDuration: duration,
|
||||
strokeWidth: strokeWidth,
|
||||
interactive: false,
|
||||
controller: controller,
|
||||
),
|
||||
AppBottomTabSymbol.news => nsi.BookOpenTextIcon(
|
||||
size: size,
|
||||
color: color,
|
||||
animationDuration: duration,
|
||||
strokeWidth: strokeWidth,
|
||||
interactive: false,
|
||||
controller: controller,
|
||||
),
|
||||
AppBottomTabSymbol.profile => nsi.CircleUserIcon(
|
||||
size: size,
|
||||
color: color,
|
||||
animationDuration: duration,
|
||||
strokeWidth: strokeWidth,
|
||||
interactive: false,
|
||||
controller: controller,
|
||||
),
|
||||
};
|
||||
}
|
||||
@@ -32,7 +32,7 @@ abstract final class UmengService {
|
||||
// Keep the carrier-login launch path independent of UMeng Common's
|
||||
// network initialization. Waiting for this future is what left the login
|
||||
// overlay spinning forever in the base project; U-Verify is configured
|
||||
// immediately below by initOneKeyLoginIfAllowed, as in Yanting.
|
||||
// immediately below by initOneKeyLoginIfAllowed.
|
||||
unawaited(
|
||||
UmengCommonSdk.initCommon(
|
||||
UmengConfig.androidAppKey,
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import 'dart:io' show Platform;
|
||||
|
||||
import 'package:android_intent_plus/android_intent.dart';
|
||||
import 'package:flutter/foundation.dart' show kIsWeb;
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
|
||||
typedef AndroidIntentLauncher = Future<void> Function(AndroidIntent intent);
|
||||
|
||||
abstract final class AppLinkUtil {
|
||||
static final _androidPackageNamePattern = RegExp(
|
||||
r'^[A-Za-z][A-Za-z0-9_]*(?:\.[A-Za-z][A-Za-z0-9_]*)+$',
|
||||
);
|
||||
|
||||
static String targetAppFromUrl(String urlString) {
|
||||
final value = Uri.tryParse(urlString)?.queryParameters['targetApp']?.trim();
|
||||
if (value == null || !_androidPackageNamePattern.hasMatch(value)) return '';
|
||||
return value;
|
||||
}
|
||||
|
||||
static Future<bool> openExternal(Uri uri, {String packageName = ''}) =>
|
||||
AppLinkLauncher(
|
||||
isAndroid: () => !kIsWeb && Platform.isAndroid,
|
||||
launchAndroidIntent: (intent) => intent.launch(),
|
||||
).openExternal(uri, packageName: packageName);
|
||||
}
|
||||
|
||||
class AppLinkLauncher {
|
||||
const AppLinkLauncher({
|
||||
required this.isAndroid,
|
||||
required this.launchAndroidIntent,
|
||||
this.launchExternal,
|
||||
});
|
||||
|
||||
final bool Function() isAndroid;
|
||||
final AndroidIntentLauncher launchAndroidIntent;
|
||||
final Future<bool> Function(Uri uri)? launchExternal;
|
||||
|
||||
Future<bool> openExternal(Uri uri, {String packageName = ''}) async {
|
||||
if (uri.host.isEmpty ||
|
||||
(uri.scheme.toLowerCase() != 'http' &&
|
||||
uri.scheme.toLowerCase() != 'https')) {
|
||||
return false;
|
||||
}
|
||||
if (isAndroid() && packageName.trim().isNotEmpty) {
|
||||
try {
|
||||
await launchAndroidIntent(
|
||||
AndroidIntent(
|
||||
action: 'action_view',
|
||||
data: uri.toString(),
|
||||
package: packageName.trim(),
|
||||
),
|
||||
);
|
||||
return true;
|
||||
} catch (_) {}
|
||||
}
|
||||
final launcher = launchExternal;
|
||||
if (launcher != null) return launcher(uri);
|
||||
return launchUrl(uri, mode: LaunchMode.externalApplication);
|
||||
}
|
||||
}
|
||||
@@ -16,7 +16,6 @@ class AppNetworkImage extends StatelessWidget {
|
||||
this.alignment = Alignment.center,
|
||||
this.borderRadius,
|
||||
this.cacheKey,
|
||||
this.httpHeaders,
|
||||
this.placeholder,
|
||||
this.errorWidget,
|
||||
this.semanticLabel,
|
||||
@@ -29,7 +28,6 @@ class AppNetworkImage extends StatelessWidget {
|
||||
final Alignment alignment;
|
||||
final BorderRadius? borderRadius;
|
||||
final String? cacheKey;
|
||||
final Map<String, String>? httpHeaders;
|
||||
final Widget? placeholder;
|
||||
final Widget? errorWidget;
|
||||
final String? semanticLabel;
|
||||
@@ -41,7 +39,6 @@ class AppNetworkImage extends StatelessWidget {
|
||||
: CachedNetworkImage(
|
||||
imageUrl: imageUrl,
|
||||
cacheKey: cacheKey,
|
||||
httpHeaders: httpHeaders,
|
||||
width: width,
|
||||
height: height,
|
||||
fit: fit,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
|
||||
import '../network/api_error_message.dart';
|
||||
import 'app_state_card.dart';
|
||||
|
||||
/// Shared loading, failure and empty-state contract for feature pages.
|
||||
@@ -37,11 +38,11 @@ class AsyncStateView<T> extends StatelessWidget {
|
||||
return state.when(
|
||||
loading: () =>
|
||||
loading ?? const Center(child: CircularProgressIndicator()),
|
||||
error: (_, _) => Center(
|
||||
error: (error, _) => Center(
|
||||
child: AppStateCard(
|
||||
icon: Icons.error_outline,
|
||||
title: errorTitle,
|
||||
subtitle: errorSubtitle,
|
||||
subtitle: ErrorMessageMapper.message(error, fallback: errorSubtitle),
|
||||
buttonLabel: onRetry == null ? null : '重试',
|
||||
onButtonPressed: onRetry,
|
||||
buttonStyle: AppStateButtonStyle.filled,
|
||||
@@ -60,3 +61,44 @@ class AsyncStateView<T> extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Sliver counterpart for CustomScrollView pages without forcing one global
|
||||
/// card layout on successful feature content.
|
||||
class SliverAsyncState<T> extends StatelessWidget {
|
||||
const SliverAsyncState({
|
||||
super.key,
|
||||
required this.state,
|
||||
required this.data,
|
||||
this.loading,
|
||||
this.onRetry,
|
||||
this.errorSubtitle = '请检查网络后重试',
|
||||
});
|
||||
|
||||
final AsyncValue<T> state;
|
||||
final Widget Function(T value) data;
|
||||
final Widget? loading;
|
||||
final VoidCallback? onRetry;
|
||||
final String errorSubtitle;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => state.when(
|
||||
data: data,
|
||||
loading: () => SliverFillRemaining(
|
||||
hasScrollBody: false,
|
||||
child: loading ?? const Center(child: CircularProgressIndicator()),
|
||||
),
|
||||
error: (error, _) => SliverFillRemaining(
|
||||
hasScrollBody: false,
|
||||
child: Center(
|
||||
child: AppStateCard(
|
||||
icon: Icons.error_outline,
|
||||
title: '加载失败',
|
||||
subtitle: ErrorMessageMapper.message(error, fallback: errorSubtitle),
|
||||
buttonLabel: onRetry == null ? null : '重试',
|
||||
onButtonPressed: onRetry,
|
||||
buttonStyle: AppStateButtonStyle.filled,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -16,17 +16,21 @@ class AdConfigForegroundRefresher {
|
||||
final void Function() _banner;
|
||||
final Future<void> Function() _splash, _mall;
|
||||
Future<void>? _running;
|
||||
Future<void> refresh() =>
|
||||
_running ??= _refresh().whenComplete(() => _running = null);
|
||||
Future<void> _refresh() async {
|
||||
Future<void> refresh({bool refreshMallTabConfig = true}) =>
|
||||
_running ??= _refresh(
|
||||
refreshMallTabConfig: refreshMallTabConfig,
|
||||
).whenComplete(() => _running = null);
|
||||
Future<void> _refresh({required bool refreshMallTabConfig}) async {
|
||||
try {
|
||||
_banner();
|
||||
} catch (_) {}
|
||||
try {
|
||||
await _splash();
|
||||
} catch (_) {}
|
||||
try {
|
||||
await _mall();
|
||||
} catch (_) {}
|
||||
if (refreshMallTabConfig) {
|
||||
try {
|
||||
await _mall();
|
||||
} catch (_) {}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import 'dart:io' show File;
|
||||
import 'package:flutter_cache_manager/flutter_cache_manager.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
|
||||
import '../../../common/storage/storage_service.dart';
|
||||
import '../../../common/storage/preferences_storage.dart';
|
||||
import '../data/ads_api_client.dart';
|
||||
|
||||
class SplashAdEntry {
|
||||
@@ -33,12 +33,13 @@ class SplashAdEntry {
|
||||
}
|
||||
|
||||
class SplashAdController {
|
||||
SplashAdController(this._api);
|
||||
SplashAdController(this._api, this._preferences);
|
||||
final AdsApiClient _api;
|
||||
final PreferencesStorage _preferences;
|
||||
static const _entryKey = 'ads.splash.entry';
|
||||
static const _dailyKey = 'ads.splash.daily';
|
||||
Future<SplashAdEntry?> coldLaunchDecision() async {
|
||||
final raw = StorageService.to.preferences.getString(_entryKey);
|
||||
final raw = _preferences.getString(_entryKey);
|
||||
if (raw == null) return null;
|
||||
try {
|
||||
final entry = SplashAdEntry.fromJson(
|
||||
@@ -55,7 +56,7 @@ class SplashAdController {
|
||||
}
|
||||
|
||||
bool _underCap(SplashAdEntry entry) {
|
||||
final value = StorageService.to.preferences.getString(_dailyKey);
|
||||
final value = _preferences.getString(_dailyKey);
|
||||
final today = DateTime.now().toIso8601String().substring(0, 10);
|
||||
if (value == null) return true;
|
||||
final map = Map<String, dynamic>.from(jsonDecode(value) as Map);
|
||||
@@ -68,13 +69,13 @@ class SplashAdController {
|
||||
entry.assetPath != null && await File(entry.assetPath!).exists();
|
||||
Future<void> markShown(SplashAdEntry entry) async {
|
||||
final today = DateTime.now().toIso8601String().substring(0, 10);
|
||||
final raw = StorageService.to.preferences.getString(_dailyKey);
|
||||
final raw = _preferences.getString(_dailyKey);
|
||||
var count = 0;
|
||||
if (raw != null) {
|
||||
final m = Map<String, dynamic>.from(jsonDecode(raw) as Map);
|
||||
if (m['date'] == today) count = (m['count'] as num?)?.toInt() ?? 0;
|
||||
}
|
||||
await StorageService.to.preferences.setString(
|
||||
await _preferences.setString(
|
||||
_dailyKey,
|
||||
jsonEncode({'date': today, 'count': count + 1}),
|
||||
);
|
||||
@@ -92,7 +93,7 @@ class SplashAdController {
|
||||
result.ad!.imageUrl,
|
||||
);
|
||||
if (!await file.exists()) return;
|
||||
await StorageService.to.preferences.setString(
|
||||
await _preferences.setString(
|
||||
_entryKey,
|
||||
jsonEncode(
|
||||
SplashAdEntry(
|
||||
@@ -115,14 +116,17 @@ class SplashAdController {
|
||||
Future<void> skip(SplashAdEntry entry) => _report(entry.ad, 'ad_skip');
|
||||
Future<void> clearCache() async {
|
||||
await _clearEntry();
|
||||
await StorageService.to.preferences.remove(_dailyKey);
|
||||
await _preferences.remove(_dailyKey);
|
||||
}
|
||||
|
||||
Future<void> _clearEntry() async {
|
||||
await StorageService.to.preferences.remove(_entryKey);
|
||||
await _preferences.remove(_entryKey);
|
||||
}
|
||||
}
|
||||
|
||||
final splashAdControllerProvider = Provider(
|
||||
(ref) => SplashAdController(ref.watch(adsApiClientProvider)),
|
||||
(ref) => SplashAdController(
|
||||
ref.watch(adsApiClientProvider),
|
||||
ref.watch(preferencesStorageProvider),
|
||||
),
|
||||
);
|
||||
@@ -1,10 +1,17 @@
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:fixnum/fixnum.dart';
|
||||
// ignore: unused_import — compute() is referenced by generated Retrofit code.
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:retrofit/retrofit.dart';
|
||||
import 'package:uuid/uuid.dart';
|
||||
|
||||
import '../../../common/network/dio_provider.dart';
|
||||
import '../../../models/pb/jinzhi/v1/ads.pb.dart' as pb;
|
||||
import '../../../models/pb/jinzhi/v1/ads.pb.dart'
|
||||
show GetAdReply, ListAdsReply, ReportAdEventReply, ReportAdEventRequest;
|
||||
|
||||
part 'ads_api_client.g.dart';
|
||||
|
||||
const splashAdPlacement = 'splash';
|
||||
const homeBannerAdPlacement = 'home_banner';
|
||||
@@ -99,24 +106,26 @@ class AdCreative {
|
||||
? AdOpenMode.inApp
|
||||
: AdOpenMode.external,
|
||||
);
|
||||
factory AdCreative.fromProto(ProtoMessage fields) => AdCreative(
|
||||
adId: fields.string(1),
|
||||
placement: fields.string(2),
|
||||
targetApp: fields.string(3),
|
||||
title: fields.string(4),
|
||||
imageUrl: fields.string(6),
|
||||
mediaType: switch (fields.integer(7)) {
|
||||
1 => AdMediaType.image,
|
||||
2 => AdMediaType.gif,
|
||||
3 => AdMediaType.video,
|
||||
factory AdCreative.fromProto(pb.AdCreative creative) => AdCreative(
|
||||
adId: creative.adId,
|
||||
placement: creative.placement,
|
||||
targetApp: creative.targetApp,
|
||||
title: creative.title,
|
||||
imageUrl: creative.imageUrl,
|
||||
mediaType: switch (creative.mediaType) {
|
||||
pb.MediaType.MEDIA_TYPE_IMAGE => AdMediaType.image,
|
||||
pb.MediaType.MEDIA_TYPE_GIF => AdMediaType.gif,
|
||||
pb.MediaType.MEDIA_TYPE_VIDEO => AdMediaType.video,
|
||||
_ => AdMediaType.unsupported,
|
||||
},
|
||||
h5Url: fields.string(9),
|
||||
trackingId: fields.string(10),
|
||||
countdownSeconds: fields.integer(11),
|
||||
endAtMs: fields.integer(13),
|
||||
trackedUrl: fields.string(14),
|
||||
openMode: fields.integer(15) == 2 ? AdOpenMode.inApp : AdOpenMode.external,
|
||||
h5Url: creative.h5Url,
|
||||
trackingId: creative.trackingId,
|
||||
countdownSeconds: creative.countdownSeconds,
|
||||
endAtMs: creative.endAtMs.toInt(),
|
||||
trackedUrl: creative.trackedUrl,
|
||||
openMode: creative.openMode == pb.AdOpenMode.AD_OPEN_MODE_IN_APP
|
||||
? AdOpenMode.inApp
|
||||
: AdOpenMode.external,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -135,41 +144,49 @@ class FeaturedAdsState {
|
||||
bool get hasAds => enabled && ads.isNotEmpty;
|
||||
}
|
||||
|
||||
@RestApi()
|
||||
abstract class AdsBackendApiClient {
|
||||
factory AdsBackendApiClient(Dio dio, {String? baseUrl}) =
|
||||
_AdsBackendApiClient;
|
||||
|
||||
@GET('/api/v1/ads')
|
||||
Future<pb.GetAdReply> getAd({@Query('placement') required String placement});
|
||||
|
||||
@GET('/api/v1/ads/list')
|
||||
Future<pb.ListAdsReply> listAds({
|
||||
@Query('placement') required String placement,
|
||||
});
|
||||
|
||||
@POST('/api/v1/ads/events')
|
||||
Future<pb.ReportAdEventReply> reportAdEvent(
|
||||
@Body() pb.ReportAdEventRequest body,
|
||||
);
|
||||
}
|
||||
|
||||
class AdsApiClient {
|
||||
AdsApiClient(this._dio);
|
||||
final Dio _dio;
|
||||
AdsApiClient(this._api);
|
||||
final AdsBackendApiClient _api;
|
||||
|
||||
Future<GetAdResult> getAdResult(String placement) async {
|
||||
final response = await _dio.get<List<int>>(
|
||||
'/api/v1/ads',
|
||||
queryParameters: {'placement': placement},
|
||||
options: protobufOptions(),
|
||||
);
|
||||
final root = ProtoMessage(response.data ?? const []);
|
||||
final creative = root.message(2);
|
||||
final ad = AdCreative.fromProto(creative);
|
||||
final reply = await _api.getAd(placement: placement);
|
||||
final ad = reply.hasAd() ? AdCreative.fromProto(reply.ad) : null;
|
||||
return GetAdResult(
|
||||
root.integer(1) != 0,
|
||||
ad.isRenderable ? ad : null,
|
||||
root.integer(3),
|
||||
reply.enabled,
|
||||
ad?.isRenderable == true ? ad : null,
|
||||
reply.dailyCap,
|
||||
);
|
||||
}
|
||||
|
||||
Future<FeaturedAdsState> listAds(String placement) async {
|
||||
final response = await _dio.get<List<int>>(
|
||||
'/api/v1/ads/list',
|
||||
queryParameters: {'placement': placement},
|
||||
options: protobufOptions(),
|
||||
);
|
||||
final root = ProtoMessage(response.data ?? const []);
|
||||
final ads = root
|
||||
.messages(2)
|
||||
final reply = await _api.listAds(placement: placement);
|
||||
final ads = reply.ads
|
||||
.map(AdCreative.fromProto)
|
||||
.where((ad) => ad.isRenderable)
|
||||
.toList(growable: false);
|
||||
return FeaturedAdsState(
|
||||
root.integer(1) != 0,
|
||||
reply.enabled,
|
||||
ads,
|
||||
trackingId: root.string(3).isEmpty ? null : root.string(3),
|
||||
trackingId: reply.trackingId.isEmpty ? null : reply.trackingId,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -178,23 +195,24 @@ class AdsApiClient {
|
||||
String event, {
|
||||
String? clickId,
|
||||
String? trackingId,
|
||||
}) => _dio.post<List<int>>(
|
||||
'/api/v1/ads/events',
|
||||
data: _Writer()
|
||||
.string(1, event)
|
||||
.string(2, ad.placement)
|
||||
.string(3, ad.adId)
|
||||
.string(4, trackingId ?? ad.trackingId)
|
||||
.string(5, clickId ?? '')
|
||||
.integer(6, DateTime.now().millisecondsSinceEpoch)
|
||||
.toBytes(),
|
||||
options: protobufOptions(),
|
||||
);
|
||||
}) async {
|
||||
await _api.reportAdEvent(
|
||||
pb.ReportAdEventRequest(
|
||||
eventName: event,
|
||||
placement: ad.placement,
|
||||
adId: ad.adId,
|
||||
trackingId: trackingId ?? ad.trackingId,
|
||||
clickId: clickId ?? '',
|
||||
clientTsMs: Int64(DateTime.now().millisecondsSinceEpoch),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String newClickId() => const Uuid().v4();
|
||||
}
|
||||
|
||||
final adsApiClientProvider = Provider(
|
||||
(ref) => AdsApiClient(ref.watch(dioProvider)),
|
||||
(ref) => AdsApiClient(AdsBackendApiClient(ref.watch(backendDioProvider))),
|
||||
);
|
||||
final featuredAdsProvider = FutureProvider.autoDispose
|
||||
.family<FeaturedAdsState, String>(
|
||||
@@ -209,86 +227,3 @@ Uri? _http(String raw) {
|
||||
? uri
|
||||
: null;
|
||||
}
|
||||
|
||||
class ProtoMessage {
|
||||
ProtoMessage(this.bytes);
|
||||
final List<int> bytes;
|
||||
Map<int, List<Object>>? _cache;
|
||||
Map<int, List<Object>> get _fields => _cache ??= _read();
|
||||
int integer(int tag) => (_fields[tag]?.lastOrNull as int?) ?? 0;
|
||||
String string(int tag) {
|
||||
final value = _fields[tag]?.lastOrNull;
|
||||
return value is List<int> ? String.fromCharCodes(value) : '';
|
||||
}
|
||||
|
||||
ProtoMessage message(int tag) {
|
||||
final value = _fields[tag]?.lastOrNull;
|
||||
return ProtoMessage(value is List<int> ? value : const []);
|
||||
}
|
||||
|
||||
List<ProtoMessage> messages(int tag) => (_fields[tag] ?? const [])
|
||||
.whereType<List<int>>()
|
||||
.map(ProtoMessage.new)
|
||||
.toList();
|
||||
Map<int, List<Object>> _read() {
|
||||
final out = <int, List<Object>>{};
|
||||
var i = 0;
|
||||
int v() {
|
||||
var n = 0, s = 0;
|
||||
while (i < bytes.length) {
|
||||
final b = bytes[i++];
|
||||
n |= (b & 127) << s;
|
||||
if (b < 128) return n;
|
||||
s += 7;
|
||||
}
|
||||
throw const FormatException('Invalid protobuf');
|
||||
}
|
||||
|
||||
while (i < bytes.length) {
|
||||
final key = v(), tag = key >> 3;
|
||||
Object value;
|
||||
if ((key & 7) == 0) {
|
||||
value = v();
|
||||
} else if ((key & 7) == 2) {
|
||||
final l = v(), end = i + l;
|
||||
if (end > bytes.length) throw const FormatException('Invalid protobuf');
|
||||
value = bytes.sublist(i, end);
|
||||
i = end;
|
||||
} else {
|
||||
throw const FormatException('Unsupported protobuf');
|
||||
}
|
||||
(out[tag] ??= []).add(value);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
}
|
||||
|
||||
extension on List<Object> {
|
||||
Object? get lastOrNull => isEmpty ? null : last;
|
||||
}
|
||||
|
||||
class _Writer {
|
||||
final b = BytesBuilder(copy: false);
|
||||
_Writer string(int t, String value) {
|
||||
final d = value.codeUnits;
|
||||
v((t << 3) | 2);
|
||||
v(d.length);
|
||||
b.add(d);
|
||||
return this;
|
||||
}
|
||||
|
||||
_Writer integer(int t, int value) {
|
||||
v(t << 3);
|
||||
v(value);
|
||||
return this;
|
||||
}
|
||||
|
||||
Uint8List toBytes() => b.toBytes();
|
||||
void v(int n) {
|
||||
while (n > 127) {
|
||||
b.addByte((n & 127) | 128);
|
||||
n >>= 7;
|
||||
}
|
||||
b.addByte(n);
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import 'package:flutter/foundation.dart' show kIsWeb;
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:video_player/video_player.dart';
|
||||
|
||||
import '../../../common/widgets/app_network_image.dart';
|
||||
import '../data/ads_api_client.dart';
|
||||
|
||||
/// 统一渲染广告图片/GIF/视频;开屏可优先使用已缓存的本地素材。
|
||||
@@ -86,12 +87,12 @@ class _AdMediaState extends State<AdMedia> {
|
||||
errorBuilder: (_, _, _) => _error(),
|
||||
);
|
||||
}
|
||||
return Image.network(
|
||||
widget.ad.imageUrl,
|
||||
return AppNetworkImage(
|
||||
imageUrl: widget.ad.imageUrl,
|
||||
fit: widget.fit,
|
||||
width: double.infinity,
|
||||
height: double.infinity,
|
||||
errorBuilder: (_, _, _) => _error(),
|
||||
errorWidget: _error(),
|
||||
);
|
||||
}
|
||||
final c = player;
|
||||
|
||||
@@ -71,9 +71,29 @@ class _SplashAdDialogState extends State<SplashAdDialog> {
|
||||
Positioned(
|
||||
top: MediaQuery.paddingOf(context).top + 16,
|
||||
right: 18,
|
||||
child: FilledButton(
|
||||
onPressed: () => Navigator.of(context).pop(SplashAdResult.skipped),
|
||||
child: Text('跳过 ${remain}s'),
|
||||
child: GestureDetector(
|
||||
key: const ValueKey('splash-skip'),
|
||||
onTap: () => Navigator.of(context).pop(SplashAdResult.skipped),
|
||||
child: DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.black.withValues(alpha: 0.62),
|
||||
borderRadius: BorderRadius.circular(18),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 14,
|
||||
vertical: 7,
|
||||
),
|
||||
child: Text(
|
||||
'跳过 ${remain > 0 ? remain : 0}s',
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
@@ -1,16 +1,29 @@
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
|
||||
import '../../../common/domain/metal_type.dart';
|
||||
import '../../auth/application/auth_controller.dart';
|
||||
import '../../auth/data/auth_models.dart';
|
||||
import '../../market/data/mock_market_repository.dart';
|
||||
import '../data/asset_models.dart';
|
||||
import '../data/mock_asset_repository.dart';
|
||||
|
||||
final assetPortfolioControllerProvider =
|
||||
StateNotifierProvider<AssetPortfolioController, PortfolioSummary>((ref) {
|
||||
final holdings = ref.watch(mockAssetRepositoryProvider).loadHoldings();
|
||||
final repository = ref.watch(mockAssetRepositoryProvider);
|
||||
final authState = ref.watch(authProvider).valueOrNull;
|
||||
final accountScope = switch (authState) {
|
||||
LoggedInAuthState(:final user) => MockAssetRepository.accountScope(
|
||||
userId: user.id,
|
||||
phone: user.phone,
|
||||
),
|
||||
_ => MockAssetRepository.guestAccountScope,
|
||||
};
|
||||
final holdings = repository.loadHoldings(accountScope);
|
||||
final market = ref.watch(mockMarketRepositoryProvider);
|
||||
return AssetPortfolioController(
|
||||
holdings: holdings,
|
||||
repository: repository,
|
||||
accountScope: accountScope,
|
||||
spotPriceFor: (metal) => market.quoteFor(metal).spotPrice,
|
||||
changeFor: (metal) => market.quoteFor(metal).changeAmount,
|
||||
);
|
||||
@@ -19,10 +32,14 @@ final assetPortfolioControllerProvider =
|
||||
class AssetPortfolioController extends StateNotifier<PortfolioSummary> {
|
||||
AssetPortfolioController({
|
||||
required List<GoldAssetHolding> holdings,
|
||||
required MockAssetRepository repository,
|
||||
required String accountScope,
|
||||
required double Function(MetalType metal) spotPriceFor,
|
||||
required double Function(MetalType metal) changeFor,
|
||||
this.recycleDiscount = 0.97,
|
||||
}) : _holdings = holdings,
|
||||
_repository = repository,
|
||||
_accountScope = accountScope,
|
||||
_spotPriceFor = spotPriceFor,
|
||||
_changeFor = changeFor,
|
||||
super(
|
||||
@@ -35,12 +52,25 @@ class AssetPortfolioController extends StateNotifier<PortfolioSummary> {
|
||||
);
|
||||
|
||||
final List<GoldAssetHolding> _holdings;
|
||||
final MockAssetRepository _repository;
|
||||
final String _accountScope;
|
||||
final double Function(MetalType metal) _spotPriceFor;
|
||||
final double Function(MetalType metal) _changeFor;
|
||||
final double recycleDiscount;
|
||||
|
||||
void addHolding(GoldAssetHolding holding) {
|
||||
Future<void> addHolding(GoldAssetHolding holding) async {
|
||||
_holdings.add(holding);
|
||||
await _repository.saveHoldings(_accountScope, _holdings);
|
||||
state = _buildSummary(
|
||||
holdings: _holdings,
|
||||
spotPriceFor: _spotPriceFor,
|
||||
changeFor: _changeFor,
|
||||
recycleDiscount: recycleDiscount,
|
||||
);
|
||||
}
|
||||
|
||||
void clearHoldings() {
|
||||
_holdings.clear();
|
||||
state = _buildSummary(
|
||||
holdings: _holdings,
|
||||
spotPriceFor: _spotPriceFor,
|
||||
|
||||
@@ -51,6 +51,45 @@ class GoldAssetHolding {
|
||||
final HoldingStatus status;
|
||||
|
||||
double materialValue(double spotPrice) => weightGram * purity * spotPrice;
|
||||
|
||||
Map<String, Object?> toJson() => {
|
||||
'id': id,
|
||||
'name': name,
|
||||
'metal': metal.name,
|
||||
'category': category.name,
|
||||
'purity': purity,
|
||||
'purityLabel': purityLabel,
|
||||
'weightGram': weightGram,
|
||||
'costAmount': costAmount,
|
||||
'purchaseDate': purchaseDate?.toIso8601String(),
|
||||
'channel': channel,
|
||||
'note': note,
|
||||
'createdAt': createdAt.toIso8601String(),
|
||||
'updatedAt': updatedAt.toIso8601String(),
|
||||
'status': status.name,
|
||||
};
|
||||
|
||||
factory GoldAssetHolding.fromJson(Map<String, Object?> json) {
|
||||
return GoldAssetHolding(
|
||||
id: json['id']! as String,
|
||||
name: json['name']! as String,
|
||||
metal: MetalType.values.byName(json['metal']! as String),
|
||||
category: AssetCategory.values.byName(json['category']! as String),
|
||||
purity: (json['purity']! as num).toDouble(),
|
||||
purityLabel: json['purityLabel']! as String,
|
||||
weightGram: (json['weightGram']! as num).toDouble(),
|
||||
costAmount: (json['costAmount'] as num?)?.toDouble(),
|
||||
purchaseDate: switch (json['purchaseDate']) {
|
||||
final String value when value.isNotEmpty => DateTime.parse(value),
|
||||
_ => null,
|
||||
},
|
||||
channel: json['channel'] as String?,
|
||||
note: json['note'] as String?,
|
||||
createdAt: DateTime.parse(json['createdAt']! as String),
|
||||
updatedAt: DateTime.parse(json['updatedAt']! as String),
|
||||
status: HoldingStatus.values.byName(json['status']! as String),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class HoldingValuation {
|
||||
|
||||
@@ -1,14 +1,91 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
|
||||
import '../../../common/domain/metal_type.dart';
|
||||
import '../../../common/storage/preferences_storage.dart';
|
||||
import '../../../common/storage/storage_keys.dart';
|
||||
import 'asset_models.dart';
|
||||
|
||||
final mockAssetRepositoryProvider = Provider<MockAssetRepository>(
|
||||
(ref) => MockAssetRepository(),
|
||||
(ref) => MockAssetRepository(ref.watch(preferencesStorageProvider)),
|
||||
);
|
||||
|
||||
class MockAssetRepository {
|
||||
List<GoldAssetHolding> loadHoldings() {
|
||||
MockAssetRepository(this._preferences);
|
||||
|
||||
static const guestAccountScope = 'guest';
|
||||
|
||||
final PreferencesStorage _preferences;
|
||||
Future<void> _operationTail = Future<void>.value();
|
||||
|
||||
static String accountScope({String? userId, String? phone}) {
|
||||
final normalizedUserId = userId?.trim() ?? '';
|
||||
if (normalizedUserId.isNotEmpty) return 'id:$normalizedUserId';
|
||||
final normalizedPhone = phone?.trim() ?? '';
|
||||
if (normalizedPhone.isNotEmpty) return 'phone:$normalizedPhone';
|
||||
return guestAccountScope;
|
||||
}
|
||||
|
||||
static String holdingsStorageKey(String accountScope) =>
|
||||
'$storageJinzhiHoldings:${Uri.encodeComponent(accountScope)}';
|
||||
|
||||
static String deletedStorageKey(String accountScope) =>
|
||||
'$storageJinzhiHoldingsDeleted:${Uri.encodeComponent(accountScope)}';
|
||||
|
||||
List<GoldAssetHolding> loadHoldings(String accountScope) {
|
||||
if (_preferences.getBool(deletedStorageKey(accountScope))) return [];
|
||||
final encoded = _preferences.getString(holdingsStorageKey(accountScope));
|
||||
if (encoded == null || encoded.isEmpty) return _seedHoldings();
|
||||
try {
|
||||
final decoded = jsonDecode(encoded);
|
||||
if (decoded is! List) return _seedHoldings();
|
||||
return [
|
||||
for (final item in decoded)
|
||||
GoldAssetHolding.fromJson(Map<String, Object?>.from(item as Map)),
|
||||
];
|
||||
} catch (_) {
|
||||
return _seedHoldings();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> saveHoldings(
|
||||
String accountScope,
|
||||
List<GoldAssetHolding> holdings,
|
||||
) async {
|
||||
final encoded = jsonEncode([
|
||||
for (final holding in holdings) holding.toJson(),
|
||||
]);
|
||||
await _serialize(() async {
|
||||
await _preferences.setString(holdingsStorageKey(accountScope), encoded);
|
||||
await _preferences.remove(deletedStorageKey(accountScope));
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> deleteAccountHoldings(String accountScope) async {
|
||||
await _serialize(() async {
|
||||
final scopes = {accountScope, guestAccountScope};
|
||||
await Future.wait([
|
||||
for (final scope in scopes) ...[
|
||||
_preferences.remove(holdingsStorageKey(scope)),
|
||||
_preferences.setBool(deletedStorageKey(scope), true),
|
||||
],
|
||||
// Remove the pre-account-scoping key left by earlier builds.
|
||||
_preferences.remove(storageJinzhiHoldings),
|
||||
]);
|
||||
});
|
||||
}
|
||||
|
||||
Future<T> _serialize<T>(Future<T> Function() operation) {
|
||||
final result = _operationTail.then((_) => operation());
|
||||
_operationTail = result.then<void>(
|
||||
(_) {},
|
||||
onError: (Object _, StackTrace _) {},
|
||||
);
|
||||
return result;
|
||||
}
|
||||
|
||||
List<GoldAssetHolding> _seedHoldings() {
|
||||
final now = DateTime(2026, 6, 18, 16, 11);
|
||||
return [
|
||||
GoldAssetHolding(
|
||||
|
||||
@@ -1,91 +1,151 @@
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
|
||||
import '../../../common/network/auth_interceptor.dart';
|
||||
import '../../../common/storage/storage_keys.dart';
|
||||
import '../../../common/storage/storage_service.dart';
|
||||
import '../../../common/network/api_exception.dart';
|
||||
import '../../../common/network/dio_provider.dart';
|
||||
import '../../../common/storage/preferences_storage.dart';
|
||||
import '../../../common/storage/storage_keys.dart';
|
||||
import '../data/auth_api.dart';
|
||||
import '../data/auth_models.dart';
|
||||
import '../data/one_key_login_api.dart';
|
||||
import 'auth_session_coordinator.dart';
|
||||
|
||||
const _legacyMockLoggedInKey = 'jinzhi_mock_logged_in';
|
||||
|
||||
final authProvider =
|
||||
StateNotifierProvider<AuthController, AsyncValue<AuthState>>(
|
||||
(ref) => AuthController(ref.read(dioProvider)),
|
||||
);
|
||||
StateNotifierProvider<AuthController, AsyncValue<AuthState>>((ref) {
|
||||
return AuthController(
|
||||
ref.read(dioProvider),
|
||||
preferences: ref.read(preferencesStorageProvider),
|
||||
coordinator: ref.read(authSessionCoordinatorProvider),
|
||||
);
|
||||
});
|
||||
|
||||
class AuthController extends StateNotifier<AsyncValue<AuthState>> {
|
||||
AuthController(this._dio) : super(const AsyncLoading()) {
|
||||
_bootstrap();
|
||||
AuthController(
|
||||
this._dio, {
|
||||
required PreferencesStorage preferences,
|
||||
required AuthSessionCoordinator coordinator,
|
||||
bool autoBootstrap = true,
|
||||
}) : _preferences = preferences,
|
||||
_coordinator = coordinator,
|
||||
super(const AsyncLoading()) {
|
||||
_coordinator.addListener(_onSessionEnded);
|
||||
if (autoBootstrap) _bootstrap();
|
||||
}
|
||||
|
||||
final Dio _dio;
|
||||
final PreferencesStorage _preferences;
|
||||
final AuthSessionCoordinator _coordinator;
|
||||
|
||||
AuthApi get _api => AuthApi(_dio);
|
||||
|
||||
Future<void> retryBootstrap() => _bootstrap();
|
||||
|
||||
Future<void> _bootstrap() async {
|
||||
final token = await loadJwt();
|
||||
state = const AsyncLoading();
|
||||
final token = await _coordinator.restore();
|
||||
if (token == null || token.isEmpty) {
|
||||
state = const AsyncData(GuestAuthState());
|
||||
return;
|
||||
}
|
||||
final phone = StorageService.to.getString(storageJinzhiMockLoggedIn) ?? '';
|
||||
state = AsyncData(
|
||||
LoggedInAuthState(
|
||||
user: JinzhiUser(
|
||||
phone: phone.isEmpty ? '13800000000' : phone,
|
||||
nickname: '金值用户',
|
||||
createdAt: DateTime(2026, 6, 18),
|
||||
),
|
||||
token: token,
|
||||
),
|
||||
);
|
||||
final cached = _readCachedProfile();
|
||||
if (cached != null) {
|
||||
state = AsyncData(
|
||||
LoggedInAuthState(user: cached, token: token, isVerified: false),
|
||||
);
|
||||
await _persistProfile(cached);
|
||||
}
|
||||
await _verifyProfile(token, _coordinator.generation);
|
||||
}
|
||||
|
||||
Future<void> sendCode(String phone) async {
|
||||
await Future<void>.delayed(const Duration(milliseconds: 200));
|
||||
Future<void> _verifyProfile(String token, int generation) async {
|
||||
try {
|
||||
final user = await _api.getProfile();
|
||||
if (generation != _coordinator.generation) return;
|
||||
await _persistProfile(user);
|
||||
state = AsyncData(
|
||||
LoggedInAuthState(user: user, token: token, isVerified: true),
|
||||
);
|
||||
} catch (error, stackTrace) {
|
||||
if (generation != _coordinator.generation) return;
|
||||
final exception = ApiException.from(error, stackTrace: stackTrace);
|
||||
if (await _coordinator.expireIfNeeded(exception)) {
|
||||
return;
|
||||
}
|
||||
// Any non-session error (transport, 403, 429, 5xx, decode) preserves a
|
||||
// stale cached session. With no profile there is nothing safe to render,
|
||||
// but the credential still remains available for retry.
|
||||
if (state.valueOrNull is LoggedInAuthState) return;
|
||||
state = AsyncError(exception, stackTrace);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> sendCode(String phone) => _api.sendSmsCode(phone);
|
||||
|
||||
Future<void> verifyCode(String phone, String code) async {
|
||||
final token = 'mock-jinzhi-token-$phone-$code';
|
||||
await saveJwt(token);
|
||||
await StorageService.to.setString(storageJinzhiMockLoggedIn, phone);
|
||||
state = AsyncData(
|
||||
LoggedInAuthState(
|
||||
user: JinzhiUser(
|
||||
phone: phone,
|
||||
nickname: '金值用户',
|
||||
createdAt: DateTime.now(),
|
||||
),
|
||||
token: token,
|
||||
),
|
||||
);
|
||||
final reply = await _api.verifySmsCode(phone, code);
|
||||
await _persistLogin(reply);
|
||||
}
|
||||
|
||||
/// Test environment reuses Yanting's one-key verification endpoint until
|
||||
/// 金值 has its own account contract.
|
||||
Future<void> verifyOneKeyLogin(String loginToken) async {
|
||||
final reply = await OneKeyLoginApi(_dio).verify(loginToken);
|
||||
await saveJwt(reply.token);
|
||||
await StorageService.to.setString(storageJinzhiMockLoggedIn, reply.phone);
|
||||
final reply = await _api.verifyOneKeyLogin(loginToken);
|
||||
await _persistLogin(reply);
|
||||
}
|
||||
|
||||
Future<void> _persistLogin(AuthLoginResult reply) async {
|
||||
await _coordinator.startSession(reply.accessToken);
|
||||
await _persistProfile(reply.user);
|
||||
state = AsyncData(
|
||||
LoggedInAuthState(
|
||||
user: JinzhiUser(
|
||||
phone: reply.phone,
|
||||
nickname: '金值用户',
|
||||
createdAt: DateTime.now(),
|
||||
),
|
||||
token: reply.token,
|
||||
),
|
||||
LoggedInAuthState(user: reply.user, token: reply.accessToken),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> logout() async {
|
||||
await clearJwt();
|
||||
await StorageService.to.setString(storageJinzhiMockLoggedIn, '');
|
||||
state = const AsyncData(GuestAuthState());
|
||||
await _coordinator.logout();
|
||||
}
|
||||
|
||||
Future<void> deleteAccount([String? code]) async {
|
||||
await logout();
|
||||
Future<void> deleteAccount(String code) async {
|
||||
final normalized = code.trim();
|
||||
if (normalized.isEmpty) {
|
||||
throw ArgumentError.value(code, 'code', 'must not be empty');
|
||||
}
|
||||
await _api.deleteAccount(normalized);
|
||||
await _coordinator.accountDeleted();
|
||||
}
|
||||
|
||||
Future<void> _persistProfile(JinzhiUser user) async {
|
||||
await _preferences.setString(storageUserId, user.id);
|
||||
await _preferences.setString(storageUserPhone, user.phone);
|
||||
await _preferences.setString(storageUserCreatedAt, user.createdAt);
|
||||
await _preferences.remove(_legacyMockLoggedInKey);
|
||||
}
|
||||
|
||||
JinzhiUser? _readCachedProfile() {
|
||||
final id = _preferences.getString(storageUserId)?.trim() ?? '';
|
||||
final phone =
|
||||
(_preferences.getString(storageUserPhone) ??
|
||||
_preferences.getString(_legacyMockLoggedInKey) ??
|
||||
'')
|
||||
.trim();
|
||||
final createdAt =
|
||||
_preferences.getString(storageUserCreatedAt)?.trim() ?? '';
|
||||
if (id.isEmpty && phone.isEmpty) return null;
|
||||
return JinzhiUser(id: id, phone: phone, createdAt: createdAt);
|
||||
}
|
||||
|
||||
void _onSessionEnded(SessionEndReason reason) {
|
||||
state = AsyncData(
|
||||
reason == SessionEndReason.expired
|
||||
? const SessionExpiredAuthState()
|
||||
: const GuestAuthState(),
|
||||
);
|
||||
}
|
||||
|
||||
bool get isLoggedIn => state.valueOrNull is LoggedInAuthState;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_coordinator.removeListener(_onSessionEnded);
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
|
||||
import '../../../common/network/api_exception.dart';
|
||||
import '../../../common/storage/preferences_storage.dart';
|
||||
import '../../../common/storage/secure_identity_storage.dart';
|
||||
import '../../../common/storage/storage_keys.dart';
|
||||
import '../../assets/data/mock_asset_repository.dart';
|
||||
|
||||
enum SessionEndReason { logout, expired, accountDeleted }
|
||||
|
||||
typedef SessionEndListener = void Function(SessionEndReason reason);
|
||||
|
||||
/// Owns session invalidation without depending on Navigator, Router or UI.
|
||||
class AuthSessionCoordinator {
|
||||
AuthSessionCoordinator(
|
||||
this._sessionStorage,
|
||||
this._preferences,
|
||||
this._assetRepository,
|
||||
);
|
||||
|
||||
final AuthSessionStorage _sessionStorage;
|
||||
final PreferencesStorage _preferences;
|
||||
final MockAssetRepository _assetRepository;
|
||||
final Set<SessionEndListener> _listeners = {};
|
||||
Future<void>? _ending;
|
||||
SessionEndReason? _endingReason;
|
||||
|
||||
int get generation => _sessionStorage.generation;
|
||||
|
||||
void addListener(SessionEndListener listener) => _listeners.add(listener);
|
||||
void removeListener(SessionEndListener listener) =>
|
||||
_listeners.remove(listener);
|
||||
|
||||
Future<String?> restore() => _sessionStorage.restore();
|
||||
|
||||
Future<void> startSession(String token) async {
|
||||
final ending = _ending;
|
||||
if (ending != null) await ending;
|
||||
await _sessionStorage.save(token);
|
||||
}
|
||||
|
||||
Future<bool> expireIfNeeded(ApiException exception) async {
|
||||
if (!exception.isUnauthorized) return false;
|
||||
final requestGeneration = exception.sessionGeneration;
|
||||
if (requestGeneration != null && requestGeneration != generation) {
|
||||
return false;
|
||||
}
|
||||
await end(SessionEndReason.expired);
|
||||
return true;
|
||||
}
|
||||
|
||||
Future<void> logout() => end(SessionEndReason.logout);
|
||||
|
||||
Future<void> accountDeleted() => end(SessionEndReason.accountDeleted);
|
||||
|
||||
Future<void> end(SessionEndReason reason) {
|
||||
_endingReason = _strongerReason(_endingReason, reason);
|
||||
final active = _ending;
|
||||
if (active != null) return active;
|
||||
final operation = _drainEnd();
|
||||
late final Future<void> tracked;
|
||||
tracked = operation.whenComplete(() {
|
||||
if (identical(_ending, tracked)) {
|
||||
_ending = null;
|
||||
_endingReason = null;
|
||||
}
|
||||
});
|
||||
_ending = tracked;
|
||||
return tracked;
|
||||
}
|
||||
|
||||
Future<void> _drainEnd() async {
|
||||
final assetAccountScope = MockAssetRepository.accountScope(
|
||||
userId: _preferences.getString(storageUserId),
|
||||
phone: _preferences.getString(storageUserPhone),
|
||||
);
|
||||
SessionEndReason? completedReason;
|
||||
while (completedReason != _endingReason) {
|
||||
final reason = _endingReason!;
|
||||
await _sessionStorage.clear(
|
||||
accountDeleted: reason == SessionEndReason.accountDeleted,
|
||||
);
|
||||
await Future.wait([
|
||||
_preferences.remove(storageUserId),
|
||||
_preferences.remove(storageUserPhone),
|
||||
_preferences.remove(storageUserCreatedAt),
|
||||
_preferences.remove('jinzhi_mock_logged_in'),
|
||||
if (reason == SessionEndReason.accountDeleted)
|
||||
_assetRepository.deleteAccountHoldings(assetAccountScope),
|
||||
]);
|
||||
completedReason = reason;
|
||||
}
|
||||
final finalReason = _endingReason!;
|
||||
for (final listener in List<SessionEndListener>.of(_listeners)) {
|
||||
listener(finalReason);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
SessionEndReason _strongerReason(
|
||||
SessionEndReason? current,
|
||||
SessionEndReason next,
|
||||
) {
|
||||
if (current == null) return next;
|
||||
return _reasonPriority(next) > _reasonPriority(current) ? next : current;
|
||||
}
|
||||
|
||||
int _reasonPriority(SessionEndReason reason) => switch (reason) {
|
||||
SessionEndReason.expired => 0,
|
||||
SessionEndReason.logout => 1,
|
||||
SessionEndReason.accountDeleted => 2,
|
||||
};
|
||||
|
||||
final authSessionCoordinatorProvider = Provider<AuthSessionCoordinator>(
|
||||
(ref) => AuthSessionCoordinator(
|
||||
ref.watch(authSessionStorageProvider),
|
||||
ref.watch(preferencesStorageProvider),
|
||||
ref.watch(mockAssetRepositoryProvider),
|
||||
),
|
||||
);
|
||||
@@ -24,6 +24,9 @@ abstract interface class OneKeyLoginGateway {
|
||||
|
||||
void setWidgetCallback(OneKeyLoginCallback? callback);
|
||||
|
||||
/// 仅在用户点按一键登录后调用,预热运营商授权页。
|
||||
Future<Object?> accelerate();
|
||||
|
||||
Future<void> requestLogin(UMCustomModel uiConfig);
|
||||
|
||||
void closeAuthPage();
|
||||
@@ -65,6 +68,11 @@ class UmengOneKeyLoginGateway implements OneKeyLoginGateway {
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Object?> accelerate() {
|
||||
return UmengVerifySdk.accelerateLoginPageWithTimeout(3);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> requestLogin(UMCustomModel uiConfig) async {
|
||||
await UmengVerifySdk.getLoginTokenWithTimeout(3, uiConfig);
|
||||
@@ -137,15 +145,25 @@ class OneKeyLoginSdkSession {
|
||||
return _result.future;
|
||||
}
|
||||
|
||||
// Availability checking already pre-accelerates the carrier page. Start
|
||||
// the token request directly here; the SDK performs its own preflight when
|
||||
// needed and reports its timeout/error through the registered callback.
|
||||
// 预热必须由用户显式点击触发;不在启动或隐私同意阶段调用它。
|
||||
unawaited(_startNativeLogin(uiConfig));
|
||||
return _result.future;
|
||||
}
|
||||
|
||||
Future<void> _startNativeLogin(UMCustomModel uiConfig) async {
|
||||
try {
|
||||
// 与 jinzhi-flutter-app 保持一致。未预热时 SDK 会在此处隐式预热,
|
||||
// 在部分 Android 机型上更容易超时或直接回调失败。
|
||||
final preheat = await _gateway.accelerate();
|
||||
if (kDebugMode && preheat is Map && preheat['ret'] != null) {
|
||||
// `ret` 是运营商预热失败码;插件原生层未将它输出到 logcat。
|
||||
// 预热失败不阻断接下来的 getLoginToken,SDK 会自行再试一次。
|
||||
debugPrint(
|
||||
'OneKeyLogin preheat failed: vendor=${preheat['vendor']}, '
|
||||
'ret=${preheat['ret']}',
|
||||
);
|
||||
}
|
||||
if (_terminal) return;
|
||||
_gateway.setTokenCallback(_handleTokenResult);
|
||||
_gateway.setWidgetCallback((Object? _) {});
|
||||
_gateway.setUiCallback(_handleUiResult);
|
||||
@@ -186,6 +204,15 @@ class OneKeyLoginSdkSession {
|
||||
|
||||
void _handleTokenResult(Object? result) {
|
||||
final code = extractUmengLoginCode(result);
|
||||
if (kDebugMode && !isUmengTokenSuccess(result)) {
|
||||
final map = result is Map ? result : const <Object?, Object?>{};
|
||||
// 本地插件原先丢弃了 onTokenFailed 的 ret;只记录诊断字段,绝不记录 token。
|
||||
debugPrint(
|
||||
'OneKeyLogin Android/iOS SDK result: code=$code, '
|
||||
'msg=${map['msg']}, vendor=${map['vendorName']}, '
|
||||
'carrierError=${map['carrierFailedResultData']}',
|
||||
);
|
||||
}
|
||||
// On iOS, 700002 only means the user tapped the authorization page's
|
||||
// login button. It is followed by a separate 600000 (or 600xxx) token
|
||||
// callback, so completing the session here discards that callback.
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
import 'package:dio/dio.dart';
|
||||
|
||||
import '../../../common/network/dio_provider.dart';
|
||||
import '../../../models/pb/jinzhi/v1/account.pb.dart' as pb;
|
||||
import 'auth_models.dart';
|
||||
|
||||
/// 金值账户的 protobuf-over-HTTP 客户端。
|
||||
class AuthApi {
|
||||
const AuthApi(this._dio);
|
||||
|
||||
final Dio _dio;
|
||||
|
||||
Future<int> sendSmsCode(String phone) async {
|
||||
final response = await _dio.post<List<int>>(
|
||||
'/api/v1/auth/sms/send',
|
||||
data: pb.SendSmsCodeRequest(phone: phone).writeToBuffer(),
|
||||
options: protobufOptions(),
|
||||
);
|
||||
return pb.SendSmsCodeReply.fromBuffer(
|
||||
response.data ?? const <int>[],
|
||||
).expiresIn;
|
||||
}
|
||||
|
||||
Future<AuthLoginResult> verifySmsCode(String phone, String code) async {
|
||||
final response = await _dio.post<List<int>>(
|
||||
'/api/v1/auth/sms/verify',
|
||||
data: pb.VerifySmsCodeRequest(phone: phone, code: code).writeToBuffer(),
|
||||
options: protobufOptions(),
|
||||
);
|
||||
return AuthLoginResult.fromProto(
|
||||
pb.VerifySmsCodeReply.fromBuffer(response.data ?? const <int>[]),
|
||||
);
|
||||
}
|
||||
|
||||
Future<AuthLoginResult> verifyOneKeyLogin(String loginToken) async {
|
||||
final response = await _dio.post<List<int>>(
|
||||
'/api/v1/auth/onekey/verify',
|
||||
data: pb.VerifyOneKeyLoginRequest(loginToken: loginToken).writeToBuffer(),
|
||||
options: protobufOptions(),
|
||||
);
|
||||
return AuthLoginResult.fromProto(
|
||||
pb.VerifySmsCodeReply.fromBuffer(response.data ?? const <int>[]),
|
||||
);
|
||||
}
|
||||
|
||||
Future<JinzhiUser> getProfile() async {
|
||||
final response = await _dio.get<List<int>>(
|
||||
'/api/v1/me',
|
||||
options: protobufOptions(),
|
||||
);
|
||||
final reply = pb.ProfileReply.fromBuffer(response.data ?? const <int>[]);
|
||||
if (!reply.hasUser()) {
|
||||
throw const FormatException('账号资料响应缺少 user');
|
||||
}
|
||||
return _userFromProto(reply.user);
|
||||
}
|
||||
|
||||
Future<void> deleteAccount(String smsCode) async {
|
||||
await _dio.delete<List<int>>(
|
||||
'/api/v1/me',
|
||||
data: pb.DeleteAccountRequest(smsCode: smsCode).writeToBuffer(),
|
||||
options: protobufOptions(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class AuthLoginResult {
|
||||
const AuthLoginResult({
|
||||
required this.accessToken,
|
||||
required this.expiresIn,
|
||||
required this.user,
|
||||
});
|
||||
|
||||
final String accessToken;
|
||||
final int expiresIn;
|
||||
final JinzhiUser user;
|
||||
|
||||
factory AuthLoginResult.fromProto(pb.VerifySmsCodeReply reply) {
|
||||
if (reply.accessToken.trim().isEmpty || !reply.hasUser()) {
|
||||
throw const FormatException('登录响应缺少 access_token 或 user');
|
||||
}
|
||||
return AuthLoginResult(
|
||||
accessToken: reply.accessToken.trim(),
|
||||
expiresIn: reply.expiresIn,
|
||||
user: _userFromProto(reply.user),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
JinzhiUser _userFromProto(pb.UserInfo user) => JinzhiUser(
|
||||
id: user.id.trim(),
|
||||
phone: user.phone.trim(),
|
||||
createdAt: user.createdAt.trim(),
|
||||
);
|
||||
@@ -1,13 +1,15 @@
|
||||
class JinzhiUser {
|
||||
const JinzhiUser({
|
||||
required this.id,
|
||||
required this.phone,
|
||||
required this.nickname,
|
||||
required this.createdAt,
|
||||
});
|
||||
|
||||
final String id;
|
||||
final String phone;
|
||||
final String nickname;
|
||||
final DateTime createdAt;
|
||||
final String createdAt;
|
||||
|
||||
String get nickname => '金值用户';
|
||||
}
|
||||
|
||||
sealed class AuthState {
|
||||
@@ -18,9 +20,18 @@ class GuestAuthState extends AuthState {
|
||||
const GuestAuthState();
|
||||
}
|
||||
|
||||
class SessionExpiredAuthState extends AuthState {
|
||||
const SessionExpiredAuthState();
|
||||
}
|
||||
|
||||
class LoggedInAuthState extends AuthState {
|
||||
const LoggedInAuthState({required this.user, required this.token});
|
||||
const LoggedInAuthState({
|
||||
required this.user,
|
||||
required this.token,
|
||||
this.isVerified = true,
|
||||
});
|
||||
|
||||
final JinzhiUser user;
|
||||
final String token;
|
||||
final bool isVerified;
|
||||
}
|
||||
@@ -1,92 +0,0 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:dio/dio.dart';
|
||||
|
||||
import '../../../common/network/dio_provider.dart';
|
||||
|
||||
/// Compatible with Yanting's protobuf `/api/v1/auth/onekey/verify` contract.
|
||||
class OneKeyLoginApi {
|
||||
OneKeyLoginApi(this._dio);
|
||||
final Dio _dio;
|
||||
|
||||
Future<OneKeyLoginReply> verify(String loginToken) async {
|
||||
final bytes = utf8.encode(loginToken);
|
||||
final request = Uint8List.fromList([
|
||||
0x0A,
|
||||
..._varint(bytes.length),
|
||||
...bytes,
|
||||
]);
|
||||
final response = await _dio.post<List<int>>(
|
||||
'/api/v1/auth/onekey/verify',
|
||||
data: request,
|
||||
options: protobufOptions(),
|
||||
);
|
||||
return _decode(Uint8List.fromList(response.data ?? const []));
|
||||
}
|
||||
|
||||
OneKeyLoginReply _decode(Uint8List data) {
|
||||
final fields = _fields(data);
|
||||
final token = utf8.decode(fields[1] ?? const []);
|
||||
final user = _fields(Uint8List.fromList(fields[3] ?? const []));
|
||||
if (token.isEmpty) throw StateError('一键登录服务未返回登录凭证');
|
||||
return OneKeyLoginReply(
|
||||
token: token,
|
||||
phone: utf8.decode(user[2] ?? const []),
|
||||
);
|
||||
}
|
||||
|
||||
Map<int, List<int>> _fields(Uint8List data) {
|
||||
final result = <int, List<int>>{};
|
||||
var i = 0;
|
||||
while (i < data.length) {
|
||||
final tag = _readVarint(data, i);
|
||||
i = tag.$2;
|
||||
final wireType = tag.$1 & 7;
|
||||
if (wireType == 0) {
|
||||
i = _readVarint(data, i).$2;
|
||||
continue;
|
||||
}
|
||||
if (wireType != 2) {
|
||||
throw const FormatException('Unsupported protobuf field');
|
||||
}
|
||||
final length = _readVarint(data, i);
|
||||
i = length.$2;
|
||||
final end = i + length.$1;
|
||||
if (end > data.length) {
|
||||
throw const FormatException('Invalid protobuf payload');
|
||||
}
|
||||
result[tag.$1 >> 3] = data.sublist(i, end);
|
||||
i = end;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
(int, int) _readVarint(Uint8List data, int index) {
|
||||
var value = 0;
|
||||
var shift = 0;
|
||||
while (index < data.length) {
|
||||
final byte = data[index++];
|
||||
value |= (byte & 0x7f) << shift;
|
||||
if (byte & 0x80 == 0) return (value, index);
|
||||
shift += 7;
|
||||
}
|
||||
throw const FormatException('Invalid protobuf varint');
|
||||
}
|
||||
|
||||
List<int> _varint(int value) {
|
||||
final result = <int>[];
|
||||
while (value > 0x7f) {
|
||||
result.add((value & 0x7f) | 0x80);
|
||||
value >>= 7;
|
||||
}
|
||||
result.add(value);
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
class OneKeyLoginReply {
|
||||
const OneKeyLoginReply({required this.token, required this.phone});
|
||||
final String token;
|
||||
final String phone;
|
||||
}
|
||||
@@ -31,7 +31,20 @@ class DeleteAccountPage extends ConsumerWidget {
|
||||
),
|
||||
error: (e, _) => Scaffold(
|
||||
backgroundColor: cs.background,
|
||||
body: Center(child: Text('加载失败:$e')),
|
||||
body: Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Text('账号状态加载失败'),
|
||||
const SizedBox(height: 12),
|
||||
FilledButton.tonal(
|
||||
onPressed: () =>
|
||||
ref.read(authProvider.notifier).retryBootstrap(),
|
||||
child: const Text('重试'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
data: (authState) {
|
||||
if (authState is! LoggedInAuthState) {
|
||||
@@ -669,7 +682,9 @@ class _VerifySheetState extends ConsumerState<_VerifySheet> {
|
||||
final cs = ShadTheme.of(context).colorScheme;
|
||||
return Padding(
|
||||
// 键盘弹出时 sheet 跟随上移
|
||||
padding: EdgeInsets.only(bottom: MediaQuery.of(context).viewInsets.bottom),
|
||||
padding: EdgeInsets.only(
|
||||
bottom: MediaQuery.of(context).viewInsets.bottom,
|
||||
),
|
||||
child: SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 8, 20, 20),
|
||||
@@ -715,11 +730,7 @@ class _VerifySheetState extends ConsumerState<_VerifySheet> {
|
||||
return [
|
||||
Text(
|
||||
'正在清除账户数据,请稍候',
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
height: 1.55,
|
||||
color: cs.mutedForeground,
|
||||
),
|
||||
style: TextStyle(fontSize: 13, height: 1.55, color: cs.mutedForeground),
|
||||
),
|
||||
const SizedBox(height: 28),
|
||||
Center(
|
||||
@@ -737,11 +748,7 @@ class _VerifySheetState extends ConsumerState<_VerifySheet> {
|
||||
return [
|
||||
Text(
|
||||
'为保障账户安全,注销前需验证您的手机号。验证码将发送至 ${_maskPhone(widget.phone)}。',
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
height: 1.55,
|
||||
color: cs.mutedForeground,
|
||||
),
|
||||
style: TextStyle(fontSize: 13, height: 1.55, color: cs.mutedForeground),
|
||||
),
|
||||
if (_error != null) ...[
|
||||
const SizedBox(height: 10),
|
||||
@@ -768,11 +775,7 @@ class _VerifySheetState extends ConsumerState<_VerifySheet> {
|
||||
return [
|
||||
Text(
|
||||
'验证码已发送至 ${_maskPhone(widget.phone)}',
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
height: 1.55,
|
||||
color: cs.mutedForeground,
|
||||
),
|
||||
style: TextStyle(fontSize: 13, height: 1.55, color: cs.mutedForeground),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
_PinInput(
|
||||
|
||||
@@ -16,7 +16,6 @@ import '../../../common/router/app_router.dart';
|
||||
import '../../../common/theme/spacing.dart';
|
||||
import '../../../common/widgets/circle_back_button.dart';
|
||||
import '../application/auth_controller.dart';
|
||||
import '../application/login_launcher.dart';
|
||||
|
||||
class LoginPage extends HookConsumerWidget {
|
||||
const LoginPage({super.key, this.next});
|
||||
@@ -32,7 +31,6 @@ class LoginPage extends HookConsumerWidget {
|
||||
final agreed = useState(false);
|
||||
final sending = useState(false);
|
||||
final verifying = useState(false);
|
||||
final oneKeyLoading = useState(false);
|
||||
final countdown = useState(0);
|
||||
final error = useState<String?>(null);
|
||||
final timer = useRef<Timer?>(null);
|
||||
@@ -49,11 +47,19 @@ class LoginPage extends HookConsumerWidget {
|
||||
Future<void> returnAfterLogin() async {
|
||||
final target = next?.trim();
|
||||
final router = GoRouter.of(context);
|
||||
if (target == null || target.isEmpty) {
|
||||
router.go(AppRoutes.profile);
|
||||
if (target != null && target.isNotEmpty) {
|
||||
router.go(target);
|
||||
return;
|
||||
}
|
||||
router.go(target);
|
||||
// The mall opens this page with no `next`. Pop back to its resident
|
||||
// WebView so the auth-state listener can deliver `enterWebMall` after
|
||||
// the app-mall token has been issued. Replacing the route here disposes
|
||||
// that WebView before the asynchronous hand-off can finish.
|
||||
if (context.canPop()) {
|
||||
context.pop();
|
||||
} else {
|
||||
router.go(AppRoutes.profile);
|
||||
}
|
||||
}
|
||||
|
||||
void startCountdown() {
|
||||
@@ -165,9 +171,6 @@ class LoginPage extends HookConsumerWidget {
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> launchOneKey() =>
|
||||
LoginLauncher.start(context, ref, next: next);
|
||||
|
||||
void onSendTap() {
|
||||
if (sending.value || countdown.value > 0) return;
|
||||
if (phoneDigits().length != 11) {
|
||||
@@ -227,8 +230,6 @@ class LoginPage extends HookConsumerWidget {
|
||||
agreed: agreed.value,
|
||||
onToggleAgreed: () => agreed.value = !agreed.value,
|
||||
onSend: onSendTap,
|
||||
oneKeyLoading: oneKeyLoading.value,
|
||||
onOneKey: launchOneKey,
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -247,8 +248,6 @@ class _PhoneStep extends StatelessWidget {
|
||||
required this.onToggleAgreed,
|
||||
required this.error,
|
||||
required this.onSend,
|
||||
required this.oneKeyLoading,
|
||||
required this.onOneKey,
|
||||
});
|
||||
|
||||
final ShadColorScheme cs;
|
||||
@@ -260,8 +259,6 @@ class _PhoneStep extends StatelessWidget {
|
||||
final VoidCallback onToggleAgreed;
|
||||
final String? error;
|
||||
final VoidCallback onSend;
|
||||
final bool oneKeyLoading;
|
||||
final VoidCallback onOneKey;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -306,13 +303,6 @@ class _PhoneStep extends StatelessWidget {
|
||||
dim: !phoneReady,
|
||||
onTap: onSend,
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
_PrimaryButton(
|
||||
label: '本机号码一键登录',
|
||||
enabled: !oneKeyLoading,
|
||||
loading: oneKeyLoading,
|
||||
onTap: onOneKey,
|
||||
),
|
||||
const SizedBox(height: 18),
|
||||
_AgreeRow(agreed: agreed, onToggle: onToggleAgreed, cs: cs),
|
||||
],
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
|
||||
import '../../web/application/mall_web_host_controller.dart';
|
||||
import 'mall_tab_config.dart';
|
||||
|
||||
enum MallEntryOpenResult { opened, unavailable, failed, alreadyOpening }
|
||||
|
||||
class MallEntryController {
|
||||
MallEntryController(this._ref);
|
||||
|
||||
final Ref _ref;
|
||||
bool _opening = false;
|
||||
|
||||
Future<MallEntryOpenResult> open(
|
||||
BuildContext context, {
|
||||
MallEntryRequest request = MallEntryRequest.home,
|
||||
MallTabConfig? initialConfig,
|
||||
}) async {
|
||||
if (_opening) return MallEntryOpenResult.alreadyOpening;
|
||||
_opening = true;
|
||||
try {
|
||||
var config = initialConfig;
|
||||
if (config?.isVisible != true) {
|
||||
config = await _ref.read(mallTabConfigProvider.notifier).ensureLoaded();
|
||||
}
|
||||
if (config?.isVisible != true || !context.mounted) {
|
||||
return MallEntryOpenResult.unavailable;
|
||||
}
|
||||
config = await _ref
|
||||
.read(mallTabConfigProvider.notifier)
|
||||
.recordClick(request: request);
|
||||
if (!config.isVisible || !context.mounted) {
|
||||
return MallEntryOpenResult.unavailable;
|
||||
}
|
||||
final uri = config.attributedUri(
|
||||
isDark: Theme.of(context).brightness == Brightness.dark,
|
||||
);
|
||||
if (uri == null) return MallEntryOpenResult.unavailable;
|
||||
// The bottom mall entry is always resident. Server open-mode metadata is
|
||||
// still retained for attribution, while explicit links inside H5 decide
|
||||
// their own external handoff through the WebView navigation policy.
|
||||
MallWebHostController.instance.open(uri);
|
||||
return MallEntryOpenResult.opened;
|
||||
} catch (_) {
|
||||
return MallEntryOpenResult.failed;
|
||||
} finally {
|
||||
_opening = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final mallEntryControllerProvider = Provider((ref) => MallEntryController(ref));
|
||||
@@ -0,0 +1,20 @@
|
||||
import '../../../common/storage/preferences_storage.dart';
|
||||
import '../../../common/storage/storage_keys.dart';
|
||||
|
||||
class MallEntryPromoStore {
|
||||
const MallEntryPromoStore(this._preferences);
|
||||
|
||||
final PreferencesStorage _preferences;
|
||||
|
||||
bool get hasOpenedMall => _preferences.getBool(storageMallEntryPromoOpened);
|
||||
|
||||
bool get hasSeenHomeBadge => _preferences.getBool(storageMallHomeBadgeSeen);
|
||||
|
||||
Future<void> markMallOpened() async {
|
||||
await _preferences.setBool(storageMallEntryPromoOpened, true);
|
||||
}
|
||||
|
||||
Future<void> markHomeBadgeSeen() async {
|
||||
await _preferences.setBool(storageMallHomeBadgeSeen, true);
|
||||
}
|
||||
}
|
||||
@@ -1,15 +1,40 @@
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:fixnum/fixnum.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:uuid/uuid.dart';
|
||||
|
||||
import '../../../common/network/dio_provider.dart';
|
||||
import '../../../models/pb/jinzhi/v1/ads.pb.dart' as pb;
|
||||
|
||||
/// 金值商城广告位的公共契约;商城是否展示完全由服务端配置决定。
|
||||
/// 标识打开商城的宿主项目。
|
||||
const mallSourceApp = 'jinzhi';
|
||||
|
||||
class MallEntryRequest {
|
||||
const MallEntryRequest({
|
||||
required this.sourceEntry,
|
||||
required this.hookType,
|
||||
this.entryScene,
|
||||
this.entryMetal,
|
||||
});
|
||||
|
||||
static const home = MallEntryRequest(
|
||||
sourceEntry: 'mall_tab',
|
||||
hookType: 'mall_tab',
|
||||
);
|
||||
static const quoteRetailGold = MallEntryRequest(
|
||||
sourceEntry: 'market_quote',
|
||||
hookType: 'quote_cta',
|
||||
entryScene: 'retail',
|
||||
entryMetal: 'au',
|
||||
);
|
||||
|
||||
final String sourceEntry;
|
||||
final String hookType;
|
||||
final String? entryScene;
|
||||
final String? entryMetal;
|
||||
}
|
||||
|
||||
class MallTabConfig {
|
||||
const MallTabConfig({
|
||||
required this.enabled,
|
||||
@@ -17,8 +42,15 @@ class MallTabConfig {
|
||||
this.title = '商城',
|
||||
this.adId = '',
|
||||
this.trackingId = '',
|
||||
this.clickId = '',
|
||||
this.openMode = 'in_app',
|
||||
this.badgeText = '',
|
||||
this.entryPromoEnabled = false,
|
||||
this.entryBadgeText = '',
|
||||
this.entryTipTitle = '',
|
||||
this.entryTipBody = '',
|
||||
this.quoteCtaEnabled = false,
|
||||
this.entryRequest = MallEntryRequest.home,
|
||||
this.allowedHosts = const <String>{},
|
||||
});
|
||||
|
||||
const MallTabConfig.disabled() : this(enabled: false, url: '');
|
||||
@@ -28,50 +60,99 @@ class MallTabConfig {
|
||||
final String title;
|
||||
final String adId;
|
||||
final String trackingId;
|
||||
final String clickId;
|
||||
final String openMode;
|
||||
final String badgeText;
|
||||
final bool entryPromoEnabled;
|
||||
final String entryBadgeText;
|
||||
final String entryTipTitle;
|
||||
final String entryTipBody;
|
||||
final bool quoteCtaEnabled;
|
||||
final MallEntryRequest entryRequest;
|
||||
final Set<String> allowedHosts;
|
||||
|
||||
bool get isVisible => enabled && initialUri != null;
|
||||
bool get opensInApp => openMode == 'in_app';
|
||||
bool get showsEntryBadge =>
|
||||
isVisible && entryPromoEnabled && entryBadgeText.trim().isNotEmpty;
|
||||
bool get showsEntryTip =>
|
||||
isVisible &&
|
||||
entryPromoEnabled &&
|
||||
(entryTipTitle.trim().isNotEmpty || entryTipBody.trim().isNotEmpty);
|
||||
|
||||
Uri? get initialUri => attributedUri(clickId: '');
|
||||
String get from {
|
||||
final value = Uri.tryParse(url.trim())?.queryParameters['from']?.trim();
|
||||
return value == null || value.isEmpty ? mallSourceApp : value;
|
||||
}
|
||||
|
||||
Uri? attributedUri({required String clickId, bool isDark = false}) {
|
||||
Uri? get initialUri => attributedUri();
|
||||
|
||||
Uri? attributedUri({
|
||||
String? clickId,
|
||||
bool isDark = false,
|
||||
MallEntryRequest? entry,
|
||||
}) {
|
||||
final uri = Uri.tryParse(url.trim());
|
||||
if (!_isAllowed(uri)) return null;
|
||||
final effectiveClickId = clickId ?? this.clickId;
|
||||
final effectiveEntry = entry ?? entryRequest;
|
||||
return uri!.replace(
|
||||
queryParameters: {
|
||||
...uri.queryParameters,
|
||||
'from': uri.queryParameters['from']?.trim().isNotEmpty == true
|
||||
? uri.queryParameters['from']!
|
||||
: mallSourceApp,
|
||||
'source_app': mallSourceApp,
|
||||
'source_entry': 'mall_tab',
|
||||
'from': from,
|
||||
'source_app': from,
|
||||
'source_entry': effectiveEntry.sourceEntry,
|
||||
'placement': 'mall_tab',
|
||||
'hook_type': 'mall_tab',
|
||||
'hook_type': effectiveEntry.hookType,
|
||||
'entry_scene': ?effectiveEntry.entryScene,
|
||||
'entry_metal': ?effectiveEntry.entryMetal,
|
||||
'target_app': 'mall',
|
||||
if (adId.isNotEmpty) 'ad_id': adId,
|
||||
if (trackingId.isNotEmpty) 'tracking_id': trackingId,
|
||||
if (clickId.isNotEmpty) 'click_id': clickId,
|
||||
if (clickId.isNotEmpty)
|
||||
if (effectiveClickId.isNotEmpty) 'click_id': effectiveClickId,
|
||||
if (effectiveClickId.isNotEmpty)
|
||||
'ts': DateTime.now().millisecondsSinceEpoch.toString(),
|
||||
'isNight': isDark ? '1' : '0',
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
MallTabConfig withClick(
|
||||
String value, {
|
||||
MallEntryRequest request = MallEntryRequest.home,
|
||||
}) => MallTabConfig(
|
||||
enabled: enabled,
|
||||
url: url,
|
||||
title: title,
|
||||
adId: adId,
|
||||
trackingId: trackingId,
|
||||
clickId: value,
|
||||
openMode: openMode,
|
||||
entryPromoEnabled: entryPromoEnabled,
|
||||
entryBadgeText: entryBadgeText,
|
||||
entryTipTitle: entryTipTitle,
|
||||
entryTipBody: entryTipBody,
|
||||
quoteCtaEnabled: quoteCtaEnabled,
|
||||
entryRequest: request,
|
||||
allowedHosts: allowedHosts,
|
||||
);
|
||||
|
||||
static bool _isAllowed(Uri? uri) {
|
||||
if (uri == null || uri.host.isEmpty) return false;
|
||||
if (uri.scheme == 'https') return true;
|
||||
if (uri.scheme.toLowerCase() == 'https') return true;
|
||||
return !kReleaseMode &&
|
||||
uri.scheme == 'http' &&
|
||||
const {'localhost', '127.0.0.1', '::1'}.contains(uri.host);
|
||||
uri.scheme.toLowerCase() == 'http' &&
|
||||
const {
|
||||
'localhost',
|
||||
'127.0.0.1',
|
||||
'::1',
|
||||
}.contains(uri.host.toLowerCase());
|
||||
}
|
||||
}
|
||||
|
||||
class MallTabConfigController extends StateNotifier<MallTabConfig> {
|
||||
MallTabConfigController(this._dio) : super(const MallTabConfig.disabled()) {
|
||||
refresh();
|
||||
MallTabConfigController(this._dio, {bool autoRefresh = true})
|
||||
: super(const MallTabConfig.disabled()) {
|
||||
if (autoRefresh) refresh();
|
||||
}
|
||||
|
||||
final Dio _dio;
|
||||
@@ -90,54 +171,51 @@ class MallTabConfigController extends StateNotifier<MallTabConfig> {
|
||||
queryParameters: const {'placement': 'mall_tab'},
|
||||
options: protobufOptions(),
|
||||
);
|
||||
final reply = _ProtoMessage(response.data ?? const []).message(2);
|
||||
final enabled = _ProtoMessage(response.data ?? const []).integer(1) != 0;
|
||||
final trackedUrl = reply.string(14);
|
||||
final h5Url = reply.string(9);
|
||||
final url = _validUrl(trackedUrl) ?? _validUrl(h5Url);
|
||||
if (!enabled || url == null) {
|
||||
state = const MallTabConfig.disabled();
|
||||
return;
|
||||
}
|
||||
state = MallTabConfig(
|
||||
enabled: true,
|
||||
url: url,
|
||||
title: _nonEmpty(reply.string(4), '商城'),
|
||||
adId: reply.string(1),
|
||||
trackingId: reply.string(10),
|
||||
openMode: _openMode(reply.integer(15)),
|
||||
badgeText: reply.string(17).trim(),
|
||||
);
|
||||
await _report('ad_impression');
|
||||
state = parseMallTabConfigResponse(response.data ?? const <int>[]);
|
||||
if (!state.isVisible) return;
|
||||
await _report(state, 'ad_impression');
|
||||
} catch (_) {
|
||||
state = const MallTabConfig.disabled();
|
||||
}
|
||||
}
|
||||
|
||||
Future<String?> recordClick() async {
|
||||
if (!state.isVisible) return null;
|
||||
final clickId = const Uuid().v4();
|
||||
Future<MallTabConfig> recordClick({
|
||||
MallEntryRequest request = MallEntryRequest.home,
|
||||
}) async {
|
||||
if (!state.isVisible) return state;
|
||||
final clicked = state.withClick(const Uuid().v4(), request: request);
|
||||
state = clicked;
|
||||
try {
|
||||
await _report('ad_click', clickId: clickId);
|
||||
await _report(clicked, 'ad_click');
|
||||
} catch (_) {
|
||||
// 归因上报失败不能阻断商城入口。
|
||||
}
|
||||
return clickId;
|
||||
return clicked;
|
||||
}
|
||||
|
||||
Future<void> _report(String event, {String clickId = ''}) =>
|
||||
_dio.post<List<int>>(
|
||||
'/api/v1/ads/events',
|
||||
data: _ProtoWriter()
|
||||
.string(1, event)
|
||||
.string(2, 'mall_tab')
|
||||
.string(3, state.adId)
|
||||
.string(4, state.trackingId)
|
||||
.string(5, clickId)
|
||||
.integer(6, DateTime.now().millisecondsSinceEpoch)
|
||||
.toBytes(),
|
||||
options: protobufOptions(),
|
||||
);
|
||||
Future<void> _report(MallTabConfig config, String event) async {
|
||||
final response = await _dio.post<List<int>>(
|
||||
'/api/v1/ads/events',
|
||||
data: pb.ReportAdEventRequest(
|
||||
eventName: event,
|
||||
placement: 'mall_tab',
|
||||
adId: config.adId,
|
||||
trackingId: config.trackingId,
|
||||
clickId: config.clickId,
|
||||
clientTsMs: Int64(DateTime.now().millisecondsSinceEpoch),
|
||||
attrs: <String, String>{
|
||||
'source_app': config.from,
|
||||
'source_entry': config.entryRequest.sourceEntry,
|
||||
'hook_type': config.entryRequest.hookType,
|
||||
'entry_scene': ?config.entryRequest.entryScene,
|
||||
'entry_metal': ?config.entryRequest.entryMetal,
|
||||
'open_mode': config.openMode,
|
||||
},
|
||||
).writeToBuffer(),
|
||||
options: protobufOptions(),
|
||||
);
|
||||
pb.ReportAdEventReply.fromBuffer(response.data ?? const <int>[]);
|
||||
}
|
||||
}
|
||||
|
||||
final mallTabConfigProvider =
|
||||
@@ -145,92 +223,74 @@ final mallTabConfigProvider =
|
||||
(ref) => MallTabConfigController(ref.watch(dioProvider)),
|
||||
);
|
||||
|
||||
Map<String, dynamic> buildEnterWebMallBridgeArguments({
|
||||
required MallTabConfig config,
|
||||
required bool logged,
|
||||
Uri? currentUri,
|
||||
bool isDark = false,
|
||||
String? authToken,
|
||||
String? scene,
|
||||
String? sourceApp,
|
||||
}) {
|
||||
final arguments = <String, dynamic>{
|
||||
...?currentUri?.queryParameters,
|
||||
...?config.attributedUri(isDark: isDark)?.queryParameters,
|
||||
'from': config.from,
|
||||
'logged': logged,
|
||||
};
|
||||
final token = authToken?.trim() ?? '';
|
||||
if (token.isNotEmpty) {
|
||||
arguments['auth_token'] = token;
|
||||
arguments['authToken'] = token;
|
||||
}
|
||||
final resolvedScene = scene?.trim() ?? '';
|
||||
if (resolvedScene.isNotEmpty) arguments['scene'] = resolvedScene;
|
||||
final resolvedSourceApp = sourceApp?.trim() ?? '';
|
||||
if (resolvedSourceApp.isNotEmpty) {
|
||||
arguments['from'] = resolvedSourceApp;
|
||||
arguments['source_app'] = resolvedSourceApp;
|
||||
}
|
||||
return arguments;
|
||||
}
|
||||
|
||||
MallTabConfig parseMallTabConfigResponse(List<int> bytes) {
|
||||
final reply = pb.GetAdReply.fromBuffer(bytes);
|
||||
if (!reply.enabled || !reply.hasAd()) {
|
||||
return const MallTabConfig.disabled();
|
||||
}
|
||||
final creative = reply.ad;
|
||||
final url = _firstAllowedUrl(creative.trackedUrl, creative.h5Url);
|
||||
if (url == null) return const MallTabConfig.disabled();
|
||||
final promotion = creative.hasMallPromotion() ? creative.mallPromotion : null;
|
||||
return MallTabConfig(
|
||||
enabled: true,
|
||||
url: url,
|
||||
title: _nonEmpty(creative.title, '商城'),
|
||||
adId: creative.adId,
|
||||
trackingId: creative.trackingId,
|
||||
openMode: _openMode(creative.openMode),
|
||||
entryPromoEnabled: creative.entryPromoEnabled,
|
||||
entryBadgeText: creative.entryBadgeText.trim(),
|
||||
entryTipTitle: creative.entryTipTitle.trim(),
|
||||
entryTipBody: creative.entryTipBody.trim(),
|
||||
quoteCtaEnabled: promotion?.quoteCtaEnabled ?? false,
|
||||
allowedHosts: <String>{Uri.parse(url).host.toLowerCase()},
|
||||
);
|
||||
}
|
||||
|
||||
String _nonEmpty(String value, String fallback) =>
|
||||
value.trim().isEmpty ? fallback : value.trim();
|
||||
|
||||
String? _validUrl(String raw) {
|
||||
final uri = Uri.tryParse(raw.trim());
|
||||
return MallTabConfig._isAllowed(uri) ? uri.toString() : null;
|
||||
}
|
||||
|
||||
String _openMode(int value) => switch (value) {
|
||||
1 => 'native_app',
|
||||
2 => 'in_app',
|
||||
String _openMode(pb.AdOpenMode value) => switch (value) {
|
||||
pb.AdOpenMode.AD_OPEN_MODE_NATIVE_APP => 'native_app',
|
||||
pb.AdOpenMode.AD_OPEN_MODE_IN_APP => 'in_app',
|
||||
_ => 'external',
|
||||
};
|
||||
|
||||
class _ProtoMessage {
|
||||
_ProtoMessage(this._bytes);
|
||||
final List<int> _bytes;
|
||||
|
||||
int integer(int wanted) =>
|
||||
_fields()[wanted] is int ? _fields()[wanted]! as int : 0;
|
||||
String string(int wanted) => _fields()[wanted] is List<int>
|
||||
? String.fromCharCodes(_fields()[wanted]! as List<int>)
|
||||
: '';
|
||||
_ProtoMessage message(int wanted) => _ProtoMessage(
|
||||
_fields()[wanted] is List<int> ? _fields()[wanted]! as List<int> : const [],
|
||||
);
|
||||
|
||||
Map<int, Object> _fields() {
|
||||
final values = <int, Object>{};
|
||||
var index = 0;
|
||||
int readVarint() {
|
||||
var value = 0;
|
||||
var shift = 0;
|
||||
while (index < _bytes.length && shift < 64) {
|
||||
final byte = _bytes[index++];
|
||||
value |= (byte & 127) << shift;
|
||||
if (byte < 128) return value;
|
||||
shift += 7;
|
||||
}
|
||||
throw const FormatException('Invalid protobuf');
|
||||
}
|
||||
|
||||
while (index < _bytes.length) {
|
||||
final key = readVarint();
|
||||
final tag = key >> 3;
|
||||
switch (key & 7) {
|
||||
case 0:
|
||||
values[tag] = readVarint();
|
||||
case 2:
|
||||
final length = readVarint();
|
||||
final end = index + length;
|
||||
if (end > _bytes.length) {
|
||||
throw const FormatException('Invalid protobuf');
|
||||
}
|
||||
values[tag] = _bytes.sublist(index, end);
|
||||
index = end;
|
||||
default:
|
||||
throw const FormatException('Unsupported protobuf');
|
||||
}
|
||||
}
|
||||
return values;
|
||||
}
|
||||
}
|
||||
|
||||
class _ProtoWriter {
|
||||
final _bytes = BytesBuilder(copy: false);
|
||||
_ProtoWriter string(int tag, String value) {
|
||||
final data = value.codeUnits;
|
||||
_varint((tag << 3) | 2);
|
||||
_varint(data.length);
|
||||
_bytes.add(data);
|
||||
return this;
|
||||
}
|
||||
|
||||
_ProtoWriter integer(int tag, int value) {
|
||||
_varint(tag << 3);
|
||||
_varint(value);
|
||||
return this;
|
||||
}
|
||||
|
||||
Uint8List toBytes() => _bytes.toBytes();
|
||||
void _varint(int value) {
|
||||
while (value > 127) {
|
||||
_bytes.addByte((value & 127) | 128);
|
||||
value >>= 7;
|
||||
}
|
||||
_bytes.addByte(value);
|
||||
String? _firstAllowedUrl(String trackedUrl, String h5Url) {
|
||||
for (final raw in <String>[trackedUrl, h5Url]) {
|
||||
final uri = Uri.tryParse(raw.trim());
|
||||
if (MallTabConfig._isAllowed(uri)) return uri.toString();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
|
||||
import '../../../common/network/dio_provider.dart';
|
||||
import '../../../common/network/protobuf_wire_reader.dart';
|
||||
|
||||
class AppMallAuthToken {
|
||||
const AppMallAuthToken({
|
||||
required this.authToken,
|
||||
required this.expiresIn,
|
||||
required this.sourceApp,
|
||||
required this.scene,
|
||||
});
|
||||
|
||||
final String authToken;
|
||||
final int expiresIn;
|
||||
final String sourceApp;
|
||||
final String scene;
|
||||
}
|
||||
|
||||
class MallAuthApi {
|
||||
const MallAuthApi(this._dio);
|
||||
final Dio _dio;
|
||||
|
||||
Future<AppMallAuthToken> issueAppMallAuthToken() async {
|
||||
final response = await _dio.post<List<int>>(
|
||||
'/api/v1/auth/mall/app-token',
|
||||
// Dio serializes a List<int> as the UTF-8 text "[]". This endpoint
|
||||
// expects an empty protobuf message, whose wire representation is zero
|
||||
// bytes; Uint8List keeps the request body binary and untransformed.
|
||||
data: Uint8List(0),
|
||||
options: protobufOptions(),
|
||||
);
|
||||
final reply = ProtobufWireMessage(response.data ?? const <int>[]);
|
||||
final token = reply.string(1).trim();
|
||||
final expiresIn = reply.integer(2);
|
||||
final sourceApp = reply.string(3).trim();
|
||||
final scene = reply.string(4).trim();
|
||||
if (token.isEmpty || expiresIn <= 0 || sourceApp.isEmpty || scene.isEmpty) {
|
||||
throw const FormatException('Incomplete mall auth token response');
|
||||
}
|
||||
return AppMallAuthToken(
|
||||
authToken: token,
|
||||
expiresIn: expiresIn,
|
||||
sourceApp: sourceApp,
|
||||
scene: scene,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
final mallAuthApiProvider = Provider(
|
||||
(ref) => MallAuthApi(ref.watch(dioProvider)),
|
||||
);
|
||||
@@ -0,0 +1,345 @@
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_svg/flutter_svg.dart';
|
||||
|
||||
String _withChinesePunctuation(String value) {
|
||||
const replacements = <String, String>{
|
||||
':': ':',
|
||||
',': ',',
|
||||
';': ';',
|
||||
'!': '!',
|
||||
'?': '?',
|
||||
'(': '(',
|
||||
')': ')',
|
||||
'[': '【',
|
||||
']': '】',
|
||||
'¥': '¥',
|
||||
};
|
||||
return value.split('').map((char) => replacements[char] ?? char).join();
|
||||
}
|
||||
|
||||
class MallEntryPromoTooltip extends StatefulWidget {
|
||||
const MallEntryPromoTooltip({
|
||||
super.key,
|
||||
required this.visible,
|
||||
required this.title,
|
||||
required this.body,
|
||||
required this.onCardTap,
|
||||
required this.child,
|
||||
});
|
||||
|
||||
final bool visible;
|
||||
final String title;
|
||||
final String body;
|
||||
final VoidCallback onCardTap;
|
||||
final Widget child;
|
||||
|
||||
@override
|
||||
State<MallEntryPromoTooltip> createState() => _MallEntryPromoTooltipState();
|
||||
}
|
||||
|
||||
class _MallEntryPromoTooltipState extends State<MallEntryPromoTooltip> {
|
||||
static const _cardWidth = 236.0;
|
||||
static const _cardHeight = 115.0;
|
||||
static const _anchorToCardBottom = 54.0;
|
||||
static const _targetCenterToCardRight = 23.0;
|
||||
static const _cardRightInset = 22.0;
|
||||
|
||||
final _targetKey = GlobalKey();
|
||||
OverlayEntry? _overlayEntry;
|
||||
bool _routeIsCurrent = true;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_scheduleVisibilitySync();
|
||||
}
|
||||
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
super.didChangeDependencies();
|
||||
_routeIsCurrent = ModalRoute.of(context)?.isCurrent ?? true;
|
||||
_scheduleVisibilitySync();
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant MallEntryPromoTooltip oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
_scheduleVisibilitySync();
|
||||
}
|
||||
|
||||
void _scheduleVisibilitySync() {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (!mounted) return;
|
||||
if (widget.visible && _routeIsCurrent) {
|
||||
_showOverlay();
|
||||
} else {
|
||||
_removeOverlay();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void _showOverlay() {
|
||||
final current = _overlayEntry;
|
||||
if (current != null) {
|
||||
current.markNeedsBuild();
|
||||
return;
|
||||
}
|
||||
final entry = OverlayEntry(builder: _buildOverlay);
|
||||
_overlayEntry = entry;
|
||||
Overlay.of(context, rootOverlay: true).insert(entry);
|
||||
}
|
||||
|
||||
void _removeOverlay() {
|
||||
final entry = _overlayEntry;
|
||||
if (entry == null) return;
|
||||
_overlayEntry = null;
|
||||
entry
|
||||
..remove()
|
||||
..dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_removeOverlay();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return KeyedSubtree(key: _targetKey, child: widget.child);
|
||||
}
|
||||
|
||||
Widget _buildOverlay(BuildContext overlayContext) {
|
||||
final targetContext = _targetKey.currentContext;
|
||||
final targetBox = targetContext?.findRenderObject();
|
||||
if (targetContext == null ||
|
||||
targetBox is! RenderBox ||
|
||||
!targetBox.attached) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
final overlayBox = Overlay.of(
|
||||
targetContext,
|
||||
rootOverlay: true,
|
||||
).context.findRenderObject();
|
||||
if (overlayBox is! RenderBox || !overlayBox.attached) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
final targetCenter = targetBox.localToGlobal(
|
||||
targetBox.size.center(Offset.zero),
|
||||
ancestor: overlayBox,
|
||||
);
|
||||
final title = _withChinesePunctuation(widget.title.trim());
|
||||
final body = _withChinesePunctuation(widget.body.trim());
|
||||
return Positioned.fill(
|
||||
child: LayoutBuilder(
|
||||
key: const ValueKey('mall-entry-promo-pass-through-layer'),
|
||||
builder: (context, constraints) {
|
||||
final overlaySize = Size(constraints.maxWidth, constraints.maxHeight);
|
||||
final cardWidth = math.min(
|
||||
_cardWidth,
|
||||
math.max(0.0, overlaySize.width - 40),
|
||||
);
|
||||
final desiredCardLeft =
|
||||
targetCenter.dx + _targetCenterToCardRight - cardWidth;
|
||||
final maximumCardLeft = math.max(
|
||||
20.0,
|
||||
overlaySize.width - cardWidth - _cardRightInset,
|
||||
);
|
||||
final cardLeft = desiredCardLeft
|
||||
.clamp(20.0, maximumCardLeft)
|
||||
.toDouble();
|
||||
final desiredCardTop =
|
||||
targetCenter.dy - _anchorToCardBottom - _cardHeight;
|
||||
final minimumTop = MediaQuery.paddingOf(context).top + 16;
|
||||
final maximumTop = math.max(
|
||||
minimumTop,
|
||||
overlaySize.height - _cardHeight - 20,
|
||||
);
|
||||
final cardTop = desiredCardTop
|
||||
.clamp(minimumTop, maximumTop)
|
||||
.toDouble();
|
||||
final cardBottom = cardTop + _cardHeight;
|
||||
final pointerLeft = (targetCenter.dx - 9)
|
||||
.clamp(0.0, overlaySize.width - 18)
|
||||
.toDouble();
|
||||
|
||||
return Stack(
|
||||
clipBehavior: Clip.none,
|
||||
children: [
|
||||
Positioned(
|
||||
left: cardLeft,
|
||||
top: cardTop,
|
||||
width: cardWidth,
|
||||
height: _cardHeight,
|
||||
child: Semantics(
|
||||
button: true,
|
||||
label: '进入商城',
|
||||
child: GestureDetector(
|
||||
key: const ValueKey('mall-entry-promo-card-action'),
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: widget.onCardTap,
|
||||
child: DecoratedBox(
|
||||
key: const ValueKey('mall-entry-promo-card'),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xE6FFFFFF),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
boxShadow: const [
|
||||
BoxShadow(
|
||||
color: Color(0x33000000),
|
||||
offset: Offset(0, 8),
|
||||
blurRadius: 20,
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 16),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
SvgPicture.asset(
|
||||
'assets/navigation/mall_entry_promo_gift.svg',
|
||||
key: const ValueKey('mall-entry-promo-gift'),
|
||||
width: 45,
|
||||
height: 40,
|
||||
fit: BoxFit.fill,
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
if (title.isNotEmpty)
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
height: 22,
|
||||
child: FittedBox(
|
||||
fit: BoxFit.scaleDown,
|
||||
child: Text(
|
||||
title,
|
||||
maxLines: 1,
|
||||
style: const TextStyle(
|
||||
color: Colors.black,
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
height: 1.375,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (title.isNotEmpty && body.isNotEmpty)
|
||||
const SizedBox(height: 4),
|
||||
if (body.isNotEmpty)
|
||||
Text(
|
||||
body,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(
|
||||
color: Color(0x99000000),
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w400,
|
||||
height: 17 / 12,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
left: pointerLeft,
|
||||
top: cardBottom - 1,
|
||||
width: 18,
|
||||
height: 16,
|
||||
child: IgnorePointer(
|
||||
child: SvgPicture.asset(
|
||||
'assets/navigation/mall_entry_promo_pointer.svg',
|
||||
key: const ValueKey('mall-entry-promo-pointer'),
|
||||
fit: BoxFit.fill,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class MallEntryPromoBadge extends StatelessWidget {
|
||||
const MallEntryPromoBadge({
|
||||
super.key,
|
||||
required this.visible,
|
||||
required this.label,
|
||||
required this.child,
|
||||
this.top = -4,
|
||||
this.right = -20,
|
||||
});
|
||||
|
||||
final bool visible;
|
||||
final String label;
|
||||
final Widget child;
|
||||
final double top;
|
||||
final double right;
|
||||
|
||||
static const _singleCharacterWidth = 30.0;
|
||||
static const _badgeHeight = 18.0;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final text = label.trim();
|
||||
if (!visible || text.isEmpty) return child;
|
||||
final isSingleCharacter = text.runes.length == 1;
|
||||
return Stack(
|
||||
clipBehavior: Clip.none,
|
||||
children: [
|
||||
child,
|
||||
Positioned(
|
||||
top: top,
|
||||
right: isSingleCharacter ? right : right + 8,
|
||||
child: IgnorePointer(
|
||||
child: Badge(
|
||||
key: const ValueKey('mall-entry-promo-badge'),
|
||||
backgroundColor: const Color(0xFFE7473C),
|
||||
textColor: Colors.white,
|
||||
largeSize: isSingleCharacter ? _badgeHeight : null,
|
||||
textStyle: const TextStyle(
|
||||
fontSize: 9,
|
||||
fontWeight: FontWeight.w600,
|
||||
height: 1,
|
||||
),
|
||||
padding: isSingleCharacter
|
||||
? EdgeInsets.zero
|
||||
: const EdgeInsets.symmetric(horizontal: 5, vertical: 2),
|
||||
label: isSingleCharacter
|
||||
? SizedBox(
|
||||
key: const ValueKey('mall-entry-promo-badge-label'),
|
||||
width: _singleCharacterWidth,
|
||||
height: _badgeHeight,
|
||||
child: Center(
|
||||
child: Text(
|
||||
text,
|
||||
maxLines: 1,
|
||||
textAlign: TextAlign.center,
|
||||
strutStyle: const StrutStyle(
|
||||
height: 1,
|
||||
leading: 0,
|
||||
forceStrutHeight: true,
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
: Text(
|
||||
text,
|
||||
key: const ValueKey('mall-entry-promo-badge-label'),
|
||||
maxLines: 1,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,8 @@ import 'package:shadcn_ui/shadcn_ui.dart';
|
||||
import '../../../common/domain/metal_type.dart';
|
||||
import '../../../common/domain/money.dart';
|
||||
import '../../../common/widgets/adaptive.dart';
|
||||
import '../../mall/application/mall_entry_controller.dart';
|
||||
import '../../mall/application/mall_tab_config.dart';
|
||||
import '../application/exchange_calculator_controller.dart';
|
||||
import '../application/market_controller.dart';
|
||||
import '../data/market_models.dart';
|
||||
@@ -93,6 +95,28 @@ class MarketPage extends ConsumerWidget {
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
FilledButton.icon(
|
||||
key: const ValueKey('market-mall-retail-cta'),
|
||||
onPressed: () async {
|
||||
final result = await ref
|
||||
.read(mallEntryControllerProvider)
|
||||
.open(
|
||||
context,
|
||||
request: MallEntryRequest.quoteRetailGold,
|
||||
);
|
||||
if (!context.mounted ||
|
||||
(result != MallEntryOpenResult.unavailable &&
|
||||
result != MallEntryOpenResult.failed)) {
|
||||
return;
|
||||
}
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('商城暂时无法打开,请稍后重试')),
|
||||
);
|
||||
},
|
||||
icon: const Icon(Icons.shopping_bag_outlined),
|
||||
label: const Text('买商城饰品(全款)'),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
_Card(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
|
||||
@@ -19,6 +19,8 @@ class ProfilePage extends ConsumerWidget {
|
||||
final auth = ref.watch(authProvider);
|
||||
final cs = ShadTheme.of(context).colorScheme;
|
||||
final loggedIn = auth.valueOrNull is LoggedInAuthState;
|
||||
final authLoading = auth.isLoading;
|
||||
final authFailed = auth.hasError;
|
||||
final currentUser = auth.valueOrNull is LoggedInAuthState
|
||||
? (auth.valueOrNull as LoggedInAuthState).user
|
||||
: null;
|
||||
@@ -100,8 +102,22 @@ class ProfilePage extends ConsumerWidget {
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
FilledButton.tonal(
|
||||
onPressed: () => LoginLauncher.start(context, ref),
|
||||
child: Text(loggedIn ? '切换账号' : '去登录'),
|
||||
onPressed: authLoading
|
||||
? null
|
||||
: authFailed
|
||||
? () => ref
|
||||
.read(authProvider.notifier)
|
||||
.retryBootstrap()
|
||||
: () => LoginLauncher.start(context, ref),
|
||||
child: Text(
|
||||
authLoading
|
||||
? '正在恢复账号…'
|
||||
: authFailed
|
||||
? '账号恢复失败,点击重试'
|
||||
: loggedIn
|
||||
? '切换账号'
|
||||
: '去登录',
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
OutlinedButton(
|
||||
|
||||
@@ -48,33 +48,20 @@ class UpgradeInfo {
|
||||
}
|
||||
}
|
||||
|
||||
// BLOCKED(proto): this endpoint has no authoritative generated Upgrade proto.
|
||||
// Keep its existing wire contract until that source becomes available.
|
||||
class UpdateApiClient {
|
||||
UpdateApiClient(
|
||||
this._dio, {
|
||||
required String apiPathPrefix,
|
||||
required String updatePackageName,
|
||||
}) : _apiPathPrefix = _normalizePrefix(apiPathPrefix),
|
||||
_updatePackageName = updatePackageName.trim();
|
||||
UpdateApiClient(this._dio);
|
||||
|
||||
final Dio _dio;
|
||||
final String _apiPathPrefix;
|
||||
final String _updatePackageName;
|
||||
|
||||
Options get _options {
|
||||
final options = protobufOptions();
|
||||
if (_updatePackageName.isEmpty) return options;
|
||||
return options.copyWith(
|
||||
headers: {...?options.headers, 'pkg': _updatePackageName},
|
||||
);
|
||||
}
|
||||
|
||||
Future<UpgradeInfo> checkUpgrade() async {
|
||||
final response = await _dio.post<List<int>>(
|
||||
_path('/sys/upgrade'),
|
||||
'/sys/upgrade',
|
||||
// Uint8List ensures Dio sends the empty protobuf as 0 bytes instead of
|
||||
// JSON-encoding an empty Dart List as `[]` (Content-Length: 2).
|
||||
data: Uint8List(0), // jinzhi.v1.GetUpgradeRequest
|
||||
options: _options,
|
||||
options: protobufOptions(),
|
||||
);
|
||||
return UpgradeInfo.fromProtobuf(response.data ?? const <int>[]);
|
||||
}
|
||||
@@ -84,33 +71,18 @@ class UpdateApiClient {
|
||||
required String source,
|
||||
}) async {
|
||||
await _dio.post<List<int>>(
|
||||
_path('/active/upgrade/click'),
|
||||
'/active/upgrade/click',
|
||||
data: _ProtoWriter()
|
||||
.string(1, buildId)
|
||||
.string(2, source)
|
||||
.toBytes(), // jinzhi.v1.UpgradeClickRequest
|
||||
options: _options,
|
||||
options: protobufOptions(),
|
||||
);
|
||||
}
|
||||
|
||||
String _path(String endpoint) => '$_apiPathPrefix$endpoint';
|
||||
|
||||
static String _normalizePrefix(String value) {
|
||||
final trimmed = value.trim();
|
||||
if (trimmed.isEmpty || trimmed == '/') return '';
|
||||
final withLeadingSlash = trimmed.startsWith('/') ? trimmed : '/$trimmed';
|
||||
return withLeadingSlash.endsWith('/')
|
||||
? withLeadingSlash.substring(0, withLeadingSlash.length - 1)
|
||||
: withLeadingSlash;
|
||||
}
|
||||
}
|
||||
|
||||
final updateApiClientProvider = Provider<UpdateApiClient>(
|
||||
(ref) => UpdateApiClient(
|
||||
ref.watch(dioProvider),
|
||||
apiPathPrefix: ref.watch(appConfigProvider).apiPathPrefix,
|
||||
updatePackageName: ref.watch(appConfigProvider).updatePackageName,
|
||||
),
|
||||
(ref) => UpdateApiClient(ref.watch(dioProvider)),
|
||||
);
|
||||
|
||||
class _ProtoFields {
|
||||
|
||||
@@ -6,6 +6,7 @@ import 'package:go_router/go_router.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:package_info_plus/package_info_plus.dart';
|
||||
import 'package:shadcn_ui/shadcn_ui.dart';
|
||||
import 'package:url_launcher/url_launcher_string.dart';
|
||||
|
||||
import '../../../common/config/app_h5_urls.dart';
|
||||
import '../../../common/push/notification_permission.dart';
|
||||
@@ -32,6 +33,7 @@ class SettingsPage extends HookConsumerWidget {
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final cs = ShadTheme.of(context).colorScheme;
|
||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||
final themeMode = ref.watch(themeModeControllerProvider);
|
||||
final profileSettings = ref.watch(profileSettingsControllerProvider);
|
||||
final authAsync = ref.watch(authProvider);
|
||||
@@ -198,16 +200,28 @@ class SettingsPage extends HookConsumerWidget {
|
||||
_NavRow(
|
||||
icon: FlutterRemix.file_list_3_line,
|
||||
label: '用户协议',
|
||||
onTap: () => context.push(
|
||||
'${AppRoutes.web}?url=${Uri.encodeComponent(AppH5UrlsHelper.withCacheBuster(AppH5Urls.userProtocolUrl))}',
|
||||
onTap: () => launchUrlString(
|
||||
AppH5UrlsHelper.withCacheBuster(
|
||||
AppH5UrlsHelper.buildUrlWithNight(
|
||||
AppH5Urls.userProtocolUrl,
|
||||
isDark,
|
||||
),
|
||||
),
|
||||
mode: LaunchMode.inAppBrowserView,
|
||||
),
|
||||
),
|
||||
Divider(height: 1, thickness: 1, color: cs.border),
|
||||
_NavRow(
|
||||
icon: FlutterRemix.shield_check_line,
|
||||
label: '隐私政策',
|
||||
onTap: () => context.push(
|
||||
'${AppRoutes.web}?url=${Uri.encodeComponent(AppH5UrlsHelper.withCacheBuster(AppH5Urls.privacyUrl))}',
|
||||
onTap: () => launchUrlString(
|
||||
AppH5UrlsHelper.withCacheBuster(
|
||||
AppH5UrlsHelper.buildUrlWithNight(
|
||||
AppH5Urls.privacyUrl,
|
||||
isDark,
|
||||
),
|
||||
),
|
||||
mode: LaunchMode.inAppBrowserView,
|
||||
),
|
||||
),
|
||||
Divider(height: 1, thickness: 1, color: cs.border),
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
enum MallBridgeTab { home, market, orders, profile }
|
||||
|
||||
class MallBridgeState {
|
||||
const MallBridgeState({
|
||||
this.ready = false,
|
||||
this.activeTab = MallBridgeTab.home,
|
||||
this.canGoBack = false,
|
||||
});
|
||||
|
||||
final bool ready;
|
||||
final MallBridgeTab activeTab;
|
||||
final bool canGoBack;
|
||||
}
|
||||
|
||||
class MallBridgeController {
|
||||
MallBridgeController._();
|
||||
|
||||
static final instance = MallBridgeController._();
|
||||
|
||||
final state = ValueNotifier(const MallBridgeState());
|
||||
Future<void> Function(MallBridgeTab tab, bool reselect)? _activateTab;
|
||||
Future<void> Function()? _goBack;
|
||||
|
||||
void attach({
|
||||
required Future<void> Function(MallBridgeTab tab, bool reselect)
|
||||
activateTab,
|
||||
required Future<void> Function() goBack,
|
||||
}) {
|
||||
_activateTab = activateTab;
|
||||
_goBack = goBack;
|
||||
}
|
||||
|
||||
void detach() {
|
||||
_activateTab = null;
|
||||
_goBack = null;
|
||||
state.value = const MallBridgeState();
|
||||
}
|
||||
|
||||
void update(Map<dynamic, dynamic>? raw) {
|
||||
final tab = MallBridgeTab.values.firstWhere(
|
||||
(item) => item.name == raw?['activeTab'],
|
||||
orElse: () => MallBridgeTab.home,
|
||||
);
|
||||
state.value = MallBridgeState(
|
||||
ready: _asBool(raw?['ready']),
|
||||
activeTab: tab,
|
||||
canGoBack: _asBool(raw?['canGoBack']),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> activate(MallBridgeTab tab, {bool reselect = false}) async {
|
||||
final current = state.value;
|
||||
state.value = MallBridgeState(
|
||||
ready: current.ready,
|
||||
activeTab: tab,
|
||||
canGoBack: current.canGoBack,
|
||||
);
|
||||
await _activateTab?.call(tab, reselect);
|
||||
}
|
||||
|
||||
Future<void> goBack() async => await _goBack?.call();
|
||||
}
|
||||
|
||||
bool _asBool(Object? value) =>
|
||||
value == true || value == 1 || value == '1' || value == 'true';
|
||||
@@ -1,30 +1,121 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
class MallWebHostState {
|
||||
const MallWebHostState({this.url, this.visible = false});
|
||||
const MallWebHostState({
|
||||
this.url,
|
||||
this.visible = false,
|
||||
this.isShowingQiyuPage = false,
|
||||
this.isQiyuKeyboardActive = false,
|
||||
});
|
||||
|
||||
final String? url;
|
||||
final bool visible;
|
||||
final bool isShowingQiyuPage;
|
||||
final bool isQiyuKeyboardActive;
|
||||
}
|
||||
|
||||
/// 进程常驻的商城 WebView 状态,切换原生 Tab 时不销毁商城页面。
|
||||
/// Process-lifetime presentation state for the resident mall WebView.
|
||||
class MallWebHostController {
|
||||
MallWebHostController._();
|
||||
static final instance = MallWebHostController._();
|
||||
final state = ValueNotifier(const MallWebHostState());
|
||||
Future<void> Function()? _backHandler;
|
||||
|
||||
void preload(String url) {
|
||||
final old = state.value;
|
||||
if (old.url == url) return;
|
||||
state.value = MallWebHostState(url: url, visible: old.visible);
|
||||
static final instance = MallWebHostController._();
|
||||
|
||||
final state = ValueNotifier(const MallWebHostState());
|
||||
String? _pendingPreloadUrl;
|
||||
Future<void> Function()? _clearCache;
|
||||
Future<void> Function()? _handleSystemBack;
|
||||
|
||||
void open(Uri url) {
|
||||
_pendingPreloadUrl = null;
|
||||
state.value = MallWebHostState(url: url.toString(), visible: true);
|
||||
}
|
||||
|
||||
void open(String url) =>
|
||||
state.value = MallWebHostState(url: url, visible: true);
|
||||
void dismiss() => state.value = MallWebHostState(url: state.value.url);
|
||||
void clear() => state.value = const MallWebHostState();
|
||||
void attachBackHandler(Future<void> Function() handler) =>
|
||||
_backHandler = handler;
|
||||
void detachBackHandler() => _backHandler = null;
|
||||
Future<void> handleBack() => _backHandler?.call() ?? Future.value();
|
||||
void preload(Uri url) {
|
||||
final current = state.value;
|
||||
if (current.visible) {
|
||||
if (current.url == null ||
|
||||
cacheKeyFor(current.url!) != cacheKeyFor(url.toString())) {
|
||||
_pendingPreloadUrl = url.toString();
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (current.url != null &&
|
||||
cacheKeyFor(current.url!) == cacheKeyFor(url.toString())) {
|
||||
return;
|
||||
}
|
||||
state.value = MallWebHostState(
|
||||
url: url.toString(),
|
||||
visible: current.visible,
|
||||
);
|
||||
}
|
||||
|
||||
void dismiss() {
|
||||
final current = state.value;
|
||||
final pending = _pendingPreloadUrl;
|
||||
_pendingPreloadUrl = null;
|
||||
state.value = MallWebHostState(url: pending ?? current.url);
|
||||
}
|
||||
|
||||
void setShowingQiyuPage(bool isShowingQiyuPage) {
|
||||
final current = state.value;
|
||||
if (current.isShowingQiyuPage == isShowingQiyuPage) return;
|
||||
state.value = MallWebHostState(
|
||||
url: current.url,
|
||||
visible: current.visible,
|
||||
isShowingQiyuPage: isShowingQiyuPage,
|
||||
isQiyuKeyboardActive: isShowingQiyuPage
|
||||
? current.isQiyuKeyboardActive
|
||||
: false,
|
||||
);
|
||||
}
|
||||
|
||||
void setQiyuKeyboardActive(bool active) {
|
||||
final current = state.value;
|
||||
if (current.isQiyuKeyboardActive == active) return;
|
||||
state.value = MallWebHostState(
|
||||
url: current.url,
|
||||
visible: current.visible,
|
||||
isShowingQiyuPage: current.isShowingQiyuPage,
|
||||
isQiyuKeyboardActive: active,
|
||||
);
|
||||
}
|
||||
|
||||
void attachCacheClearer(Future<void> Function() clearer) =>
|
||||
_clearCache = clearer;
|
||||
|
||||
void detachCacheClearer() => _clearCache = null;
|
||||
|
||||
Future<void> clear() async {
|
||||
await _clearCache?.call();
|
||||
_pendingPreloadUrl = null;
|
||||
state.value = const MallWebHostState();
|
||||
}
|
||||
|
||||
static Uri uriForTheme(Uri uri, {required bool isDark}) {
|
||||
final query = Map<String, List<String>>.from(uri.queryParametersAll);
|
||||
query['isNight'] = [isDark ? '1' : '0'];
|
||||
return uri.replace(queryParameters: query);
|
||||
}
|
||||
|
||||
void attachSystemBackHandler(Future<void> Function() handler) {
|
||||
_handleSystemBack = handler;
|
||||
}
|
||||
|
||||
void detachSystemBackHandler() => _handleSystemBack = null;
|
||||
|
||||
Future<void> handleSystemBack() =>
|
||||
_handleSystemBack?.call() ?? Future<void>.value();
|
||||
|
||||
static String cacheKeyFor(String rawUrl) {
|
||||
final uri = Uri.tryParse(rawUrl);
|
||||
if (uri == null) return rawUrl;
|
||||
return uri
|
||||
.replace(
|
||||
queryParameters: uri.queryParameters['isNight'] == null
|
||||
? const <String, String>{}
|
||||
: <String, String>{'isNight': uri.queryParameters['isNight']!},
|
||||
fragment: '',
|
||||
)
|
||||
.toString();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
typedef WebCanGoBack = Future<bool> Function();
|
||||
typedef WebGoBack = Future<void> Function();
|
||||
typedef WebRunJavaScriptReturningResult = Future<Object> Function(String code);
|
||||
|
||||
const visibleWebBackControlJavaScript = r'''
|
||||
(() => {
|
||||
const selectors = [
|
||||
'.back-btn',
|
||||
'[aria-label="返回"]',
|
||||
'[aria-label="Back"]',
|
||||
'[title="返回"]',
|
||||
'[title="Back"]',
|
||||
].join(',');
|
||||
const candidates = Array.from(document.querySelectorAll(selectors));
|
||||
const control = candidates.find((element) => {
|
||||
const rect = element.getBoundingClientRect();
|
||||
const style = window.getComputedStyle(element);
|
||||
if (
|
||||
rect.width <= 0 || rect.height <= 0 || rect.bottom <= 0 ||
|
||||
rect.right <= 0 || rect.top >= window.innerHeight ||
|
||||
rect.left >= window.innerWidth || style.display === 'none' ||
|
||||
style.visibility === 'hidden' || style.pointerEvents === 'none' ||
|
||||
Number(style.opacity || 1) <= 0
|
||||
) return false;
|
||||
const x = Math.min(Math.max(rect.left + rect.width / 2, 0), window.innerWidth - 1);
|
||||
const y = Math.min(Math.max(rect.top + rect.height / 2, 0), window.innerHeight - 1);
|
||||
const topElement = document.elementFromPoint(x, y);
|
||||
return topElement !== null && (topElement === element || element.contains(topElement));
|
||||
});
|
||||
if (!control) return false;
|
||||
control.click();
|
||||
return true;
|
||||
})()
|
||||
''';
|
||||
|
||||
Future<bool> tryGoBackInWebHistory({
|
||||
required WebCanGoBack canGoBack,
|
||||
required WebGoBack goBack,
|
||||
}) async {
|
||||
if (!await canGoBack()) return false;
|
||||
await goBack();
|
||||
return true;
|
||||
}
|
||||
|
||||
Future<bool> tryGoBackUsingVisibleWebControl({
|
||||
required WebRunJavaScriptReturningResult runJavaScriptReturningResult,
|
||||
}) async {
|
||||
final result = await runJavaScriptReturningResult(
|
||||
visibleWebBackControlJavaScript,
|
||||
);
|
||||
if (result is bool) return result;
|
||||
return result.toString().replaceAll('"', '').trim().toLowerCase() == 'true';
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
/// Mall pages stay in the resident WebView unless H5 explicitly requests an
|
||||
/// external handoff or the URI is handled by the operating system.
|
||||
bool shouldOpenWebUrlExternally(String rawUrl) {
|
||||
final uri = Uri.tryParse(rawUrl);
|
||||
if (uri == null) return false;
|
||||
return const {'tel', 'mailto', 'sms'}.contains(uri.scheme.toLowerCase()) ||
|
||||
uri.queryParameters.containsKey('out');
|
||||
}
|
||||
|
||||
Uri sanitizeSystemCommunicationUri(Uri uri) {
|
||||
if (uri.scheme.toLowerCase() != 'tel') return uri;
|
||||
final parameterMarker = uri.path.indexOf('&');
|
||||
final phone = parameterMarker < 0
|
||||
? uri.path
|
||||
: uri.path.substring(0, parameterMarker);
|
||||
return Uri(scheme: uri.scheme, path: phone);
|
||||
}
|
||||
|
||||
bool isTrustedQiyuUrl(String rawUrl) {
|
||||
final uri = Uri.tryParse(rawUrl);
|
||||
if (uri == null ||
|
||||
(uri.scheme != 'http' && uri.scheme != 'https') ||
|
||||
uri.host.isEmpty) {
|
||||
return false;
|
||||
}
|
||||
final host = uri.host.toLowerCase();
|
||||
return host == 'qiyukf.com' ||
|
||||
host.endsWith('.qiyukf.com') ||
|
||||
host == 'qiyukf.cn' ||
|
||||
host.endsWith('.qiyukf.cn');
|
||||
}
|
||||
|
||||
bool shouldApplyQiyuSafeArea(String rawUrl) => isTrustedQiyuUrl(rawUrl);
|
||||
|
||||
bool isQiyuClientEntryUrl(String rawUrl) {
|
||||
final path = Uri.tryParse(rawUrl)?.path;
|
||||
return path == '/client' || (path?.startsWith('/client/') ?? false);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
|
||||
class JsEvent {
|
||||
const JsEvent(this.method, [this.args]);
|
||||
|
||||
final String method;
|
||||
final List<dynamic>? args;
|
||||
}
|
||||
|
||||
class WebReloadController {
|
||||
final _controller = StreamController<JsEvent>.broadcast();
|
||||
|
||||
Stream<JsEvent> get stream => _controller.stream;
|
||||
void callEvent(JsEvent event) => _controller.add(event);
|
||||
Future<void> dispose() => _controller.close();
|
||||
}
|
||||
|
||||
final webReloadControllerProvider = Provider<WebReloadController>((ref) {
|
||||
final controller = WebReloadController();
|
||||
ref.onDispose(() => unawaited(controller.dispose()));
|
||||
return controller;
|
||||
});
|
||||
|
||||
final webReloadPProvider = StreamProvider.autoDispose<JsEvent>((ref) {
|
||||
return ref.watch(webReloadControllerProvider).stream;
|
||||
});
|
||||
@@ -0,0 +1,87 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:dsbridge_flutter/dsbridge_flutter.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:package_info_plus/package_info_plus.dart';
|
||||
|
||||
import 'js_api_result.dart';
|
||||
|
||||
class JsApi extends JavaScriptNamespaceInterface {
|
||||
JsApi({
|
||||
this.onClose,
|
||||
this.onLoginRequested,
|
||||
this.onMallStateChanged,
|
||||
Future<String> Function()? appVersionResolver,
|
||||
}) : _appVersionResolver = appVersionResolver ?? _readAppVersion;
|
||||
|
||||
final VoidCallback? onClose;
|
||||
final VoidCallback? onLoginRequested;
|
||||
final ValueChanged<Map<dynamic, dynamic>?>? onMallStateChanged;
|
||||
final Future<String> Function() _appVersionResolver;
|
||||
|
||||
@override
|
||||
void register() {
|
||||
registerFunction(close);
|
||||
registerFunction(getAppVersion);
|
||||
registerFunction(mallStateChanged);
|
||||
registerFunction(login);
|
||||
}
|
||||
|
||||
@pragma('vm:entry-point')
|
||||
void close(dynamic arg, CompletionHandler handler) {
|
||||
onClose?.call();
|
||||
handler.complete(const JsApiResult.success().toMap());
|
||||
}
|
||||
|
||||
@pragma('vm:entry-point')
|
||||
void getAppVersion(dynamic arg, CompletionHandler handler) {
|
||||
unawaited(_completeAppVersion(handler));
|
||||
}
|
||||
|
||||
Future<void> _completeAppVersion(CompletionHandler handler) async {
|
||||
try {
|
||||
final version = await _appVersionResolver();
|
||||
handler.complete(
|
||||
JsApiResult(
|
||||
code: 0,
|
||||
message: 'success',
|
||||
data: <String, dynamic>{'version': version},
|
||||
).toMap(),
|
||||
);
|
||||
} catch (_) {
|
||||
handler.complete(
|
||||
const JsApiResult(code: 1, message: 'app version unavailable').toMap(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
static Future<String> _readAppVersion() async =>
|
||||
(await PackageInfo.fromPlatform()).version;
|
||||
|
||||
@pragma('vm:entry-point')
|
||||
void mallStateChanged(dynamic arg, CompletionHandler handler) {
|
||||
onMallStateChanged?.call(coerceMallStateMap(arg));
|
||||
handler.complete(const JsApiResult.success().toMap());
|
||||
}
|
||||
|
||||
@pragma('vm:entry-point')
|
||||
void login(dynamic arg, CompletionHandler handler) {
|
||||
onLoginRequested?.call();
|
||||
handler.complete(const JsApiResult.success().toMap());
|
||||
}
|
||||
}
|
||||
|
||||
Map<dynamic, dynamic>? coerceMallStateMap(dynamic arg) {
|
||||
if (arg is Map) return arg;
|
||||
if (arg is String && arg.isNotEmpty) {
|
||||
try {
|
||||
final decoded = jsonDecode(arg);
|
||||
if (decoded is Map) return decoded;
|
||||
} catch (_) {}
|
||||
}
|
||||
if (arg is List && arg.isNotEmpty && arg.first is Map) {
|
||||
return arg.first as Map<dynamic, dynamic>;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import 'dart:convert';
|
||||
|
||||
class JsApiResult {
|
||||
const JsApiResult({required this.code, required this.message, this.data});
|
||||
|
||||
const JsApiResult.success() : code = 0, message = 'success', data = null;
|
||||
|
||||
final int code;
|
||||
final String message;
|
||||
final Map<String, dynamic>? data;
|
||||
|
||||
Map<String, dynamic> toMap() => <String, dynamic>{
|
||||
'code': code,
|
||||
'message': message,
|
||||
if (data != null) 'data': data,
|
||||
};
|
||||
|
||||
String toJson() => jsonEncode(toMap());
|
||||
}
|
||||
@@ -1,130 +1,703 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:dsbridge_flutter/dsbridge_flutter.dart';
|
||||
import 'package:flutter/foundation.dart'
|
||||
show
|
||||
TargetPlatform,
|
||||
debugPrint,
|
||||
defaultTargetPlatform,
|
||||
kIsWeb,
|
||||
kReleaseMode;
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:permission_handler/permission_handler.dart';
|
||||
import 'package:shadcn_ui/shadcn_ui.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
import 'package:webview_flutter/webview_flutter.dart';
|
||||
import 'package:webview_flutter_android/webview_flutter_android.dart';
|
||||
import 'package:webview_flutter_wkwebview/webview_flutter_wkwebview.dart';
|
||||
|
||||
import '../../../common/util/app_link_util.dart';
|
||||
import '../../auth/application/auth_controller.dart';
|
||||
import '../../auth/application/login_launcher.dart';
|
||||
import '../../auth/data/auth_models.dart';
|
||||
import '../../mall/application/mall_tab_config.dart';
|
||||
import '../../mall/data/mall_auth_api.dart';
|
||||
import '../application/mall_bridge_state.dart';
|
||||
import '../application/mall_web_host_controller.dart';
|
||||
import '../application/web_back_navigation.dart';
|
||||
import '../application/web_navigation_policy.dart';
|
||||
import '../application/web_reload_providers.dart';
|
||||
import 'js_api.dart';
|
||||
|
||||
/// 常驻在 Tab Shell 上层的商城 H5 宿主。
|
||||
class MallWebHost extends StatefulWidget {
|
||||
const MallWebHost({super.key, required this.onExit});
|
||||
final VoidCallback onExit;
|
||||
/// Process-lifetime resident mall WebView. The controller, bridge contract and
|
||||
/// first-load ordering intentionally match the Purse implementation.
|
||||
class MallWebHost extends ConsumerStatefulWidget {
|
||||
const MallWebHost({
|
||||
super.key,
|
||||
required this.url,
|
||||
required this.visible,
|
||||
required this.onRequestExit,
|
||||
});
|
||||
|
||||
final String url;
|
||||
final bool visible;
|
||||
final VoidCallback onRequestExit;
|
||||
|
||||
@override
|
||||
State<MallWebHost> createState() => _MallWebHostState();
|
||||
ConsumerState<MallWebHost> createState() => _MallWebHostState();
|
||||
}
|
||||
|
||||
class _MallWebHostState extends State<MallWebHost> {
|
||||
final _host = MallWebHostController.instance;
|
||||
WebViewController? _controller;
|
||||
class _MallWebHostState extends ConsumerState<MallWebHost>
|
||||
with WidgetsBindingObserver {
|
||||
static const _defaultQiyuStatusBarColor = Color(0xFF3B82F6);
|
||||
static const _mallSystemUiOverlayStyle = SystemUiOverlayStyle(
|
||||
statusBarColor: Colors.transparent,
|
||||
statusBarIconBrightness: Brightness.dark,
|
||||
statusBarBrightness: Brightness.light,
|
||||
);
|
||||
static const _webFilePickerChannel = MethodChannel(
|
||||
'com.jinzhi.butler/web_file_picker',
|
||||
);
|
||||
|
||||
late final DWebViewController _controller;
|
||||
String? _loadedUrl;
|
||||
String? _error;
|
||||
String? _errorText;
|
||||
int _progress = 0;
|
||||
bool _hasFinishedFirstPage = false;
|
||||
bool _hasDisplayedPage = false;
|
||||
bool _isHandlingSystemBack = false;
|
||||
bool _isHandlingLoginRequest = false;
|
||||
bool _isShowingQiyuPage = false;
|
||||
bool _hasEmbeddedQiyuFrame = false;
|
||||
Color _qiyuStatusBarColor = _defaultQiyuStatusBarColor;
|
||||
String? _qiyuUrlLoadedWithMallReferer;
|
||||
final _pendingH5Events = <JsEvent>[];
|
||||
Future<void>? _androidWebViewConfiguration;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_host.state.addListener(_sync);
|
||||
_host.attachBackHandler(_goBack);
|
||||
_sync();
|
||||
WidgetsBinding.instance.addObserver(this);
|
||||
ref.listenManual<AsyncValue<JsEvent>>(webReloadPProvider, (_, next) {
|
||||
next.whenData(_callH5Handler);
|
||||
});
|
||||
ref.listenManual<AsyncValue<AuthState>>(authProvider, (previous, next) {
|
||||
final previousAuth = previous?.valueOrNull;
|
||||
final nextAuth = next.valueOrNull;
|
||||
final wasLogged = previousAuth is LoggedInAuthState;
|
||||
final logged = nextAuth is LoggedInAuthState;
|
||||
final tokenChanged =
|
||||
wasLogged && logged && previousAuth.token != nextAuth.token;
|
||||
if (wasLogged == logged && !tokenChanged) return;
|
||||
if (logged) {
|
||||
unawaited(_notifyH5MallEnteredForCurrentUrl());
|
||||
} else {
|
||||
unawaited(_notifyH5LoggedOut());
|
||||
}
|
||||
});
|
||||
_controller = _createController();
|
||||
MallBridgeController.instance.attach(
|
||||
activateTab: _switchTab,
|
||||
goBack: _goBack,
|
||||
);
|
||||
MallWebHostController.instance.attachCacheClearer(_clearWebViewCache);
|
||||
MallWebHostController.instance.attachSystemBackHandler(_handleSystemBack);
|
||||
_configurePlatformWebView();
|
||||
unawaited(_load(widget.url));
|
||||
if (widget.visible) unawaited(_notifyH5MallEnteredForCurrentUrl());
|
||||
}
|
||||
|
||||
DWebViewController _createController() {
|
||||
late final DWebViewController controller;
|
||||
controller = DWebViewController()
|
||||
// ignore: discarded_futures
|
||||
..setBackgroundColor(Colors.transparent)
|
||||
// ignore: discarded_futures
|
||||
..setJavaScriptMode(JavaScriptMode.unrestricted)
|
||||
..addJavaScriptObject(
|
||||
JsApi(
|
||||
onClose: widget.onRequestExit,
|
||||
onLoginRequested: () => unawaited(_requestMallLogin()),
|
||||
onMallStateChanged: MallBridgeController.instance.update,
|
||||
),
|
||||
)
|
||||
// Temporary compatibility with the pre-DSBridge Jinzhi H5 contract.
|
||||
// ignore: discarded_futures
|
||||
..addJavaScriptChannel(
|
||||
'JinzhiBridge',
|
||||
onMessageReceived: (message) {
|
||||
if (message.message == 'close') widget.onRequestExit();
|
||||
},
|
||||
)
|
||||
// ignore: discarded_futures
|
||||
..setNavigationDelegate(
|
||||
NavigationDelegate(
|
||||
onNavigationRequest: _handleNavigationRequest,
|
||||
onProgress: (value) {
|
||||
if (mounted) setState(() => _progress = value);
|
||||
},
|
||||
onPageStarted: (url) {
|
||||
_updateQiyuPageLayout(url);
|
||||
if (!_hasFinishedFirstPage && mounted) {
|
||||
setState(() {
|
||||
_errorText = null;
|
||||
_progress = 0;
|
||||
});
|
||||
}
|
||||
},
|
||||
onUrlChange: (change) => _updateQiyuPageLayout(change.url),
|
||||
onPageFinished: (url) {
|
||||
_hasFinishedFirstPage = true;
|
||||
_hasDisplayedPage = true;
|
||||
_updateQiyuPageLayout(url);
|
||||
if (_isShowingQiyuPage) {
|
||||
_applySystemUiOverlayStyle();
|
||||
}
|
||||
unawaited(_refreshEmbeddedQiyuPresence());
|
||||
_flushPendingH5Events();
|
||||
if (mounted) setState(() => _progress = 100);
|
||||
},
|
||||
onWebResourceError: (error) {
|
||||
if (error.isForMainFrame != true || _hasFinishedFirstPage) return;
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_errorText = error.description;
|
||||
_progress = 100;
|
||||
});
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
return controller;
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_host.state.removeListener(_sync);
|
||||
_host.detachBackHandler();
|
||||
WidgetsBinding.instance.removeObserver(this);
|
||||
MallWebHostController.instance.setShowingQiyuPage(false);
|
||||
SystemChrome.setSystemUIOverlayStyle(_mallSystemUiOverlayStyle);
|
||||
MallWebHostController.instance.detachCacheClearer();
|
||||
MallWebHostController.instance.detachSystemBackHandler();
|
||||
MallBridgeController.instance.detach();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _sync() {
|
||||
final url = _host.state.value.url;
|
||||
if (url != null && url != _loadedUrl) _load(url);
|
||||
if (mounted) setState(() {});
|
||||
@override
|
||||
void didUpdateWidget(covariant MallWebHost oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (MallWebHostController.cacheKeyFor(oldWidget.url) !=
|
||||
MallWebHostController.cacheKeyFor(widget.url)) {
|
||||
unawaited(_load(widget.url));
|
||||
}
|
||||
if (!oldWidget.visible && widget.visible) {
|
||||
unawaited(_notifyH5MallEnteredForCurrentUrl());
|
||||
} else if (oldWidget.url != widget.url && widget.visible) {
|
||||
unawaited(_notifyH5MallEnteredForCurrentUrl());
|
||||
}
|
||||
}
|
||||
|
||||
void _load(String rawUrl) {
|
||||
final uri = Uri.tryParse(rawUrl);
|
||||
if (uri == null || (uri.scheme != 'https' && uri.scheme != 'http')) {
|
||||
_error = '商城链接不可用';
|
||||
Future<void> _switchTab(MallBridgeTab tab, bool reselect) async =>
|
||||
_controller.callHandler('mall.switchTab', args: [tab.name, reselect]);
|
||||
|
||||
Future<void> _goBack() async => _controller.callHandler('mall.back');
|
||||
|
||||
Future<NavigationDecision> _handleNavigationRequest(
|
||||
NavigationRequest request,
|
||||
) async {
|
||||
if (!kIsWeb &&
|
||||
defaultTargetPlatform == TargetPlatform.android &&
|
||||
isTrustedQiyuUrl(request.url)) {
|
||||
return _openQiyuWithMallReferer(request.url);
|
||||
}
|
||||
if (!shouldOpenWebUrlExternally(request.url)) {
|
||||
return NavigationDecision.navigate;
|
||||
}
|
||||
final target = Uri.tryParse(request.url);
|
||||
if (target == null) return NavigationDecision.prevent;
|
||||
final targetApp = AppLinkUtil.targetAppFromUrl(request.url);
|
||||
final isHttpUrl = target.scheme == 'http' || target.scheme == 'https';
|
||||
final launched = isHttpUrl && targetApp.isNotEmpty
|
||||
? await AppLinkUtil.openExternal(target, packageName: targetApp)
|
||||
: await launchUrl(
|
||||
sanitizeSystemCommunicationUri(target),
|
||||
mode: LaunchMode.externalApplication,
|
||||
);
|
||||
if (launched && mounted) widget.onRequestExit();
|
||||
return NavigationDecision.prevent;
|
||||
}
|
||||
|
||||
Future<void> _handleSystemBack() async {
|
||||
if (kIsWeb ||
|
||||
defaultTargetPlatform != TargetPlatform.android ||
|
||||
!_hasFinishedFirstPage ||
|
||||
_errorText != null ||
|
||||
_isHandlingSystemBack) {
|
||||
return;
|
||||
}
|
||||
_error = null;
|
||||
_progress = 0;
|
||||
_loadedUrl = rawUrl;
|
||||
_controller ??= WebViewController()
|
||||
..setJavaScriptMode(JavaScriptMode.unrestricted)
|
||||
..addJavaScriptChannel(
|
||||
'JinzhiBridge',
|
||||
onMessageReceived: (message) {
|
||||
if (message.message == 'close') widget.onExit();
|
||||
},
|
||||
)
|
||||
..setNavigationDelegate(
|
||||
NavigationDelegate(
|
||||
onProgress: (value) {
|
||||
if (mounted) setState(() => _progress = value);
|
||||
},
|
||||
onWebResourceError: (error) {
|
||||
if (error.isForMainFrame == false) return;
|
||||
if (mounted) setState(() => _error = error.description);
|
||||
},
|
||||
onNavigationRequest: (request) async {
|
||||
final target = Uri.tryParse(request.url);
|
||||
if (target == null ||
|
||||
target.scheme == 'http' ||
|
||||
target.scheme == 'https') {
|
||||
return NavigationDecision.navigate;
|
||||
}
|
||||
if (await canLaunchUrl(target)) {
|
||||
await launchUrl(target, mode: LaunchMode.externalApplication);
|
||||
}
|
||||
return NavigationDecision.prevent;
|
||||
},
|
||||
),
|
||||
if (MallBridgeController.instance.state.value.canGoBack) {
|
||||
await _goBack();
|
||||
return;
|
||||
}
|
||||
_isHandlingSystemBack = true;
|
||||
try {
|
||||
await tryGoBackInWebHistory(
|
||||
canGoBack: _controller.canGoBack,
|
||||
goBack: _controller.goBack,
|
||||
);
|
||||
unawaited(_controller!.loadRequest(uri));
|
||||
} finally {
|
||||
_isHandlingSystemBack = false;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _goBack() async {
|
||||
final controller = _controller;
|
||||
if (controller != null && await controller.canGoBack()) {
|
||||
await controller.goBack();
|
||||
Future<NavigationDecision> _openQiyuWithMallReferer(String rawUrl) async {
|
||||
final mallUri = Uri.tryParse(_loadedUrl ?? '');
|
||||
final qiyuUri = Uri.tryParse(rawUrl);
|
||||
if (!isQiyuClientEntryUrl(rawUrl) ||
|
||||
_qiyuUrlLoadedWithMallReferer == rawUrl ||
|
||||
mallUri == null ||
|
||||
qiyuUri == null) {
|
||||
return NavigationDecision.navigate;
|
||||
}
|
||||
_qiyuUrlLoadedWithMallReferer = rawUrl;
|
||||
try {
|
||||
await _controller.loadRequest(
|
||||
qiyuUri,
|
||||
headers: {'Referer': '${mallUri.origin}/'},
|
||||
);
|
||||
return NavigationDecision.prevent;
|
||||
} catch (error) {
|
||||
_qiyuUrlLoadedWithMallReferer = null;
|
||||
debugPrint('[Mall] Failed to reload Qiyu with mall referrer: $error');
|
||||
return NavigationDecision.navigate;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _load(String raw) async {
|
||||
final uri = Uri.tryParse(raw);
|
||||
if (!_isAllowedMallUri(uri) || _loadedUrl == raw) return;
|
||||
_loadedUrl = raw;
|
||||
_qiyuUrlLoadedWithMallReferer = null;
|
||||
_hasFinishedFirstPage = false;
|
||||
_errorText = null;
|
||||
_progress = 0;
|
||||
_isShowingQiyuPage = false;
|
||||
_hasEmbeddedQiyuFrame = false;
|
||||
MallWebHostController.instance.setShowingQiyuPage(false);
|
||||
MallWebHostController.instance.setQiyuKeyboardActive(false);
|
||||
_resetQiyuStatusBarColor();
|
||||
_applySystemUiOverlayStyle();
|
||||
await _configureAndroidWebViewBeforeFirstLoad();
|
||||
if (_loadedUrl != raw) return;
|
||||
await _controller.loadRequest(
|
||||
uri!,
|
||||
headers: const {'Cache-Control': 'no-cache'},
|
||||
);
|
||||
}
|
||||
|
||||
bool _isAllowedMallUri(Uri? uri) {
|
||||
if (uri == null || uri.host.isEmpty) return false;
|
||||
if (uri.scheme == 'https') return true;
|
||||
return !kReleaseMode &&
|
||||
uri.scheme == 'http' &&
|
||||
const {'localhost', '127.0.0.1', '::1'}.contains(uri.host);
|
||||
}
|
||||
|
||||
Future<void> _clearWebViewCache() => _controller.clearCache();
|
||||
|
||||
void _configurePlatformWebView() {
|
||||
final platform = _controller.platform;
|
||||
if (platform is WebKitWebViewController) {
|
||||
unawaited(platform.setAllowsBackForwardNavigationGestures(true));
|
||||
} else if (platform is AndroidWebViewController) {
|
||||
_androidWebViewConfiguration ??= _configureAndroidWebView(platform);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _configureAndroidWebViewBeforeFirstLoad() {
|
||||
final platform = _controller.platform;
|
||||
if (platform is! AndroidWebViewController) return Future<void>.value();
|
||||
return _androidWebViewConfiguration ??= _configureAndroidWebView(platform);
|
||||
}
|
||||
|
||||
Future<void> _configureAndroidWebView(
|
||||
AndroidWebViewController controller,
|
||||
) async {
|
||||
await controller.setOnPlatformPermissionRequest(
|
||||
_onAndroidWebPermissionRequest,
|
||||
);
|
||||
await controller.setOnShowFileSelector(_selectWebViewFiles);
|
||||
try {
|
||||
await AndroidWebViewCookieManager(
|
||||
const PlatformWebViewCookieManagerCreationParams(),
|
||||
).setAcceptThirdPartyCookies(controller, true);
|
||||
} catch (_) {}
|
||||
try {
|
||||
final userAgent = await controller.getUserAgent();
|
||||
if (userAgent == null) return;
|
||||
final browserUserAgent = userAgent
|
||||
.replaceAll(RegExp(r';\s*wv'), '')
|
||||
.replaceAll(RegExp(r'\bVersion/4\.0\s*'), '')
|
||||
.trim();
|
||||
if (browserUserAgent != userAgent) {
|
||||
await controller.setUserAgent(browserUserAgent);
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
void _onAndroidWebPermissionRequest(
|
||||
PlatformWebViewPermissionRequest request,
|
||||
) {
|
||||
unawaited(_grantAndroidWebPermissions(request));
|
||||
}
|
||||
|
||||
Future<void> _grantAndroidWebPermissions(
|
||||
PlatformWebViewPermissionRequest request,
|
||||
) async {
|
||||
if (!await _canGrantWebMediaPermission(request.types)) {
|
||||
await request.deny();
|
||||
return;
|
||||
}
|
||||
final permissions = <Permission>[
|
||||
if (request.types.contains(WebViewPermissionResourceType.camera))
|
||||
Permission.camera,
|
||||
if (request.types.contains(WebViewPermissionResourceType.microphone))
|
||||
Permission.microphone,
|
||||
];
|
||||
if (permissions.isEmpty) {
|
||||
await request.deny();
|
||||
return;
|
||||
}
|
||||
final statuses = await permissions.request();
|
||||
if (statuses.values.every((status) => status.isGranted)) {
|
||||
await request.grant();
|
||||
} else {
|
||||
await request.deny();
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> _canGrantWebMediaPermission(
|
||||
Set<WebViewPermissionResourceType> types,
|
||||
) async {
|
||||
switch (await _detectWebMediaCaptureContext()) {
|
||||
case _WebMediaCaptureContext.qiyu:
|
||||
return true;
|
||||
case _WebMediaCaptureContext.faceVerification:
|
||||
return types.contains(WebViewPermissionResourceType.camera) &&
|
||||
!types.contains(WebViewPermissionResourceType.microphone);
|
||||
case _WebMediaCaptureContext.none:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> _canGrantQiyuWebMediaPermission() async =>
|
||||
await _detectWebMediaCaptureContext() == _WebMediaCaptureContext.qiyu;
|
||||
|
||||
Future<_WebMediaCaptureContext> _detectWebMediaCaptureContext() async {
|
||||
if (_isShowingQiyuPage) return _WebMediaCaptureContext.qiyu;
|
||||
const script = '''
|
||||
(() => {
|
||||
const isQiyu = Array.from(document.querySelectorAll('iframe[src]')).some((frame) => {
|
||||
try {
|
||||
const host = new URL(frame.src, document.baseURI).hostname.toLowerCase();
|
||||
return host === 'qiyukf.com' || host.endsWith('.qiyukf.com') ||
|
||||
host === 'qiyukf.cn' || host.endsWith('.qiyukf.cn');
|
||||
} catch (_) { return false; }
|
||||
});
|
||||
if (isQiyu) return 'qiyu';
|
||||
const isFaceVerification = Array.from(document.scripts).some((script) => {
|
||||
try {
|
||||
const url = new URL(script.src || '', document.baseURI);
|
||||
return url.hostname === 'g.alicdn.com' &&
|
||||
url.pathname.includes('/captcha-frontend/FeiLin/');
|
||||
} catch (_) { return false; }
|
||||
});
|
||||
return isFaceVerification ? 'face_verification' : 'none';
|
||||
})();
|
||||
''';
|
||||
try {
|
||||
final result = await _controller.runJavaScriptReturningResult(script);
|
||||
return switch (result.toString().replaceAll('"', '')) {
|
||||
'qiyu' => _WebMediaCaptureContext.qiyu,
|
||||
'face_verification' => _WebMediaCaptureContext.faceVerification,
|
||||
_ => _WebMediaCaptureContext.none,
|
||||
};
|
||||
} catch (_) {
|
||||
return _WebMediaCaptureContext.none;
|
||||
}
|
||||
}
|
||||
|
||||
Future<List<String>> _selectWebViewFiles(FileSelectorParams params) async {
|
||||
final isQiyuMediaUpload =
|
||||
_isImageOrVideoUpload(params.acceptTypes) &&
|
||||
await _canGrantQiyuWebMediaPermission();
|
||||
final captureEnabled = isQiyuMediaUpload
|
||||
? await _requestQiyuUploadPermissions(params.acceptTypes)
|
||||
: params.isCaptureEnabled;
|
||||
try {
|
||||
return (await _webFilePickerChannel
|
||||
.invokeListMethod<String>('pickFiles', <String, dynamic>{
|
||||
'acceptTypes': params.acceptTypes,
|
||||
'allowMultiple': params.mode == FileSelectorMode.openMultiple,
|
||||
'captureEnabled': captureEnabled,
|
||||
'isQiyuSession': isQiyuMediaUpload,
|
||||
})) ??
|
||||
const <String>[];
|
||||
} on PlatformException {
|
||||
return const <String>[];
|
||||
}
|
||||
}
|
||||
|
||||
bool _isImageOrVideoUpload(List<String> acceptTypes) =>
|
||||
acceptTypes.isNotEmpty &&
|
||||
acceptTypes.every(
|
||||
(type) => type.startsWith('image/') || type.startsWith('video/'),
|
||||
);
|
||||
|
||||
Future<bool> _requestQiyuUploadPermissions(List<String> acceptTypes) async {
|
||||
final uploadsVideo = acceptTypes.any((type) => type.startsWith('video/'));
|
||||
final statuses = await <Permission>[
|
||||
Permission.camera,
|
||||
if (uploadsVideo) Permission.microphone,
|
||||
].request();
|
||||
return statuses.values.every((status) => status.isGranted);
|
||||
}
|
||||
|
||||
SystemUiOverlayStyle get _qiyuSystemUiOverlayStyle => SystemUiOverlayStyle(
|
||||
statusBarColor: _qiyuStatusBarColor,
|
||||
statusBarIconBrightness: Brightness.light,
|
||||
statusBarBrightness: Brightness.dark,
|
||||
);
|
||||
|
||||
void _applySystemUiOverlayStyle() {
|
||||
SystemChrome.setSystemUIOverlayStyle(
|
||||
_isShowingQiyuPage
|
||||
? _qiyuSystemUiOverlayStyle
|
||||
: _mallSystemUiOverlayStyle,
|
||||
);
|
||||
}
|
||||
|
||||
void _resetQiyuStatusBarColor() {
|
||||
_qiyuStatusBarColor = _defaultQiyuStatusBarColor;
|
||||
}
|
||||
|
||||
void _setQiyuPageLayout(bool isQiyuPage) {
|
||||
if (_isShowingQiyuPage == isQiyuPage) return;
|
||||
_isShowingQiyuPage = isQiyuPage;
|
||||
MallWebHostController.instance.setShowingQiyuPage(isQiyuPage);
|
||||
if (!isQiyuPage) _resetQiyuStatusBarColor();
|
||||
_applySystemUiOverlayStyle();
|
||||
if (mounted) setState(() {});
|
||||
_syncQiyuKeyboardAvoidance();
|
||||
}
|
||||
|
||||
void _updateQiyuPageLayout(String? rawUrl) =>
|
||||
_setQiyuPageLayout(shouldApplyQiyuSafeArea(rawUrl ?? ''));
|
||||
|
||||
@override
|
||||
void didChangeMetrics() {
|
||||
super.didChangeMetrics();
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted) _syncQiyuKeyboardAvoidance();
|
||||
});
|
||||
}
|
||||
|
||||
void _syncQiyuKeyboardAvoidance() {
|
||||
if (!mounted) return;
|
||||
final keyboardOpen = MediaQuery.viewInsetsOf(context).bottom > 0;
|
||||
MallWebHostController.instance.setQiyuKeyboardActive(
|
||||
keyboardOpen && (_isShowingQiyuPage || _hasEmbeddedQiyuFrame),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _refreshEmbeddedQiyuPresence() async {
|
||||
const script = '''
|
||||
(() => Array.from(document.querySelectorAll('iframe[src]')).some((frame) => {
|
||||
try {
|
||||
const host = new URL(frame.src, document.baseURI).hostname.toLowerCase();
|
||||
return host === 'qiyukf.com' || host.endsWith('.qiyukf.com') ||
|
||||
host === 'qiyukf.cn' || host.endsWith('.qiyukf.cn');
|
||||
} catch (_) { return false; }
|
||||
}))();
|
||||
''';
|
||||
try {
|
||||
final result = await _controller.runJavaScriptReturningResult(script);
|
||||
if (!mounted) return;
|
||||
_hasEmbeddedQiyuFrame = result.toString().replaceAll('"', '') == 'true';
|
||||
_syncQiyuKeyboardAvoidance();
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
Future<void> _retry() async {
|
||||
final url = _loadedUrl;
|
||||
if (url == null) return;
|
||||
_loadedUrl = null;
|
||||
await _load(url);
|
||||
}
|
||||
|
||||
Future<void> _requestMallLogin() async {
|
||||
if (_isLoggedIn) {
|
||||
await _notifyH5MallEnteredForCurrentUrl();
|
||||
return;
|
||||
}
|
||||
if (_isHandlingLoginRequest || !mounted) return;
|
||||
_isHandlingLoginRequest = true;
|
||||
try {
|
||||
await LoginLauncher.start(context, ref);
|
||||
} finally {
|
||||
_isHandlingLoginRequest = false;
|
||||
}
|
||||
}
|
||||
|
||||
bool get _isLoggedIn =>
|
||||
ref.read(authProvider).valueOrNull is LoggedInAuthState;
|
||||
|
||||
Future<void> _notifyH5LoggedOut() async {
|
||||
_isHandlingLoginRequest = false;
|
||||
_callH5Handler(const JsEvent('logout'));
|
||||
try {
|
||||
await _controller.runJavaScript(
|
||||
'window.__chartProLogout&&window.__chartProLogout()',
|
||||
);
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
Future<void> _notifyH5MallEnteredForCurrentUrl() async {
|
||||
final rawUrl = MallWebHostController.instance.state.value.url ?? _loadedUrl;
|
||||
final uri = rawUrl == null ? null : Uri.tryParse(rawUrl);
|
||||
if (!_isAllowedMallUri(uri)) return;
|
||||
final logged = _isLoggedIn;
|
||||
final mallAuth = logged ? await _issueMallAuthToken() : null;
|
||||
if (!mounted) return;
|
||||
final arguments = buildEnterWebMallBridgeArguments(
|
||||
config: ref.read(mallTabConfigProvider),
|
||||
currentUri: uri!,
|
||||
logged: logged,
|
||||
isDark: Theme.of(context).brightness == Brightness.dark,
|
||||
authToken: mallAuth?.authToken,
|
||||
scene: mallAuth?.scene,
|
||||
sourceApp: mallAuth?.sourceApp,
|
||||
);
|
||||
_callH5Handler(JsEvent('enterWebMall', [arguments]));
|
||||
}
|
||||
|
||||
Future<AppMallAuthToken?> _issueMallAuthToken() async {
|
||||
try {
|
||||
return await ref.read(mallAuthApiProvider).issueAppMallAuthToken();
|
||||
} catch (error) {
|
||||
debugPrint('[Mall] Failed to issue H5 auth token: $error');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
void _callH5Handler(JsEvent event) {
|
||||
if (!_hasFinishedFirstPage) {
|
||||
_pendingH5Events.add(event);
|
||||
return;
|
||||
}
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) => _sendH5Handler(event));
|
||||
}
|
||||
|
||||
void _flushPendingH5Events() {
|
||||
if (_pendingH5Events.isEmpty) return;
|
||||
final events = List<JsEvent>.of(_pendingH5Events);
|
||||
_pendingH5Events.clear();
|
||||
for (final event in events) {
|
||||
_sendH5Handler(event);
|
||||
}
|
||||
}
|
||||
|
||||
void _sendH5Handler(JsEvent event) {
|
||||
try {
|
||||
_controller.callHandler(event.method, args: event.args);
|
||||
} catch (error) {
|
||||
debugPrint('[Mall] H5 bridge event failed: ${event.method}; $error');
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final current = _host.state.value;
|
||||
if (current.url == null) return const SizedBox.shrink();
|
||||
return ColoredBox(
|
||||
color: Theme.of(context).scaffoldBackgroundColor,
|
||||
child: Stack(
|
||||
children: [
|
||||
if (_controller != null)
|
||||
Positioned.fill(child: WebViewWidget(controller: _controller!)),
|
||||
if (_error != null)
|
||||
Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Icon(Icons.wifi_off_rounded, size: 32),
|
||||
const SizedBox(height: 12),
|
||||
const Text('商城加载失败'),
|
||||
const SizedBox(height: 8),
|
||||
TextButton(
|
||||
onPressed: () => _load(_loadedUrl ?? ''),
|
||||
child: const Text('重新加载'),
|
||||
),
|
||||
],
|
||||
),
|
||||
// Qiyu already consumes CSS safe-area values. Adding Flutter's status-bar
|
||||
// inset here shifts its own blue navigation bar down a second time.
|
||||
const topInset = 0.0;
|
||||
final bottomInset = qiyuWebViewBottomInset(
|
||||
isShowingQiyuPage: _isShowingQiyuPage || _hasEmbeddedQiyuFrame,
|
||||
platform: defaultTargetPlatform,
|
||||
keyboardInset: MediaQuery.viewInsetsOf(context).bottom,
|
||||
isWeb: kIsWeb,
|
||||
);
|
||||
final showLoading = shouldShowMallLoading(
|
||||
hasError: _errorText != null,
|
||||
hasDisplayedPage: _hasDisplayedPage,
|
||||
progress: _progress,
|
||||
);
|
||||
final content = Stack(
|
||||
children: [
|
||||
if (_errorText == null)
|
||||
Positioned(
|
||||
top: topInset,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: bottomInset,
|
||||
child: WebViewWidget(controller: _controller),
|
||||
),
|
||||
if (showLoading) const MallLoadingIndicator(),
|
||||
if (_errorText != null)
|
||||
Center(
|
||||
child: TextButton(
|
||||
onPressed: () => unawaited(_retry()),
|
||||
child: const Text('商城加载失败,点击重试'),
|
||||
),
|
||||
if (_error == null && _progress < 100)
|
||||
const Align(
|
||||
alignment: Alignment.topCenter,
|
||||
child: LinearProgressIndicator(minHeight: 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
return _isShowingQiyuPage
|
||||
? AnnotatedRegion<SystemUiOverlayStyle>(
|
||||
value: _qiyuSystemUiOverlayStyle,
|
||||
child: content,
|
||||
)
|
||||
: content;
|
||||
}
|
||||
}
|
||||
|
||||
enum _WebMediaCaptureContext { none, qiyu, faceVerification }
|
||||
|
||||
/// Keep Qiyu's Android WebView above the IME. Its delayed focus-scroll must
|
||||
/// not stack with Android WebView's visual-viewport resize; regular mall pages
|
||||
/// and iOS retain their native keyboard behavior.
|
||||
double qiyuWebViewBottomInset({
|
||||
required bool isShowingQiyuPage,
|
||||
required TargetPlatform platform,
|
||||
required double keyboardInset,
|
||||
required bool isWeb,
|
||||
}) {
|
||||
if (isWeb ||
|
||||
!isShowingQiyuPage ||
|
||||
platform != TargetPlatform.android ||
|
||||
keyboardInset <= 0) {
|
||||
return 0;
|
||||
}
|
||||
return keyboardInset;
|
||||
}
|
||||
|
||||
bool shouldShowMallLoading({
|
||||
required bool hasError,
|
||||
required bool hasDisplayedPage,
|
||||
required int progress,
|
||||
}) => !hasError && !hasDisplayedPage && progress < 100;
|
||||
|
||||
class MallLoadingIndicator extends StatelessWidget {
|
||||
const MallLoadingIndicator({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final color = ShadTheme.of(context).colorScheme.primary;
|
||||
return Center(
|
||||
child: SizedBox(
|
||||
width: 28,
|
||||
height: 28,
|
||||
child: CircularProgressIndicator(strokeWidth: 2, color: color),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
import 'package:webview_flutter/webview_flutter.dart';
|
||||
|
||||
import '../application/web_navigation_policy.dart';
|
||||
|
||||
/// Shared HTTPS WebView host. Mall-specific residency and attribution are
|
||||
/// layered above this page so ordinary H5 links never receive mall state.
|
||||
class AppWebPage extends StatefulWidget {
|
||||
@@ -10,12 +15,10 @@ class AppWebPage extends StatefulWidget {
|
||||
super.key,
|
||||
required this.initialUri,
|
||||
this.hideTitle = false,
|
||||
this.allowedHosts = const <String>{},
|
||||
});
|
||||
|
||||
final Uri initialUri;
|
||||
final bool hideTitle;
|
||||
final Set<String> allowedHosts;
|
||||
|
||||
@override
|
||||
State<AppWebPage> createState() => _AppWebPageState();
|
||||
@@ -30,53 +33,54 @@ class _AppWebPageState extends State<AppWebPage> {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_controller = WebViewController()
|
||||
..setJavaScriptMode(JavaScriptMode.unrestricted)
|
||||
..addJavaScriptChannel(
|
||||
'JinzhiBridge',
|
||||
onMessageReceived: (message) {
|
||||
if (message.message == 'close' && mounted) context.pop();
|
||||
},
|
||||
)
|
||||
..setNavigationDelegate(
|
||||
NavigationDelegate(
|
||||
onProgress: (progress) => setState(() => _progress = progress),
|
||||
onPageFinished: (_) async {
|
||||
final title = await _controller.getTitle();
|
||||
if (mounted && title != null && title.trim().isNotEmpty) {
|
||||
setState(() => _title = title.trim());
|
||||
}
|
||||
},
|
||||
onWebResourceError: (error) {
|
||||
if (error.isForMainFrame == true && mounted) {
|
||||
setState(() => _error = error.description);
|
||||
}
|
||||
},
|
||||
onNavigationRequest: _onNavigationRequest,
|
||||
),
|
||||
)
|
||||
..loadRequest(widget.initialUri);
|
||||
_controller =
|
||||
WebViewController(
|
||||
// Ordinary pages never receive camera/microphone or other protected
|
||||
// resources. MallWebHost owns its separate permission policy.
|
||||
onPermissionRequest: (request) => unawaited(request.deny()),
|
||||
)
|
||||
..setJavaScriptMode(JavaScriptMode.unrestricted)
|
||||
..addJavaScriptChannel(
|
||||
'JinzhiBridge',
|
||||
onMessageReceived: (message) {
|
||||
if (message.message == 'close' && mounted) context.pop();
|
||||
},
|
||||
)
|
||||
..setNavigationDelegate(
|
||||
NavigationDelegate(
|
||||
onProgress: (progress) => setState(() => _progress = progress),
|
||||
onPageFinished: (_) async {
|
||||
final title = await _controller.getTitle();
|
||||
if (mounted && title != null && title.trim().isNotEmpty) {
|
||||
setState(() => _title = title.trim());
|
||||
}
|
||||
},
|
||||
onWebResourceError: (error) {
|
||||
if (error.isForMainFrame == true && mounted) {
|
||||
setState(() => _error = error.description);
|
||||
}
|
||||
},
|
||||
onNavigationRequest: _onNavigationRequest,
|
||||
),
|
||||
)
|
||||
..loadRequest(widget.initialUri);
|
||||
}
|
||||
|
||||
NavigationDecision _onNavigationRequest(NavigationRequest request) {
|
||||
final uri = Uri.tryParse(request.url);
|
||||
if (uri == null || !_isHttp(uri)) return NavigationDecision.prevent;
|
||||
if (_allowsInApp(uri)) return NavigationDecision.navigate;
|
||||
launchUrl(uri, mode: LaunchMode.externalApplication);
|
||||
return NavigationDecision.prevent;
|
||||
if (uri != null && shouldOpenWebUrlExternally(request.url)) {
|
||||
launchUrl(uri, mode: LaunchMode.externalApplication);
|
||||
return NavigationDecision.prevent;
|
||||
}
|
||||
if (uri == null || !_isAllowedWebUri(uri)) {
|
||||
return NavigationDecision.prevent;
|
||||
}
|
||||
// Align with jinzhi-flutter-app: a normal HTTPS navigation stays in this
|
||||
// WebView even when its host changes. H5 opts into an external handoff
|
||||
// through `out`, which is handled above.
|
||||
return NavigationDecision.navigate;
|
||||
}
|
||||
|
||||
bool _allowsInApp(Uri uri) {
|
||||
final host = uri.host.toLowerCase();
|
||||
if (host == widget.initialUri.host.toLowerCase()) return true;
|
||||
return widget.allowedHosts.any(
|
||||
(allowed) => host == allowed || host.endsWith('.$allowed'),
|
||||
);
|
||||
}
|
||||
|
||||
bool _isHttp(Uri uri) =>
|
||||
uri.hasAuthority && (uri.scheme == 'https' || uri.scheme == 'http');
|
||||
|
||||
Future<bool> _onPop() async {
|
||||
if (await _controller.canGoBack()) {
|
||||
await _controller.goBack();
|
||||
@@ -118,6 +122,16 @@ class _AppWebPageState extends State<AppWebPage> {
|
||||
|
||||
Uri? parseSafeWebUri(String? raw) {
|
||||
final uri = Uri.tryParse(raw?.trim() ?? '');
|
||||
if (uri == null || !uri.hasAuthority) return null;
|
||||
return uri.scheme == 'https' || uri.scheme == 'http' ? uri : null;
|
||||
return uri != null && _isAllowedWebUri(uri) ? uri : null;
|
||||
}
|
||||
|
||||
bool _isAllowedWebUri(Uri uri) {
|
||||
if (!uri.hasAuthority || uri.host.isEmpty) return false;
|
||||
if (uri.scheme.toLowerCase() == 'https') return true;
|
||||
if (kReleaseMode || uri.scheme.toLowerCase() != 'http') return false;
|
||||
return const {
|
||||
'localhost',
|
||||
'127.0.0.1',
|
||||
'::1',
|
||||
}.contains(uri.host.toLowerCase());
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
//
|
||||
// Generated code. Do not modify.
|
||||
// source: errors/errors.proto
|
||||
//
|
||||
// @dart = 2.12
|
||||
|
||||
// ignore_for_file: annotate_overrides, camel_case_types, comment_references
|
||||
// ignore_for_file: constant_identifier_names, library_prefixes
|
||||
// ignore_for_file: non_constant_identifier_names, prefer_final_fields
|
||||
// ignore_for_file: unnecessary_import, unnecessary_this, unused_import
|
||||
|
||||
import 'dart:core' as $core;
|
||||
|
||||
import 'package:protobuf/protobuf.dart' as $pb;
|
||||
|
||||
class Status extends $pb.GeneratedMessage {
|
||||
factory Status({
|
||||
$core.int? code,
|
||||
$core.String? reason,
|
||||
$core.String? message,
|
||||
$core.Map<$core.String, $core.String>? metadata,
|
||||
}) {
|
||||
final $result = create();
|
||||
if (code != null) {
|
||||
$result.code = code;
|
||||
}
|
||||
if (reason != null) {
|
||||
$result.reason = reason;
|
||||
}
|
||||
if (message != null) {
|
||||
$result.message = message;
|
||||
}
|
||||
if (metadata != null) {
|
||||
$result.metadata.addAll(metadata);
|
||||
}
|
||||
return $result;
|
||||
}
|
||||
Status._() : super();
|
||||
factory Status.fromBuffer($core.List<$core.int> i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromBuffer(i, r);
|
||||
factory Status.fromJson($core.String i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromJson(i, r);
|
||||
|
||||
static final $pb.BuilderInfo _i = $pb.BuilderInfo(_omitMessageNames ? '' : 'Status', package: const $pb.PackageName(_omitMessageNames ? '' : 'errors'), createEmptyInstance: create)
|
||||
..a<$core.int>(1, _omitFieldNames ? '' : 'code', $pb.PbFieldType.O3)
|
||||
..aOS(2, _omitFieldNames ? '' : 'reason')
|
||||
..aOS(3, _omitFieldNames ? '' : 'message')
|
||||
..m<$core.String, $core.String>(4, _omitFieldNames ? '' : 'metadata', entryClassName: 'Status.MetadataEntry', keyFieldType: $pb.PbFieldType.OS, valueFieldType: $pb.PbFieldType.OS, packageName: const $pb.PackageName('errors'))
|
||||
..hasRequiredFields = false
|
||||
;
|
||||
|
||||
@$core.Deprecated(
|
||||
'Using this can add significant overhead to your binary. '
|
||||
'Use [GeneratedMessageGenericExtensions.deepCopy] instead. '
|
||||
'Will be removed in next major version')
|
||||
Status clone() => Status()..mergeFromMessage(this);
|
||||
@$core.Deprecated(
|
||||
'Using this can add significant overhead to your binary. '
|
||||
'Use [GeneratedMessageGenericExtensions.rebuild] instead. '
|
||||
'Will be removed in next major version')
|
||||
Status copyWith(void Function(Status) updates) => super.copyWith((message) => updates(message as Status)) as Status;
|
||||
|
||||
$pb.BuilderInfo get info_ => _i;
|
||||
|
||||
@$core.pragma('dart2js:noInline')
|
||||
static Status create() => Status._();
|
||||
Status createEmptyInstance() => create();
|
||||
static $pb.PbList<Status> createRepeated() => $pb.PbList<Status>();
|
||||
@$core.pragma('dart2js:noInline')
|
||||
static Status getDefault() => _defaultInstance ??= $pb.GeneratedMessage.$_defaultFor<Status>(create);
|
||||
static Status? _defaultInstance;
|
||||
|
||||
@$pb.TagNumber(1)
|
||||
$core.int get code => $_getIZ(0);
|
||||
@$pb.TagNumber(1)
|
||||
set code($core.int v) { $_setSignedInt32(0, v); }
|
||||
@$pb.TagNumber(1)
|
||||
$core.bool hasCode() => $_has(0);
|
||||
@$pb.TagNumber(1)
|
||||
void clearCode() => clearField(1);
|
||||
|
||||
@$pb.TagNumber(2)
|
||||
$core.String get reason => $_getSZ(1);
|
||||
@$pb.TagNumber(2)
|
||||
set reason($core.String v) { $_setString(1, v); }
|
||||
@$pb.TagNumber(2)
|
||||
$core.bool hasReason() => $_has(1);
|
||||
@$pb.TagNumber(2)
|
||||
void clearReason() => clearField(2);
|
||||
|
||||
@$pb.TagNumber(3)
|
||||
$core.String get message => $_getSZ(2);
|
||||
@$pb.TagNumber(3)
|
||||
set message($core.String v) { $_setString(2, v); }
|
||||
@$pb.TagNumber(3)
|
||||
$core.bool hasMessage() => $_has(2);
|
||||
@$pb.TagNumber(3)
|
||||
void clearMessage() => clearField(3);
|
||||
|
||||
@$pb.TagNumber(4)
|
||||
$core.Map<$core.String, $core.String> get metadata => $_getMap(3);
|
||||
}
|
||||
|
||||
class Errors {
|
||||
static final defaultCode = $pb.Extension<$core.int>(_omitMessageNames ? '' : 'google.protobuf.EnumOptions', _omitFieldNames ? '' : 'defaultCode', 1108, $pb.PbFieldType.O3);
|
||||
static final code = $pb.Extension<$core.int>(_omitMessageNames ? '' : 'google.protobuf.EnumValueOptions', _omitFieldNames ? '' : 'code', 1109, $pb.PbFieldType.O3);
|
||||
static void registerAllExtensions($pb.ExtensionRegistry registry) {
|
||||
registry.add(defaultCode);
|
||||
registry.add(code);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
const _omitFieldNames = $core.bool.fromEnvironment('protobuf.omit_field_names');
|
||||
const _omitMessageNames = $core.bool.fromEnvironment('protobuf.omit_message_names');
|
||||
@@ -0,0 +1,11 @@
|
||||
//
|
||||
// Generated code. Do not modify.
|
||||
// source: errors/errors.proto
|
||||
//
|
||||
// @dart = 2.12
|
||||
|
||||
// ignore_for_file: annotate_overrides, camel_case_types, comment_references
|
||||
// ignore_for_file: constant_identifier_names, library_prefixes
|
||||
// ignore_for_file: non_constant_identifier_names, prefer_final_fields
|
||||
// ignore_for_file: unnecessary_import, unnecessary_this, unused_import
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
//
|
||||
// Generated code. Do not modify.
|
||||
// source: errors/errors.proto
|
||||
//
|
||||
// @dart = 2.12
|
||||
|
||||
// ignore_for_file: annotate_overrides, camel_case_types, comment_references
|
||||
// ignore_for_file: constant_identifier_names, library_prefixes
|
||||
// ignore_for_file: non_constant_identifier_names, prefer_final_fields
|
||||
// ignore_for_file: unnecessary_import, unnecessary_this, unused_import
|
||||
|
||||
import 'dart:convert' as $convert;
|
||||
import 'dart:core' as $core;
|
||||
import 'dart:typed_data' as $typed_data;
|
||||
|
||||
@$core.Deprecated('Use statusDescriptor instead')
|
||||
const Status$json = {
|
||||
'1': 'Status',
|
||||
'2': [
|
||||
{'1': 'code', '3': 1, '4': 1, '5': 5, '10': 'code'},
|
||||
{'1': 'reason', '3': 2, '4': 1, '5': 9, '10': 'reason'},
|
||||
{'1': 'message', '3': 3, '4': 1, '5': 9, '10': 'message'},
|
||||
{'1': 'metadata', '3': 4, '4': 3, '5': 11, '6': '.errors.Status.MetadataEntry', '10': 'metadata'},
|
||||
],
|
||||
'3': [Status_MetadataEntry$json],
|
||||
};
|
||||
|
||||
@$core.Deprecated('Use statusDescriptor instead')
|
||||
const Status_MetadataEntry$json = {
|
||||
'1': 'MetadataEntry',
|
||||
'2': [
|
||||
{'1': 'key', '3': 1, '4': 1, '5': 9, '10': 'key'},
|
||||
{'1': 'value', '3': 2, '4': 1, '5': 9, '10': 'value'},
|
||||
],
|
||||
'7': {'7': true},
|
||||
};
|
||||
|
||||
/// Descriptor for `Status`. Decode as a `google.protobuf.DescriptorProto`.
|
||||
final $typed_data.Uint8List statusDescriptor = $convert.base64Decode(
|
||||
'CgZTdGF0dXMSEgoEY29kZRgBIAEoBVIEY29kZRIWCgZyZWFzb24YAiABKAlSBnJlYXNvbhIYCg'
|
||||
'dtZXNzYWdlGAMgASgJUgdtZXNzYWdlEjgKCG1ldGFkYXRhGAQgAygLMhwuZXJyb3JzLlN0YXR1'
|
||||
'cy5NZXRhZGF0YUVudHJ5UghtZXRhZGF0YRo7Cg1NZXRhZGF0YUVudHJ5EhAKA2tleRgBIAEoCV'
|
||||
'IDa2V5EhQKBXZhbHVlGAIgASgJUgV2YWx1ZToCOAE=');
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
//
|
||||
// Generated code. Do not modify.
|
||||
// source: errors/errors.proto
|
||||
//
|
||||
// @dart = 2.12
|
||||
|
||||
// ignore_for_file: annotate_overrides, camel_case_types, comment_references
|
||||
// ignore_for_file: constant_identifier_names
|
||||
// ignore_for_file: deprecated_member_use_from_same_package, library_prefixes
|
||||
// ignore_for_file: non_constant_identifier_names, prefer_final_fields
|
||||
// ignore_for_file: unnecessary_import, unnecessary_this, unused_import
|
||||
|
||||
export 'errors.pb.dart';
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
//
|
||||
// Generated code. Do not modify.
|
||||
// source: google/protobuf/empty.proto
|
||||
//
|
||||
// @dart = 2.12
|
||||
|
||||
// ignore_for_file: annotate_overrides, camel_case_types, comment_references
|
||||
// ignore_for_file: constant_identifier_names, library_prefixes
|
||||
// ignore_for_file: non_constant_identifier_names, prefer_final_fields
|
||||
// ignore_for_file: unnecessary_import, unnecessary_this, unused_import
|
||||
|
||||
import 'dart:core' as $core;
|
||||
|
||||
import 'package:protobuf/protobuf.dart' as $pb;
|
||||
|
||||
/// A generic empty message that you can re-use to avoid defining duplicated
|
||||
/// empty messages in your APIs. A typical example is to use it as the request
|
||||
/// or the response type of an API method. For instance:
|
||||
///
|
||||
/// service Foo {
|
||||
/// rpc Bar(google.protobuf.Empty) returns (google.protobuf.Empty);
|
||||
/// }
|
||||
///
|
||||
/// The JSON representation for `Empty` is empty JSON object `{}`.
|
||||
class Empty extends $pb.GeneratedMessage {
|
||||
factory Empty() => create();
|
||||
Empty._() : super();
|
||||
factory Empty.fromBuffer($core.List<$core.int> i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromBuffer(i, r);
|
||||
factory Empty.fromJson($core.String i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromJson(i, r);
|
||||
|
||||
static final $pb.BuilderInfo _i = $pb.BuilderInfo(_omitMessageNames ? '' : 'Empty', package: const $pb.PackageName(_omitMessageNames ? '' : 'google.protobuf'), createEmptyInstance: create)
|
||||
..hasRequiredFields = false
|
||||
;
|
||||
|
||||
@$core.Deprecated(
|
||||
'Using this can add significant overhead to your binary. '
|
||||
'Use [GeneratedMessageGenericExtensions.deepCopy] instead. '
|
||||
'Will be removed in next major version')
|
||||
Empty clone() => Empty()..mergeFromMessage(this);
|
||||
@$core.Deprecated(
|
||||
'Using this can add significant overhead to your binary. '
|
||||
'Use [GeneratedMessageGenericExtensions.rebuild] instead. '
|
||||
'Will be removed in next major version')
|
||||
Empty copyWith(void Function(Empty) updates) => super.copyWith((message) => updates(message as Empty)) as Empty;
|
||||
|
||||
$pb.BuilderInfo get info_ => _i;
|
||||
|
||||
@$core.pragma('dart2js:noInline')
|
||||
static Empty create() => Empty._();
|
||||
Empty createEmptyInstance() => create();
|
||||
static $pb.PbList<Empty> createRepeated() => $pb.PbList<Empty>();
|
||||
@$core.pragma('dart2js:noInline')
|
||||
static Empty getDefault() => _defaultInstance ??= $pb.GeneratedMessage.$_defaultFor<Empty>(create);
|
||||
static Empty? _defaultInstance;
|
||||
}
|
||||
|
||||
|
||||
const _omitMessageNames = $core.bool.fromEnvironment('protobuf.omit_message_names');
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,868 @@
|
||||
//
|
||||
// Generated code. Do not modify.
|
||||
// source: jinzhi/v1/ads.proto
|
||||
//
|
||||
// @dart = 2.12
|
||||
|
||||
// ignore_for_file: annotate_overrides, camel_case_types, comment_references
|
||||
// ignore_for_file: constant_identifier_names, library_prefixes
|
||||
// ignore_for_file: non_constant_identifier_names, prefer_final_fields
|
||||
// ignore_for_file: unnecessary_import, unnecessary_this, unused_import
|
||||
|
||||
import 'dart:async' as $async;
|
||||
import 'dart:core' as $core;
|
||||
|
||||
import 'package:fixnum/fixnum.dart' as $fixnum;
|
||||
import 'package:protobuf/protobuf.dart' as $pb;
|
||||
|
||||
import 'ads.pbenum.dart';
|
||||
|
||||
export 'ads.pbenum.dart';
|
||||
|
||||
class GetAdRequest extends $pb.GeneratedMessage {
|
||||
factory GetAdRequest({
|
||||
$core.String? placement,
|
||||
}) {
|
||||
final $result = create();
|
||||
if (placement != null) {
|
||||
$result.placement = placement;
|
||||
}
|
||||
return $result;
|
||||
}
|
||||
GetAdRequest._() : super();
|
||||
factory GetAdRequest.fromBuffer($core.List<$core.int> i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromBuffer(i, r);
|
||||
factory GetAdRequest.fromJson($core.String i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromJson(i, r);
|
||||
|
||||
static final $pb.BuilderInfo _i = $pb.BuilderInfo(_omitMessageNames ? '' : 'GetAdRequest', package: const $pb.PackageName(_omitMessageNames ? '' : 'jinzhi.v1'), createEmptyInstance: create)
|
||||
..aOS(1, _omitFieldNames ? '' : 'placement')
|
||||
..hasRequiredFields = false
|
||||
;
|
||||
|
||||
@$core.Deprecated(
|
||||
'Using this can add significant overhead to your binary. '
|
||||
'Use [GeneratedMessageGenericExtensions.deepCopy] instead. '
|
||||
'Will be removed in next major version')
|
||||
GetAdRequest clone() => GetAdRequest()..mergeFromMessage(this);
|
||||
@$core.Deprecated(
|
||||
'Using this can add significant overhead to your binary. '
|
||||
'Use [GeneratedMessageGenericExtensions.rebuild] instead. '
|
||||
'Will be removed in next major version')
|
||||
GetAdRequest copyWith(void Function(GetAdRequest) updates) => super.copyWith((message) => updates(message as GetAdRequest)) as GetAdRequest;
|
||||
|
||||
$pb.BuilderInfo get info_ => _i;
|
||||
|
||||
@$core.pragma('dart2js:noInline')
|
||||
static GetAdRequest create() => GetAdRequest._();
|
||||
GetAdRequest createEmptyInstance() => create();
|
||||
static $pb.PbList<GetAdRequest> createRepeated() => $pb.PbList<GetAdRequest>();
|
||||
@$core.pragma('dart2js:noInline')
|
||||
static GetAdRequest getDefault() => _defaultInstance ??= $pb.GeneratedMessage.$_defaultFor<GetAdRequest>(create);
|
||||
static GetAdRequest? _defaultInstance;
|
||||
|
||||
@$pb.TagNumber(1)
|
||||
$core.String get placement => $_getSZ(0);
|
||||
@$pb.TagNumber(1)
|
||||
set placement($core.String v) { $_setString(0, v); }
|
||||
@$pb.TagNumber(1)
|
||||
$core.bool hasPlacement() => $_has(0);
|
||||
@$pb.TagNumber(1)
|
||||
void clearPlacement() => clearField(1);
|
||||
}
|
||||
|
||||
class GetAdReply extends $pb.GeneratedMessage {
|
||||
factory GetAdReply({
|
||||
$core.bool? enabled,
|
||||
AdCreative? ad,
|
||||
$core.int? dailyCap,
|
||||
}) {
|
||||
final $result = create();
|
||||
if (enabled != null) {
|
||||
$result.enabled = enabled;
|
||||
}
|
||||
if (ad != null) {
|
||||
$result.ad = ad;
|
||||
}
|
||||
if (dailyCap != null) {
|
||||
$result.dailyCap = dailyCap;
|
||||
}
|
||||
return $result;
|
||||
}
|
||||
GetAdReply._() : super();
|
||||
factory GetAdReply.fromBuffer($core.List<$core.int> i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromBuffer(i, r);
|
||||
factory GetAdReply.fromJson($core.String i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromJson(i, r);
|
||||
|
||||
static final $pb.BuilderInfo _i = $pb.BuilderInfo(_omitMessageNames ? '' : 'GetAdReply', package: const $pb.PackageName(_omitMessageNames ? '' : 'jinzhi.v1'), createEmptyInstance: create)
|
||||
..aOB(1, _omitFieldNames ? '' : 'enabled')
|
||||
..aOM<AdCreative>(2, _omitFieldNames ? '' : 'ad', subBuilder: AdCreative.create)
|
||||
..a<$core.int>(3, _omitFieldNames ? '' : 'dailyCap', $pb.PbFieldType.O3)
|
||||
..hasRequiredFields = false
|
||||
;
|
||||
|
||||
@$core.Deprecated(
|
||||
'Using this can add significant overhead to your binary. '
|
||||
'Use [GeneratedMessageGenericExtensions.deepCopy] instead. '
|
||||
'Will be removed in next major version')
|
||||
GetAdReply clone() => GetAdReply()..mergeFromMessage(this);
|
||||
@$core.Deprecated(
|
||||
'Using this can add significant overhead to your binary. '
|
||||
'Use [GeneratedMessageGenericExtensions.rebuild] instead. '
|
||||
'Will be removed in next major version')
|
||||
GetAdReply copyWith(void Function(GetAdReply) updates) => super.copyWith((message) => updates(message as GetAdReply)) as GetAdReply;
|
||||
|
||||
$pb.BuilderInfo get info_ => _i;
|
||||
|
||||
@$core.pragma('dart2js:noInline')
|
||||
static GetAdReply create() => GetAdReply._();
|
||||
GetAdReply createEmptyInstance() => create();
|
||||
static $pb.PbList<GetAdReply> createRepeated() => $pb.PbList<GetAdReply>();
|
||||
@$core.pragma('dart2js:noInline')
|
||||
static GetAdReply getDefault() => _defaultInstance ??= $pb.GeneratedMessage.$_defaultFor<GetAdReply>(create);
|
||||
static GetAdReply? _defaultInstance;
|
||||
|
||||
@$pb.TagNumber(1)
|
||||
$core.bool get enabled => $_getBF(0);
|
||||
@$pb.TagNumber(1)
|
||||
set enabled($core.bool v) { $_setBool(0, v); }
|
||||
@$pb.TagNumber(1)
|
||||
$core.bool hasEnabled() => $_has(0);
|
||||
@$pb.TagNumber(1)
|
||||
void clearEnabled() => clearField(1);
|
||||
|
||||
@$pb.TagNumber(2)
|
||||
AdCreative get ad => $_getN(1);
|
||||
@$pb.TagNumber(2)
|
||||
set ad(AdCreative v) { setField(2, v); }
|
||||
@$pb.TagNumber(2)
|
||||
$core.bool hasAd() => $_has(1);
|
||||
@$pb.TagNumber(2)
|
||||
void clearAd() => clearField(2);
|
||||
@$pb.TagNumber(2)
|
||||
AdCreative ensureAd() => $_ensure(1);
|
||||
|
||||
@$pb.TagNumber(3)
|
||||
$core.int get dailyCap => $_getIZ(2);
|
||||
@$pb.TagNumber(3)
|
||||
set dailyCap($core.int v) { $_setSignedInt32(2, v); }
|
||||
@$pb.TagNumber(3)
|
||||
$core.bool hasDailyCap() => $_has(2);
|
||||
@$pb.TagNumber(3)
|
||||
void clearDailyCap() => clearField(3);
|
||||
}
|
||||
|
||||
class ListAdsRequest extends $pb.GeneratedMessage {
|
||||
factory ListAdsRequest({
|
||||
$core.String? placement,
|
||||
}) {
|
||||
final $result = create();
|
||||
if (placement != null) {
|
||||
$result.placement = placement;
|
||||
}
|
||||
return $result;
|
||||
}
|
||||
ListAdsRequest._() : super();
|
||||
factory ListAdsRequest.fromBuffer($core.List<$core.int> i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromBuffer(i, r);
|
||||
factory ListAdsRequest.fromJson($core.String i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromJson(i, r);
|
||||
|
||||
static final $pb.BuilderInfo _i = $pb.BuilderInfo(_omitMessageNames ? '' : 'ListAdsRequest', package: const $pb.PackageName(_omitMessageNames ? '' : 'jinzhi.v1'), createEmptyInstance: create)
|
||||
..aOS(1, _omitFieldNames ? '' : 'placement')
|
||||
..hasRequiredFields = false
|
||||
;
|
||||
|
||||
@$core.Deprecated(
|
||||
'Using this can add significant overhead to your binary. '
|
||||
'Use [GeneratedMessageGenericExtensions.deepCopy] instead. '
|
||||
'Will be removed in next major version')
|
||||
ListAdsRequest clone() => ListAdsRequest()..mergeFromMessage(this);
|
||||
@$core.Deprecated(
|
||||
'Using this can add significant overhead to your binary. '
|
||||
'Use [GeneratedMessageGenericExtensions.rebuild] instead. '
|
||||
'Will be removed in next major version')
|
||||
ListAdsRequest copyWith(void Function(ListAdsRequest) updates) => super.copyWith((message) => updates(message as ListAdsRequest)) as ListAdsRequest;
|
||||
|
||||
$pb.BuilderInfo get info_ => _i;
|
||||
|
||||
@$core.pragma('dart2js:noInline')
|
||||
static ListAdsRequest create() => ListAdsRequest._();
|
||||
ListAdsRequest createEmptyInstance() => create();
|
||||
static $pb.PbList<ListAdsRequest> createRepeated() => $pb.PbList<ListAdsRequest>();
|
||||
@$core.pragma('dart2js:noInline')
|
||||
static ListAdsRequest getDefault() => _defaultInstance ??= $pb.GeneratedMessage.$_defaultFor<ListAdsRequest>(create);
|
||||
static ListAdsRequest? _defaultInstance;
|
||||
|
||||
@$pb.TagNumber(1)
|
||||
$core.String get placement => $_getSZ(0);
|
||||
@$pb.TagNumber(1)
|
||||
set placement($core.String v) { $_setString(0, v); }
|
||||
@$pb.TagNumber(1)
|
||||
$core.bool hasPlacement() => $_has(0);
|
||||
@$pb.TagNumber(1)
|
||||
void clearPlacement() => clearField(1);
|
||||
}
|
||||
|
||||
class ListAdsReply extends $pb.GeneratedMessage {
|
||||
factory ListAdsReply({
|
||||
$core.bool? enabled,
|
||||
$core.Iterable<AdCreative>? ads,
|
||||
$core.String? trackingId,
|
||||
}) {
|
||||
final $result = create();
|
||||
if (enabled != null) {
|
||||
$result.enabled = enabled;
|
||||
}
|
||||
if (ads != null) {
|
||||
$result.ads.addAll(ads);
|
||||
}
|
||||
if (trackingId != null) {
|
||||
$result.trackingId = trackingId;
|
||||
}
|
||||
return $result;
|
||||
}
|
||||
ListAdsReply._() : super();
|
||||
factory ListAdsReply.fromBuffer($core.List<$core.int> i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromBuffer(i, r);
|
||||
factory ListAdsReply.fromJson($core.String i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromJson(i, r);
|
||||
|
||||
static final $pb.BuilderInfo _i = $pb.BuilderInfo(_omitMessageNames ? '' : 'ListAdsReply', package: const $pb.PackageName(_omitMessageNames ? '' : 'jinzhi.v1'), createEmptyInstance: create)
|
||||
..aOB(1, _omitFieldNames ? '' : 'enabled')
|
||||
..pc<AdCreative>(2, _omitFieldNames ? '' : 'ads', $pb.PbFieldType.PM, subBuilder: AdCreative.create)
|
||||
..aOS(3, _omitFieldNames ? '' : 'trackingId')
|
||||
..hasRequiredFields = false
|
||||
;
|
||||
|
||||
@$core.Deprecated(
|
||||
'Using this can add significant overhead to your binary. '
|
||||
'Use [GeneratedMessageGenericExtensions.deepCopy] instead. '
|
||||
'Will be removed in next major version')
|
||||
ListAdsReply clone() => ListAdsReply()..mergeFromMessage(this);
|
||||
@$core.Deprecated(
|
||||
'Using this can add significant overhead to your binary. '
|
||||
'Use [GeneratedMessageGenericExtensions.rebuild] instead. '
|
||||
'Will be removed in next major version')
|
||||
ListAdsReply copyWith(void Function(ListAdsReply) updates) => super.copyWith((message) => updates(message as ListAdsReply)) as ListAdsReply;
|
||||
|
||||
$pb.BuilderInfo get info_ => _i;
|
||||
|
||||
@$core.pragma('dart2js:noInline')
|
||||
static ListAdsReply create() => ListAdsReply._();
|
||||
ListAdsReply createEmptyInstance() => create();
|
||||
static $pb.PbList<ListAdsReply> createRepeated() => $pb.PbList<ListAdsReply>();
|
||||
@$core.pragma('dart2js:noInline')
|
||||
static ListAdsReply getDefault() => _defaultInstance ??= $pb.GeneratedMessage.$_defaultFor<ListAdsReply>(create);
|
||||
static ListAdsReply? _defaultInstance;
|
||||
|
||||
@$pb.TagNumber(1)
|
||||
$core.bool get enabled => $_getBF(0);
|
||||
@$pb.TagNumber(1)
|
||||
set enabled($core.bool v) { $_setBool(0, v); }
|
||||
@$pb.TagNumber(1)
|
||||
$core.bool hasEnabled() => $_has(0);
|
||||
@$pb.TagNumber(1)
|
||||
void clearEnabled() => clearField(1);
|
||||
|
||||
@$pb.TagNumber(2)
|
||||
$core.List<AdCreative> get ads => $_getList(1);
|
||||
|
||||
@$pb.TagNumber(3)
|
||||
$core.String get trackingId => $_getSZ(2);
|
||||
@$pb.TagNumber(3)
|
||||
set trackingId($core.String v) { $_setString(2, v); }
|
||||
@$pb.TagNumber(3)
|
||||
$core.bool hasTrackingId() => $_has(2);
|
||||
@$pb.TagNumber(3)
|
||||
void clearTrackingId() => clearField(3);
|
||||
}
|
||||
|
||||
class AdCreative extends $pb.GeneratedMessage {
|
||||
factory AdCreative({
|
||||
$core.String? adId,
|
||||
$core.String? placement,
|
||||
$core.String? targetApp,
|
||||
$core.String? title,
|
||||
$core.String? subtitle,
|
||||
$core.String? imageUrl,
|
||||
MediaType? mediaType,
|
||||
$core.String? ctaText,
|
||||
$core.String? h5Url,
|
||||
$core.String? trackingId,
|
||||
$core.int? countdownSeconds,
|
||||
$core.int? priority,
|
||||
$fixnum.Int64? endAtMs,
|
||||
$core.String? trackedUrl,
|
||||
AdOpenMode? openMode,
|
||||
$core.bool? entryPromoEnabled,
|
||||
$core.String? entryBadgeText,
|
||||
$core.String? entryTipTitle,
|
||||
$core.String? entryTipBody,
|
||||
MallPromotion? mallPromotion,
|
||||
}) {
|
||||
final $result = create();
|
||||
if (adId != null) {
|
||||
$result.adId = adId;
|
||||
}
|
||||
if (placement != null) {
|
||||
$result.placement = placement;
|
||||
}
|
||||
if (targetApp != null) {
|
||||
$result.targetApp = targetApp;
|
||||
}
|
||||
if (title != null) {
|
||||
$result.title = title;
|
||||
}
|
||||
if (subtitle != null) {
|
||||
$result.subtitle = subtitle;
|
||||
}
|
||||
if (imageUrl != null) {
|
||||
$result.imageUrl = imageUrl;
|
||||
}
|
||||
if (mediaType != null) {
|
||||
$result.mediaType = mediaType;
|
||||
}
|
||||
if (ctaText != null) {
|
||||
$result.ctaText = ctaText;
|
||||
}
|
||||
if (h5Url != null) {
|
||||
$result.h5Url = h5Url;
|
||||
}
|
||||
if (trackingId != null) {
|
||||
$result.trackingId = trackingId;
|
||||
}
|
||||
if (countdownSeconds != null) {
|
||||
$result.countdownSeconds = countdownSeconds;
|
||||
}
|
||||
if (priority != null) {
|
||||
$result.priority = priority;
|
||||
}
|
||||
if (endAtMs != null) {
|
||||
$result.endAtMs = endAtMs;
|
||||
}
|
||||
if (trackedUrl != null) {
|
||||
$result.trackedUrl = trackedUrl;
|
||||
}
|
||||
if (openMode != null) {
|
||||
$result.openMode = openMode;
|
||||
}
|
||||
if (entryPromoEnabled != null) {
|
||||
$result.entryPromoEnabled = entryPromoEnabled;
|
||||
}
|
||||
if (entryBadgeText != null) {
|
||||
$result.entryBadgeText = entryBadgeText;
|
||||
}
|
||||
if (entryTipTitle != null) {
|
||||
$result.entryTipTitle = entryTipTitle;
|
||||
}
|
||||
if (entryTipBody != null) {
|
||||
$result.entryTipBody = entryTipBody;
|
||||
}
|
||||
if (mallPromotion != null) {
|
||||
$result.mallPromotion = mallPromotion;
|
||||
}
|
||||
return $result;
|
||||
}
|
||||
AdCreative._() : super();
|
||||
factory AdCreative.fromBuffer($core.List<$core.int> i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromBuffer(i, r);
|
||||
factory AdCreative.fromJson($core.String i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromJson(i, r);
|
||||
|
||||
static final $pb.BuilderInfo _i = $pb.BuilderInfo(_omitMessageNames ? '' : 'AdCreative', package: const $pb.PackageName(_omitMessageNames ? '' : 'jinzhi.v1'), createEmptyInstance: create)
|
||||
..aOS(1, _omitFieldNames ? '' : 'adId')
|
||||
..aOS(2, _omitFieldNames ? '' : 'placement')
|
||||
..aOS(3, _omitFieldNames ? '' : 'targetApp')
|
||||
..aOS(4, _omitFieldNames ? '' : 'title')
|
||||
..aOS(5, _omitFieldNames ? '' : 'subtitle')
|
||||
..aOS(6, _omitFieldNames ? '' : 'imageUrl')
|
||||
..e<MediaType>(7, _omitFieldNames ? '' : 'mediaType', $pb.PbFieldType.OE, defaultOrMaker: MediaType.MEDIA_TYPE_UNSPECIFIED, valueOf: MediaType.valueOf, enumValues: MediaType.values)
|
||||
..aOS(8, _omitFieldNames ? '' : 'ctaText')
|
||||
..aOS(9, _omitFieldNames ? '' : 'h5Url')
|
||||
..aOS(10, _omitFieldNames ? '' : 'trackingId')
|
||||
..a<$core.int>(11, _omitFieldNames ? '' : 'countdownSeconds', $pb.PbFieldType.O3)
|
||||
..a<$core.int>(12, _omitFieldNames ? '' : 'priority', $pb.PbFieldType.O3)
|
||||
..aInt64(13, _omitFieldNames ? '' : 'endAtMs')
|
||||
..aOS(14, _omitFieldNames ? '' : 'trackedUrl')
|
||||
..e<AdOpenMode>(15, _omitFieldNames ? '' : 'openMode', $pb.PbFieldType.OE, defaultOrMaker: AdOpenMode.AD_OPEN_MODE_UNSPECIFIED, valueOf: AdOpenMode.valueOf, enumValues: AdOpenMode.values)
|
||||
..aOB(16, _omitFieldNames ? '' : 'entryPromoEnabled')
|
||||
..aOS(17, _omitFieldNames ? '' : 'entryBadgeText')
|
||||
..aOS(18, _omitFieldNames ? '' : 'entryTipTitle')
|
||||
..aOS(19, _omitFieldNames ? '' : 'entryTipBody')
|
||||
..aOM<MallPromotion>(20, _omitFieldNames ? '' : 'mallPromotion', subBuilder: MallPromotion.create)
|
||||
..hasRequiredFields = false
|
||||
;
|
||||
|
||||
@$core.Deprecated(
|
||||
'Using this can add significant overhead to your binary. '
|
||||
'Use [GeneratedMessageGenericExtensions.deepCopy] instead. '
|
||||
'Will be removed in next major version')
|
||||
AdCreative clone() => AdCreative()..mergeFromMessage(this);
|
||||
@$core.Deprecated(
|
||||
'Using this can add significant overhead to your binary. '
|
||||
'Use [GeneratedMessageGenericExtensions.rebuild] instead. '
|
||||
'Will be removed in next major version')
|
||||
AdCreative copyWith(void Function(AdCreative) updates) => super.copyWith((message) => updates(message as AdCreative)) as AdCreative;
|
||||
|
||||
$pb.BuilderInfo get info_ => _i;
|
||||
|
||||
@$core.pragma('dart2js:noInline')
|
||||
static AdCreative create() => AdCreative._();
|
||||
AdCreative createEmptyInstance() => create();
|
||||
static $pb.PbList<AdCreative> createRepeated() => $pb.PbList<AdCreative>();
|
||||
@$core.pragma('dart2js:noInline')
|
||||
static AdCreative getDefault() => _defaultInstance ??= $pb.GeneratedMessage.$_defaultFor<AdCreative>(create);
|
||||
static AdCreative? _defaultInstance;
|
||||
|
||||
@$pb.TagNumber(1)
|
||||
$core.String get adId => $_getSZ(0);
|
||||
@$pb.TagNumber(1)
|
||||
set adId($core.String v) { $_setString(0, v); }
|
||||
@$pb.TagNumber(1)
|
||||
$core.bool hasAdId() => $_has(0);
|
||||
@$pb.TagNumber(1)
|
||||
void clearAdId() => clearField(1);
|
||||
|
||||
@$pb.TagNumber(2)
|
||||
$core.String get placement => $_getSZ(1);
|
||||
@$pb.TagNumber(2)
|
||||
set placement($core.String v) { $_setString(1, v); }
|
||||
@$pb.TagNumber(2)
|
||||
$core.bool hasPlacement() => $_has(1);
|
||||
@$pb.TagNumber(2)
|
||||
void clearPlacement() => clearField(2);
|
||||
|
||||
@$pb.TagNumber(3)
|
||||
$core.String get targetApp => $_getSZ(2);
|
||||
@$pb.TagNumber(3)
|
||||
set targetApp($core.String v) { $_setString(2, v); }
|
||||
@$pb.TagNumber(3)
|
||||
$core.bool hasTargetApp() => $_has(2);
|
||||
@$pb.TagNumber(3)
|
||||
void clearTargetApp() => clearField(3);
|
||||
|
||||
@$pb.TagNumber(4)
|
||||
$core.String get title => $_getSZ(3);
|
||||
@$pb.TagNumber(4)
|
||||
set title($core.String v) { $_setString(3, v); }
|
||||
@$pb.TagNumber(4)
|
||||
$core.bool hasTitle() => $_has(3);
|
||||
@$pb.TagNumber(4)
|
||||
void clearTitle() => clearField(4);
|
||||
|
||||
@$pb.TagNumber(5)
|
||||
$core.String get subtitle => $_getSZ(4);
|
||||
@$pb.TagNumber(5)
|
||||
set subtitle($core.String v) { $_setString(4, v); }
|
||||
@$pb.TagNumber(5)
|
||||
$core.bool hasSubtitle() => $_has(4);
|
||||
@$pb.TagNumber(5)
|
||||
void clearSubtitle() => clearField(5);
|
||||
|
||||
@$pb.TagNumber(6)
|
||||
$core.String get imageUrl => $_getSZ(5);
|
||||
@$pb.TagNumber(6)
|
||||
set imageUrl($core.String v) { $_setString(5, v); }
|
||||
@$pb.TagNumber(6)
|
||||
$core.bool hasImageUrl() => $_has(5);
|
||||
@$pb.TagNumber(6)
|
||||
void clearImageUrl() => clearField(6);
|
||||
|
||||
@$pb.TagNumber(7)
|
||||
MediaType get mediaType => $_getN(6);
|
||||
@$pb.TagNumber(7)
|
||||
set mediaType(MediaType v) { setField(7, v); }
|
||||
@$pb.TagNumber(7)
|
||||
$core.bool hasMediaType() => $_has(6);
|
||||
@$pb.TagNumber(7)
|
||||
void clearMediaType() => clearField(7);
|
||||
|
||||
@$pb.TagNumber(8)
|
||||
$core.String get ctaText => $_getSZ(7);
|
||||
@$pb.TagNumber(8)
|
||||
set ctaText($core.String v) { $_setString(7, v); }
|
||||
@$pb.TagNumber(8)
|
||||
$core.bool hasCtaText() => $_has(7);
|
||||
@$pb.TagNumber(8)
|
||||
void clearCtaText() => clearField(8);
|
||||
|
||||
@$pb.TagNumber(9)
|
||||
$core.String get h5Url => $_getSZ(8);
|
||||
@$pb.TagNumber(9)
|
||||
set h5Url($core.String v) { $_setString(8, v); }
|
||||
@$pb.TagNumber(9)
|
||||
$core.bool hasH5Url() => $_has(8);
|
||||
@$pb.TagNumber(9)
|
||||
void clearH5Url() => clearField(9);
|
||||
|
||||
@$pb.TagNumber(10)
|
||||
$core.String get trackingId => $_getSZ(9);
|
||||
@$pb.TagNumber(10)
|
||||
set trackingId($core.String v) { $_setString(9, v); }
|
||||
@$pb.TagNumber(10)
|
||||
$core.bool hasTrackingId() => $_has(9);
|
||||
@$pb.TagNumber(10)
|
||||
void clearTrackingId() => clearField(10);
|
||||
|
||||
@$pb.TagNumber(11)
|
||||
$core.int get countdownSeconds => $_getIZ(10);
|
||||
@$pb.TagNumber(11)
|
||||
set countdownSeconds($core.int v) { $_setSignedInt32(10, v); }
|
||||
@$pb.TagNumber(11)
|
||||
$core.bool hasCountdownSeconds() => $_has(10);
|
||||
@$pb.TagNumber(11)
|
||||
void clearCountdownSeconds() => clearField(11);
|
||||
|
||||
@$pb.TagNumber(12)
|
||||
$core.int get priority => $_getIZ(11);
|
||||
@$pb.TagNumber(12)
|
||||
set priority($core.int v) { $_setSignedInt32(11, v); }
|
||||
@$pb.TagNumber(12)
|
||||
$core.bool hasPriority() => $_has(11);
|
||||
@$pb.TagNumber(12)
|
||||
void clearPriority() => clearField(12);
|
||||
|
||||
@$pb.TagNumber(13)
|
||||
$fixnum.Int64 get endAtMs => $_getI64(12);
|
||||
@$pb.TagNumber(13)
|
||||
set endAtMs($fixnum.Int64 v) { $_setInt64(12, v); }
|
||||
@$pb.TagNumber(13)
|
||||
$core.bool hasEndAtMs() => $_has(12);
|
||||
@$pb.TagNumber(13)
|
||||
void clearEndAtMs() => clearField(13);
|
||||
|
||||
@$pb.TagNumber(14)
|
||||
$core.String get trackedUrl => $_getSZ(13);
|
||||
@$pb.TagNumber(14)
|
||||
set trackedUrl($core.String v) { $_setString(13, v); }
|
||||
@$pb.TagNumber(14)
|
||||
$core.bool hasTrackedUrl() => $_has(13);
|
||||
@$pb.TagNumber(14)
|
||||
void clearTrackedUrl() => clearField(14);
|
||||
|
||||
@$pb.TagNumber(15)
|
||||
AdOpenMode get openMode => $_getN(14);
|
||||
@$pb.TagNumber(15)
|
||||
set openMode(AdOpenMode v) { setField(15, v); }
|
||||
@$pb.TagNumber(15)
|
||||
$core.bool hasOpenMode() => $_has(14);
|
||||
@$pb.TagNumber(15)
|
||||
void clearOpenMode() => clearField(15);
|
||||
|
||||
@$pb.TagNumber(16)
|
||||
$core.bool get entryPromoEnabled => $_getBF(15);
|
||||
@$pb.TagNumber(16)
|
||||
set entryPromoEnabled($core.bool v) { $_setBool(15, v); }
|
||||
@$pb.TagNumber(16)
|
||||
$core.bool hasEntryPromoEnabled() => $_has(15);
|
||||
@$pb.TagNumber(16)
|
||||
void clearEntryPromoEnabled() => clearField(16);
|
||||
|
||||
@$pb.TagNumber(17)
|
||||
$core.String get entryBadgeText => $_getSZ(16);
|
||||
@$pb.TagNumber(17)
|
||||
set entryBadgeText($core.String v) { $_setString(16, v); }
|
||||
@$pb.TagNumber(17)
|
||||
$core.bool hasEntryBadgeText() => $_has(16);
|
||||
@$pb.TagNumber(17)
|
||||
void clearEntryBadgeText() => clearField(17);
|
||||
|
||||
@$pb.TagNumber(18)
|
||||
$core.String get entryTipTitle => $_getSZ(17);
|
||||
@$pb.TagNumber(18)
|
||||
set entryTipTitle($core.String v) { $_setString(17, v); }
|
||||
@$pb.TagNumber(18)
|
||||
$core.bool hasEntryTipTitle() => $_has(17);
|
||||
@$pb.TagNumber(18)
|
||||
void clearEntryTipTitle() => clearField(18);
|
||||
|
||||
@$pb.TagNumber(19)
|
||||
$core.String get entryTipBody => $_getSZ(18);
|
||||
@$pb.TagNumber(19)
|
||||
set entryTipBody($core.String v) { $_setString(18, v); }
|
||||
@$pb.TagNumber(19)
|
||||
$core.bool hasEntryTipBody() => $_has(18);
|
||||
@$pb.TagNumber(19)
|
||||
void clearEntryTipBody() => clearField(19);
|
||||
|
||||
@$pb.TagNumber(20)
|
||||
MallPromotion get mallPromotion => $_getN(19);
|
||||
@$pb.TagNumber(20)
|
||||
set mallPromotion(MallPromotion v) { setField(20, v); }
|
||||
@$pb.TagNumber(20)
|
||||
$core.bool hasMallPromotion() => $_has(19);
|
||||
@$pb.TagNumber(20)
|
||||
void clearMallPromotion() => clearField(20);
|
||||
@$pb.TagNumber(20)
|
||||
MallPromotion ensureMallPromotion() => $_ensure(19);
|
||||
}
|
||||
|
||||
/// MallPromotion is an optional, version-gated set of low-impact entry points
|
||||
/// layered on top of the existing mall_tab creative. Older clients safely
|
||||
/// ignore this message.
|
||||
class MallPromotion extends $pb.GeneratedMessage {
|
||||
factory MallPromotion({
|
||||
$core.bool? quoteCtaEnabled,
|
||||
}) {
|
||||
final $result = create();
|
||||
if (quoteCtaEnabled != null) {
|
||||
$result.quoteCtaEnabled = quoteCtaEnabled;
|
||||
}
|
||||
return $result;
|
||||
}
|
||||
MallPromotion._() : super();
|
||||
factory MallPromotion.fromBuffer($core.List<$core.int> i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromBuffer(i, r);
|
||||
factory MallPromotion.fromJson($core.String i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromJson(i, r);
|
||||
|
||||
static final $pb.BuilderInfo _i = $pb.BuilderInfo(_omitMessageNames ? '' : 'MallPromotion', package: const $pb.PackageName(_omitMessageNames ? '' : 'jinzhi.v1'), createEmptyInstance: create)
|
||||
..aOB(1, _omitFieldNames ? '' : 'quoteCtaEnabled')
|
||||
..hasRequiredFields = false
|
||||
;
|
||||
|
||||
@$core.Deprecated(
|
||||
'Using this can add significant overhead to your binary. '
|
||||
'Use [GeneratedMessageGenericExtensions.deepCopy] instead. '
|
||||
'Will be removed in next major version')
|
||||
MallPromotion clone() => MallPromotion()..mergeFromMessage(this);
|
||||
@$core.Deprecated(
|
||||
'Using this can add significant overhead to your binary. '
|
||||
'Use [GeneratedMessageGenericExtensions.rebuild] instead. '
|
||||
'Will be removed in next major version')
|
||||
MallPromotion copyWith(void Function(MallPromotion) updates) => super.copyWith((message) => updates(message as MallPromotion)) as MallPromotion;
|
||||
|
||||
$pb.BuilderInfo get info_ => _i;
|
||||
|
||||
@$core.pragma('dart2js:noInline')
|
||||
static MallPromotion create() => MallPromotion._();
|
||||
MallPromotion createEmptyInstance() => create();
|
||||
static $pb.PbList<MallPromotion> createRepeated() => $pb.PbList<MallPromotion>();
|
||||
@$core.pragma('dart2js:noInline')
|
||||
static MallPromotion getDefault() => _defaultInstance ??= $pb.GeneratedMessage.$_defaultFor<MallPromotion>(create);
|
||||
static MallPromotion? _defaultInstance;
|
||||
|
||||
@$pb.TagNumber(1)
|
||||
$core.bool get quoteCtaEnabled => $_getBF(0);
|
||||
@$pb.TagNumber(1)
|
||||
set quoteCtaEnabled($core.bool v) { $_setBool(0, v); }
|
||||
@$pb.TagNumber(1)
|
||||
$core.bool hasQuoteCtaEnabled() => $_has(0);
|
||||
@$pb.TagNumber(1)
|
||||
void clearQuoteCtaEnabled() => clearField(1);
|
||||
}
|
||||
|
||||
class ReportAdEventRequest extends $pb.GeneratedMessage {
|
||||
factory ReportAdEventRequest({
|
||||
$core.String? eventName,
|
||||
$core.String? placement,
|
||||
$core.String? adId,
|
||||
$core.String? trackingId,
|
||||
$core.String? clickId,
|
||||
$fixnum.Int64? clientTsMs,
|
||||
$core.Map<$core.String, $core.String>? attrs,
|
||||
$core.String? articleId,
|
||||
}) {
|
||||
final $result = create();
|
||||
if (eventName != null) {
|
||||
$result.eventName = eventName;
|
||||
}
|
||||
if (placement != null) {
|
||||
$result.placement = placement;
|
||||
}
|
||||
if (adId != null) {
|
||||
$result.adId = adId;
|
||||
}
|
||||
if (trackingId != null) {
|
||||
$result.trackingId = trackingId;
|
||||
}
|
||||
if (clickId != null) {
|
||||
$result.clickId = clickId;
|
||||
}
|
||||
if (clientTsMs != null) {
|
||||
$result.clientTsMs = clientTsMs;
|
||||
}
|
||||
if (attrs != null) {
|
||||
$result.attrs.addAll(attrs);
|
||||
}
|
||||
if (articleId != null) {
|
||||
$result.articleId = articleId;
|
||||
}
|
||||
return $result;
|
||||
}
|
||||
ReportAdEventRequest._() : super();
|
||||
factory ReportAdEventRequest.fromBuffer($core.List<$core.int> i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromBuffer(i, r);
|
||||
factory ReportAdEventRequest.fromJson($core.String i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromJson(i, r);
|
||||
|
||||
static final $pb.BuilderInfo _i = $pb.BuilderInfo(_omitMessageNames ? '' : 'ReportAdEventRequest', package: const $pb.PackageName(_omitMessageNames ? '' : 'jinzhi.v1'), createEmptyInstance: create)
|
||||
..aOS(1, _omitFieldNames ? '' : 'eventName')
|
||||
..aOS(2, _omitFieldNames ? '' : 'placement')
|
||||
..aOS(3, _omitFieldNames ? '' : 'adId')
|
||||
..aOS(4, _omitFieldNames ? '' : 'trackingId')
|
||||
..aOS(5, _omitFieldNames ? '' : 'clickId')
|
||||
..aInt64(6, _omitFieldNames ? '' : 'clientTsMs')
|
||||
..m<$core.String, $core.String>(7, _omitFieldNames ? '' : 'attrs', entryClassName: 'ReportAdEventRequest.AttrsEntry', keyFieldType: $pb.PbFieldType.OS, valueFieldType: $pb.PbFieldType.OS, packageName: const $pb.PackageName('jinzhi.v1'))
|
||||
..aOS(8, _omitFieldNames ? '' : 'articleId')
|
||||
..hasRequiredFields = false
|
||||
;
|
||||
|
||||
@$core.Deprecated(
|
||||
'Using this can add significant overhead to your binary. '
|
||||
'Use [GeneratedMessageGenericExtensions.deepCopy] instead. '
|
||||
'Will be removed in next major version')
|
||||
ReportAdEventRequest clone() => ReportAdEventRequest()..mergeFromMessage(this);
|
||||
@$core.Deprecated(
|
||||
'Using this can add significant overhead to your binary. '
|
||||
'Use [GeneratedMessageGenericExtensions.rebuild] instead. '
|
||||
'Will be removed in next major version')
|
||||
ReportAdEventRequest copyWith(void Function(ReportAdEventRequest) updates) => super.copyWith((message) => updates(message as ReportAdEventRequest)) as ReportAdEventRequest;
|
||||
|
||||
$pb.BuilderInfo get info_ => _i;
|
||||
|
||||
@$core.pragma('dart2js:noInline')
|
||||
static ReportAdEventRequest create() => ReportAdEventRequest._();
|
||||
ReportAdEventRequest createEmptyInstance() => create();
|
||||
static $pb.PbList<ReportAdEventRequest> createRepeated() => $pb.PbList<ReportAdEventRequest>();
|
||||
@$core.pragma('dart2js:noInline')
|
||||
static ReportAdEventRequest getDefault() => _defaultInstance ??= $pb.GeneratedMessage.$_defaultFor<ReportAdEventRequest>(create);
|
||||
static ReportAdEventRequest? _defaultInstance;
|
||||
|
||||
@$pb.TagNumber(1)
|
||||
$core.String get eventName => $_getSZ(0);
|
||||
@$pb.TagNumber(1)
|
||||
set eventName($core.String v) { $_setString(0, v); }
|
||||
@$pb.TagNumber(1)
|
||||
$core.bool hasEventName() => $_has(0);
|
||||
@$pb.TagNumber(1)
|
||||
void clearEventName() => clearField(1);
|
||||
|
||||
@$pb.TagNumber(2)
|
||||
$core.String get placement => $_getSZ(1);
|
||||
@$pb.TagNumber(2)
|
||||
set placement($core.String v) { $_setString(1, v); }
|
||||
@$pb.TagNumber(2)
|
||||
$core.bool hasPlacement() => $_has(1);
|
||||
@$pb.TagNumber(2)
|
||||
void clearPlacement() => clearField(2);
|
||||
|
||||
@$pb.TagNumber(3)
|
||||
$core.String get adId => $_getSZ(2);
|
||||
@$pb.TagNumber(3)
|
||||
set adId($core.String v) { $_setString(2, v); }
|
||||
@$pb.TagNumber(3)
|
||||
$core.bool hasAdId() => $_has(2);
|
||||
@$pb.TagNumber(3)
|
||||
void clearAdId() => clearField(3);
|
||||
|
||||
@$pb.TagNumber(4)
|
||||
$core.String get trackingId => $_getSZ(3);
|
||||
@$pb.TagNumber(4)
|
||||
set trackingId($core.String v) { $_setString(3, v); }
|
||||
@$pb.TagNumber(4)
|
||||
$core.bool hasTrackingId() => $_has(3);
|
||||
@$pb.TagNumber(4)
|
||||
void clearTrackingId() => clearField(4);
|
||||
|
||||
@$pb.TagNumber(5)
|
||||
$core.String get clickId => $_getSZ(4);
|
||||
@$pb.TagNumber(5)
|
||||
set clickId($core.String v) { $_setString(4, v); }
|
||||
@$pb.TagNumber(5)
|
||||
$core.bool hasClickId() => $_has(4);
|
||||
@$pb.TagNumber(5)
|
||||
void clearClickId() => clearField(5);
|
||||
|
||||
@$pb.TagNumber(6)
|
||||
$fixnum.Int64 get clientTsMs => $_getI64(5);
|
||||
@$pb.TagNumber(6)
|
||||
set clientTsMs($fixnum.Int64 v) { $_setInt64(5, v); }
|
||||
@$pb.TagNumber(6)
|
||||
$core.bool hasClientTsMs() => $_has(5);
|
||||
@$pb.TagNumber(6)
|
||||
void clearClientTsMs() => clearField(6);
|
||||
|
||||
@$pb.TagNumber(7)
|
||||
$core.Map<$core.String, $core.String> get attrs => $_getMap(6);
|
||||
|
||||
@$pb.TagNumber(8)
|
||||
$core.String get articleId => $_getSZ(7);
|
||||
@$pb.TagNumber(8)
|
||||
set articleId($core.String v) { $_setString(7, v); }
|
||||
@$pb.TagNumber(8)
|
||||
$core.bool hasArticleId() => $_has(7);
|
||||
@$pb.TagNumber(8)
|
||||
void clearArticleId() => clearField(8);
|
||||
}
|
||||
|
||||
class ReportAdEventReply extends $pb.GeneratedMessage {
|
||||
factory ReportAdEventReply({
|
||||
$core.String? eventId,
|
||||
$core.bool? accepted,
|
||||
}) {
|
||||
final $result = create();
|
||||
if (eventId != null) {
|
||||
$result.eventId = eventId;
|
||||
}
|
||||
if (accepted != null) {
|
||||
$result.accepted = accepted;
|
||||
}
|
||||
return $result;
|
||||
}
|
||||
ReportAdEventReply._() : super();
|
||||
factory ReportAdEventReply.fromBuffer($core.List<$core.int> i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromBuffer(i, r);
|
||||
factory ReportAdEventReply.fromJson($core.String i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromJson(i, r);
|
||||
|
||||
static final $pb.BuilderInfo _i = $pb.BuilderInfo(_omitMessageNames ? '' : 'ReportAdEventReply', package: const $pb.PackageName(_omitMessageNames ? '' : 'jinzhi.v1'), createEmptyInstance: create)
|
||||
..aOS(1, _omitFieldNames ? '' : 'eventId')
|
||||
..aOB(2, _omitFieldNames ? '' : 'accepted')
|
||||
..hasRequiredFields = false
|
||||
;
|
||||
|
||||
@$core.Deprecated(
|
||||
'Using this can add significant overhead to your binary. '
|
||||
'Use [GeneratedMessageGenericExtensions.deepCopy] instead. '
|
||||
'Will be removed in next major version')
|
||||
ReportAdEventReply clone() => ReportAdEventReply()..mergeFromMessage(this);
|
||||
@$core.Deprecated(
|
||||
'Using this can add significant overhead to your binary. '
|
||||
'Use [GeneratedMessageGenericExtensions.rebuild] instead. '
|
||||
'Will be removed in next major version')
|
||||
ReportAdEventReply copyWith(void Function(ReportAdEventReply) updates) => super.copyWith((message) => updates(message as ReportAdEventReply)) as ReportAdEventReply;
|
||||
|
||||
$pb.BuilderInfo get info_ => _i;
|
||||
|
||||
@$core.pragma('dart2js:noInline')
|
||||
static ReportAdEventReply create() => ReportAdEventReply._();
|
||||
ReportAdEventReply createEmptyInstance() => create();
|
||||
static $pb.PbList<ReportAdEventReply> createRepeated() => $pb.PbList<ReportAdEventReply>();
|
||||
@$core.pragma('dart2js:noInline')
|
||||
static ReportAdEventReply getDefault() => _defaultInstance ??= $pb.GeneratedMessage.$_defaultFor<ReportAdEventReply>(create);
|
||||
static ReportAdEventReply? _defaultInstance;
|
||||
|
||||
@$pb.TagNumber(1)
|
||||
$core.String get eventId => $_getSZ(0);
|
||||
@$pb.TagNumber(1)
|
||||
set eventId($core.String v) { $_setString(0, v); }
|
||||
@$pb.TagNumber(1)
|
||||
$core.bool hasEventId() => $_has(0);
|
||||
@$pb.TagNumber(1)
|
||||
void clearEventId() => clearField(1);
|
||||
|
||||
@$pb.TagNumber(2)
|
||||
$core.bool get accepted => $_getBF(1);
|
||||
@$pb.TagNumber(2)
|
||||
set accepted($core.bool v) { $_setBool(1, v); }
|
||||
@$pb.TagNumber(2)
|
||||
$core.bool hasAccepted() => $_has(1);
|
||||
@$pb.TagNumber(2)
|
||||
void clearAccepted() => clearField(2);
|
||||
}
|
||||
|
||||
class AdsServiceApi {
|
||||
$pb.RpcClient _client;
|
||||
AdsServiceApi(this._client);
|
||||
|
||||
$async.Future<GetAdReply> getAd($pb.ClientContext? ctx, GetAdRequest request) =>
|
||||
_client.invoke<GetAdReply>(ctx, 'AdsService', 'GetAd', request, GetAdReply())
|
||||
;
|
||||
$async.Future<ListAdsReply> listAds($pb.ClientContext? ctx, ListAdsRequest request) =>
|
||||
_client.invoke<ListAdsReply>(ctx, 'AdsService', 'ListAds', request, ListAdsReply())
|
||||
;
|
||||
$async.Future<ReportAdEventReply> reportAdEvent($pb.ClientContext? ctx, ReportAdEventRequest request) =>
|
||||
_client.invoke<ReportAdEventReply>(ctx, 'AdsService', 'ReportAdEvent', request, ReportAdEventReply())
|
||||
;
|
||||
}
|
||||
|
||||
|
||||
const _omitFieldNames = $core.bool.fromEnvironment('protobuf.omit_field_names');
|
||||
const _omitMessageNames = $core.bool.fromEnvironment('protobuf.omit_message_names');
|
||||
@@ -0,0 +1,55 @@
|
||||
//
|
||||
// Generated code. Do not modify.
|
||||
// source: jinzhi/v1/ads.proto
|
||||
//
|
||||
// @dart = 2.12
|
||||
|
||||
// ignore_for_file: annotate_overrides, camel_case_types, comment_references
|
||||
// ignore_for_file: constant_identifier_names, library_prefixes
|
||||
// ignore_for_file: non_constant_identifier_names, prefer_final_fields
|
||||
// ignore_for_file: unnecessary_import, unnecessary_this, unused_import
|
||||
|
||||
import 'dart:core' as $core;
|
||||
|
||||
import 'package:protobuf/protobuf.dart' as $pb;
|
||||
|
||||
class AdOpenMode extends $pb.ProtobufEnum {
|
||||
static const AdOpenMode AD_OPEN_MODE_UNSPECIFIED = AdOpenMode._(0, _omitEnumNames ? '' : 'AD_OPEN_MODE_UNSPECIFIED');
|
||||
static const AdOpenMode AD_OPEN_MODE_EXTERNAL = AdOpenMode._(1, _omitEnumNames ? '' : 'AD_OPEN_MODE_EXTERNAL');
|
||||
static const AdOpenMode AD_OPEN_MODE_IN_APP = AdOpenMode._(2, _omitEnumNames ? '' : 'AD_OPEN_MODE_IN_APP');
|
||||
static const AdOpenMode AD_OPEN_MODE_NATIVE_APP = AdOpenMode._(3, _omitEnumNames ? '' : 'AD_OPEN_MODE_NATIVE_APP');
|
||||
|
||||
static const $core.List<AdOpenMode> values = <AdOpenMode> [
|
||||
AD_OPEN_MODE_UNSPECIFIED,
|
||||
AD_OPEN_MODE_EXTERNAL,
|
||||
AD_OPEN_MODE_IN_APP,
|
||||
AD_OPEN_MODE_NATIVE_APP,
|
||||
];
|
||||
|
||||
static final $core.Map<$core.int, AdOpenMode> _byValue = $pb.ProtobufEnum.initByValue(values);
|
||||
static AdOpenMode? valueOf($core.int value) => _byValue[value];
|
||||
|
||||
const AdOpenMode._($core.int v, $core.String n) : super(v, n);
|
||||
}
|
||||
|
||||
class MediaType extends $pb.ProtobufEnum {
|
||||
static const MediaType MEDIA_TYPE_UNSPECIFIED = MediaType._(0, _omitEnumNames ? '' : 'MEDIA_TYPE_UNSPECIFIED');
|
||||
static const MediaType MEDIA_TYPE_IMAGE = MediaType._(1, _omitEnumNames ? '' : 'MEDIA_TYPE_IMAGE');
|
||||
static const MediaType MEDIA_TYPE_GIF = MediaType._(2, _omitEnumNames ? '' : 'MEDIA_TYPE_GIF');
|
||||
static const MediaType MEDIA_TYPE_VIDEO = MediaType._(3, _omitEnumNames ? '' : 'MEDIA_TYPE_VIDEO');
|
||||
|
||||
static const $core.List<MediaType> values = <MediaType> [
|
||||
MEDIA_TYPE_UNSPECIFIED,
|
||||
MEDIA_TYPE_IMAGE,
|
||||
MEDIA_TYPE_GIF,
|
||||
MEDIA_TYPE_VIDEO,
|
||||
];
|
||||
|
||||
static final $core.Map<$core.int, MediaType> _byValue = $pb.ProtobufEnum.initByValue(values);
|
||||
static MediaType? valueOf($core.int value) => _byValue[value];
|
||||
|
||||
const MediaType._($core.int v, $core.String n) : super(v, n);
|
||||
}
|
||||
|
||||
|
||||
const _omitEnumNames = $core.bool.fromEnvironment('protobuf.omit_enum_names');
|
||||
@@ -0,0 +1,336 @@
|
||||
//
|
||||
// Generated code. Do not modify.
|
||||
// source: jinzhi/v1/push.proto
|
||||
//
|
||||
// @dart = 2.12
|
||||
|
||||
// ignore_for_file: annotate_overrides, camel_case_types, comment_references
|
||||
// ignore_for_file: constant_identifier_names, library_prefixes
|
||||
// ignore_for_file: non_constant_identifier_names, prefer_final_fields
|
||||
// ignore_for_file: unnecessary_import, unnecessary_this, unused_import
|
||||
|
||||
import 'dart:core' as $core;
|
||||
|
||||
import 'package:protobuf/protobuf.dart' as $pb;
|
||||
|
||||
import 'push.pbenum.dart';
|
||||
|
||||
export 'push.pbenum.dart';
|
||||
|
||||
class RegisterPushDeviceRequest extends $pb.GeneratedMessage {
|
||||
factory RegisterPushDeviceRequest({
|
||||
$core.String? umengDeviceToken,
|
||||
Platform? platform,
|
||||
$core.String? appVersion,
|
||||
$core.bool? permissionGranted,
|
||||
}) {
|
||||
final $result = create();
|
||||
if (umengDeviceToken != null) {
|
||||
$result.umengDeviceToken = umengDeviceToken;
|
||||
}
|
||||
if (platform != null) {
|
||||
$result.platform = platform;
|
||||
}
|
||||
if (appVersion != null) {
|
||||
$result.appVersion = appVersion;
|
||||
}
|
||||
if (permissionGranted != null) {
|
||||
$result.permissionGranted = permissionGranted;
|
||||
}
|
||||
return $result;
|
||||
}
|
||||
RegisterPushDeviceRequest._() : super();
|
||||
factory RegisterPushDeviceRequest.fromBuffer($core.List<$core.int> i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromBuffer(i, r);
|
||||
factory RegisterPushDeviceRequest.fromJson($core.String i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromJson(i, r);
|
||||
|
||||
static final $pb.BuilderInfo _i = $pb.BuilderInfo(_omitMessageNames ? '' : 'RegisterPushDeviceRequest', package: const $pb.PackageName(_omitMessageNames ? '' : 'jinzhi.v1'), createEmptyInstance: create)
|
||||
..aOS(1, _omitFieldNames ? '' : 'umengDeviceToken')
|
||||
..e<Platform>(2, _omitFieldNames ? '' : 'platform', $pb.PbFieldType.OE, defaultOrMaker: Platform.PLATFORM_UNSPECIFIED, valueOf: Platform.valueOf, enumValues: Platform.values)
|
||||
..aOS(3, _omitFieldNames ? '' : 'appVersion')
|
||||
..aOB(4, _omitFieldNames ? '' : 'permissionGranted')
|
||||
..hasRequiredFields = false
|
||||
;
|
||||
|
||||
@$core.Deprecated(
|
||||
'Using this can add significant overhead to your binary. '
|
||||
'Use [GeneratedMessageGenericExtensions.deepCopy] instead. '
|
||||
'Will be removed in next major version')
|
||||
RegisterPushDeviceRequest clone() => RegisterPushDeviceRequest()..mergeFromMessage(this);
|
||||
@$core.Deprecated(
|
||||
'Using this can add significant overhead to your binary. '
|
||||
'Use [GeneratedMessageGenericExtensions.rebuild] instead. '
|
||||
'Will be removed in next major version')
|
||||
RegisterPushDeviceRequest copyWith(void Function(RegisterPushDeviceRequest) updates) => super.copyWith((message) => updates(message as RegisterPushDeviceRequest)) as RegisterPushDeviceRequest;
|
||||
|
||||
$pb.BuilderInfo get info_ => _i;
|
||||
|
||||
@$core.pragma('dart2js:noInline')
|
||||
static RegisterPushDeviceRequest create() => RegisterPushDeviceRequest._();
|
||||
RegisterPushDeviceRequest createEmptyInstance() => create();
|
||||
static $pb.PbList<RegisterPushDeviceRequest> createRepeated() => $pb.PbList<RegisterPushDeviceRequest>();
|
||||
@$core.pragma('dart2js:noInline')
|
||||
static RegisterPushDeviceRequest getDefault() => _defaultInstance ??= $pb.GeneratedMessage.$_defaultFor<RegisterPushDeviceRequest>(create);
|
||||
static RegisterPushDeviceRequest? _defaultInstance;
|
||||
|
||||
@$pb.TagNumber(1)
|
||||
$core.String get umengDeviceToken => $_getSZ(0);
|
||||
@$pb.TagNumber(1)
|
||||
set umengDeviceToken($core.String v) { $_setString(0, v); }
|
||||
@$pb.TagNumber(1)
|
||||
$core.bool hasUmengDeviceToken() => $_has(0);
|
||||
@$pb.TagNumber(1)
|
||||
void clearUmengDeviceToken() => clearField(1);
|
||||
|
||||
@$pb.TagNumber(2)
|
||||
Platform get platform => $_getN(1);
|
||||
@$pb.TagNumber(2)
|
||||
set platform(Platform v) { setField(2, v); }
|
||||
@$pb.TagNumber(2)
|
||||
$core.bool hasPlatform() => $_has(1);
|
||||
@$pb.TagNumber(2)
|
||||
void clearPlatform() => clearField(2);
|
||||
|
||||
@$pb.TagNumber(3)
|
||||
$core.String get appVersion => $_getSZ(2);
|
||||
@$pb.TagNumber(3)
|
||||
set appVersion($core.String v) { $_setString(2, v); }
|
||||
@$pb.TagNumber(3)
|
||||
$core.bool hasAppVersion() => $_has(2);
|
||||
@$pb.TagNumber(3)
|
||||
void clearAppVersion() => clearField(3);
|
||||
|
||||
@$pb.TagNumber(4)
|
||||
$core.bool get permissionGranted => $_getBF(3);
|
||||
@$pb.TagNumber(4)
|
||||
set permissionGranted($core.bool v) { $_setBool(3, v); }
|
||||
@$pb.TagNumber(4)
|
||||
$core.bool hasPermissionGranted() => $_has(3);
|
||||
@$pb.TagNumber(4)
|
||||
void clearPermissionGranted() => clearField(4);
|
||||
}
|
||||
|
||||
class RegisterPushDeviceReply extends $pb.GeneratedMessage {
|
||||
factory RegisterPushDeviceReply({
|
||||
$core.String? deviceRecordId,
|
||||
$core.bool? firstRegistration,
|
||||
}) {
|
||||
final $result = create();
|
||||
if (deviceRecordId != null) {
|
||||
$result.deviceRecordId = deviceRecordId;
|
||||
}
|
||||
if (firstRegistration != null) {
|
||||
$result.firstRegistration = firstRegistration;
|
||||
}
|
||||
return $result;
|
||||
}
|
||||
RegisterPushDeviceReply._() : super();
|
||||
factory RegisterPushDeviceReply.fromBuffer($core.List<$core.int> i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromBuffer(i, r);
|
||||
factory RegisterPushDeviceReply.fromJson($core.String i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromJson(i, r);
|
||||
|
||||
static final $pb.BuilderInfo _i = $pb.BuilderInfo(_omitMessageNames ? '' : 'RegisterPushDeviceReply', package: const $pb.PackageName(_omitMessageNames ? '' : 'jinzhi.v1'), createEmptyInstance: create)
|
||||
..aOS(1, _omitFieldNames ? '' : 'deviceRecordId')
|
||||
..aOB(2, _omitFieldNames ? '' : 'firstRegistration')
|
||||
..hasRequiredFields = false
|
||||
;
|
||||
|
||||
@$core.Deprecated(
|
||||
'Using this can add significant overhead to your binary. '
|
||||
'Use [GeneratedMessageGenericExtensions.deepCopy] instead. '
|
||||
'Will be removed in next major version')
|
||||
RegisterPushDeviceReply clone() => RegisterPushDeviceReply()..mergeFromMessage(this);
|
||||
@$core.Deprecated(
|
||||
'Using this can add significant overhead to your binary. '
|
||||
'Use [GeneratedMessageGenericExtensions.rebuild] instead. '
|
||||
'Will be removed in next major version')
|
||||
RegisterPushDeviceReply copyWith(void Function(RegisterPushDeviceReply) updates) => super.copyWith((message) => updates(message as RegisterPushDeviceReply)) as RegisterPushDeviceReply;
|
||||
|
||||
$pb.BuilderInfo get info_ => _i;
|
||||
|
||||
@$core.pragma('dart2js:noInline')
|
||||
static RegisterPushDeviceReply create() => RegisterPushDeviceReply._();
|
||||
RegisterPushDeviceReply createEmptyInstance() => create();
|
||||
static $pb.PbList<RegisterPushDeviceReply> createRepeated() => $pb.PbList<RegisterPushDeviceReply>();
|
||||
@$core.pragma('dart2js:noInline')
|
||||
static RegisterPushDeviceReply getDefault() => _defaultInstance ??= $pb.GeneratedMessage.$_defaultFor<RegisterPushDeviceReply>(create);
|
||||
static RegisterPushDeviceReply? _defaultInstance;
|
||||
|
||||
@$pb.TagNumber(1)
|
||||
$core.String get deviceRecordId => $_getSZ(0);
|
||||
@$pb.TagNumber(1)
|
||||
set deviceRecordId($core.String v) { $_setString(0, v); }
|
||||
@$pb.TagNumber(1)
|
||||
$core.bool hasDeviceRecordId() => $_has(0);
|
||||
@$pb.TagNumber(1)
|
||||
void clearDeviceRecordId() => clearField(1);
|
||||
|
||||
@$pb.TagNumber(2)
|
||||
$core.bool get firstRegistration => $_getBF(1);
|
||||
@$pb.TagNumber(2)
|
||||
set firstRegistration($core.bool v) { $_setBool(1, v); }
|
||||
@$pb.TagNumber(2)
|
||||
$core.bool hasFirstRegistration() => $_has(1);
|
||||
@$pb.TagNumber(2)
|
||||
void clearFirstRegistration() => clearField(2);
|
||||
}
|
||||
|
||||
/// 成对的 *_set 字段保留 proto3 bool 的局部更新语义。
|
||||
class UpdatePushPreferenceRequest extends $pb.GeneratedMessage {
|
||||
factory UpdatePushPreferenceRequest({
|
||||
$core.bool? watchlistUpdatesEnabled,
|
||||
$core.bool? watchlistUpdatesEnabledSet,
|
||||
$core.bool? recallPushEnabled,
|
||||
$core.bool? recallPushEnabledSet,
|
||||
}) {
|
||||
final $result = create();
|
||||
if (watchlistUpdatesEnabled != null) {
|
||||
$result.watchlistUpdatesEnabled = watchlistUpdatesEnabled;
|
||||
}
|
||||
if (watchlistUpdatesEnabledSet != null) {
|
||||
$result.watchlistUpdatesEnabledSet = watchlistUpdatesEnabledSet;
|
||||
}
|
||||
if (recallPushEnabled != null) {
|
||||
$result.recallPushEnabled = recallPushEnabled;
|
||||
}
|
||||
if (recallPushEnabledSet != null) {
|
||||
$result.recallPushEnabledSet = recallPushEnabledSet;
|
||||
}
|
||||
return $result;
|
||||
}
|
||||
UpdatePushPreferenceRequest._() : super();
|
||||
factory UpdatePushPreferenceRequest.fromBuffer($core.List<$core.int> i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromBuffer(i, r);
|
||||
factory UpdatePushPreferenceRequest.fromJson($core.String i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromJson(i, r);
|
||||
|
||||
static final $pb.BuilderInfo _i = $pb.BuilderInfo(_omitMessageNames ? '' : 'UpdatePushPreferenceRequest', package: const $pb.PackageName(_omitMessageNames ? '' : 'jinzhi.v1'), createEmptyInstance: create)
|
||||
..aOB(1, _omitFieldNames ? '' : 'watchlistUpdatesEnabled')
|
||||
..aOB(2, _omitFieldNames ? '' : 'watchlistUpdatesEnabledSet')
|
||||
..aOB(3, _omitFieldNames ? '' : 'recallPushEnabled')
|
||||
..aOB(4, _omitFieldNames ? '' : 'recallPushEnabledSet')
|
||||
..hasRequiredFields = false
|
||||
;
|
||||
|
||||
@$core.Deprecated(
|
||||
'Using this can add significant overhead to your binary. '
|
||||
'Use [GeneratedMessageGenericExtensions.deepCopy] instead. '
|
||||
'Will be removed in next major version')
|
||||
UpdatePushPreferenceRequest clone() => UpdatePushPreferenceRequest()..mergeFromMessage(this);
|
||||
@$core.Deprecated(
|
||||
'Using this can add significant overhead to your binary. '
|
||||
'Use [GeneratedMessageGenericExtensions.rebuild] instead. '
|
||||
'Will be removed in next major version')
|
||||
UpdatePushPreferenceRequest copyWith(void Function(UpdatePushPreferenceRequest) updates) => super.copyWith((message) => updates(message as UpdatePushPreferenceRequest)) as UpdatePushPreferenceRequest;
|
||||
|
||||
$pb.BuilderInfo get info_ => _i;
|
||||
|
||||
@$core.pragma('dart2js:noInline')
|
||||
static UpdatePushPreferenceRequest create() => UpdatePushPreferenceRequest._();
|
||||
UpdatePushPreferenceRequest createEmptyInstance() => create();
|
||||
static $pb.PbList<UpdatePushPreferenceRequest> createRepeated() => $pb.PbList<UpdatePushPreferenceRequest>();
|
||||
@$core.pragma('dart2js:noInline')
|
||||
static UpdatePushPreferenceRequest getDefault() => _defaultInstance ??= $pb.GeneratedMessage.$_defaultFor<UpdatePushPreferenceRequest>(create);
|
||||
static UpdatePushPreferenceRequest? _defaultInstance;
|
||||
|
||||
/// 沿用研值/研听契约名;在金值中表示“资产与关注内容更新”。
|
||||
@$pb.TagNumber(1)
|
||||
$core.bool get watchlistUpdatesEnabled => $_getBF(0);
|
||||
@$pb.TagNumber(1)
|
||||
set watchlistUpdatesEnabled($core.bool v) { $_setBool(0, v); }
|
||||
@$pb.TagNumber(1)
|
||||
$core.bool hasWatchlistUpdatesEnabled() => $_has(0);
|
||||
@$pb.TagNumber(1)
|
||||
void clearWatchlistUpdatesEnabled() => clearField(1);
|
||||
|
||||
@$pb.TagNumber(2)
|
||||
$core.bool get watchlistUpdatesEnabledSet => $_getBF(1);
|
||||
@$pb.TagNumber(2)
|
||||
set watchlistUpdatesEnabledSet($core.bool v) { $_setBool(1, v); }
|
||||
@$pb.TagNumber(2)
|
||||
$core.bool hasWatchlistUpdatesEnabledSet() => $_has(1);
|
||||
@$pb.TagNumber(2)
|
||||
void clearWatchlistUpdatesEnabledSet() => clearField(2);
|
||||
|
||||
@$pb.TagNumber(3)
|
||||
$core.bool get recallPushEnabled => $_getBF(2);
|
||||
@$pb.TagNumber(3)
|
||||
set recallPushEnabled($core.bool v) { $_setBool(2, v); }
|
||||
@$pb.TagNumber(3)
|
||||
$core.bool hasRecallPushEnabled() => $_has(2);
|
||||
@$pb.TagNumber(3)
|
||||
void clearRecallPushEnabled() => clearField(3);
|
||||
|
||||
@$pb.TagNumber(4)
|
||||
$core.bool get recallPushEnabledSet => $_getBF(3);
|
||||
@$pb.TagNumber(4)
|
||||
set recallPushEnabledSet($core.bool v) { $_setBool(3, v); }
|
||||
@$pb.TagNumber(4)
|
||||
$core.bool hasRecallPushEnabledSet() => $_has(3);
|
||||
@$pb.TagNumber(4)
|
||||
void clearRecallPushEnabledSet() => clearField(4);
|
||||
}
|
||||
|
||||
class PushPreferenceReply extends $pb.GeneratedMessage {
|
||||
factory PushPreferenceReply({
|
||||
$core.bool? watchlistUpdatesEnabled,
|
||||
$core.bool? recallPushEnabled,
|
||||
}) {
|
||||
final $result = create();
|
||||
if (watchlistUpdatesEnabled != null) {
|
||||
$result.watchlistUpdatesEnabled = watchlistUpdatesEnabled;
|
||||
}
|
||||
if (recallPushEnabled != null) {
|
||||
$result.recallPushEnabled = recallPushEnabled;
|
||||
}
|
||||
return $result;
|
||||
}
|
||||
PushPreferenceReply._() : super();
|
||||
factory PushPreferenceReply.fromBuffer($core.List<$core.int> i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromBuffer(i, r);
|
||||
factory PushPreferenceReply.fromJson($core.String i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromJson(i, r);
|
||||
|
||||
static final $pb.BuilderInfo _i = $pb.BuilderInfo(_omitMessageNames ? '' : 'PushPreferenceReply', package: const $pb.PackageName(_omitMessageNames ? '' : 'jinzhi.v1'), createEmptyInstance: create)
|
||||
..aOB(1, _omitFieldNames ? '' : 'watchlistUpdatesEnabled')
|
||||
..aOB(2, _omitFieldNames ? '' : 'recallPushEnabled')
|
||||
..hasRequiredFields = false
|
||||
;
|
||||
|
||||
@$core.Deprecated(
|
||||
'Using this can add significant overhead to your binary. '
|
||||
'Use [GeneratedMessageGenericExtensions.deepCopy] instead. '
|
||||
'Will be removed in next major version')
|
||||
PushPreferenceReply clone() => PushPreferenceReply()..mergeFromMessage(this);
|
||||
@$core.Deprecated(
|
||||
'Using this can add significant overhead to your binary. '
|
||||
'Use [GeneratedMessageGenericExtensions.rebuild] instead. '
|
||||
'Will be removed in next major version')
|
||||
PushPreferenceReply copyWith(void Function(PushPreferenceReply) updates) => super.copyWith((message) => updates(message as PushPreferenceReply)) as PushPreferenceReply;
|
||||
|
||||
$pb.BuilderInfo get info_ => _i;
|
||||
|
||||
@$core.pragma('dart2js:noInline')
|
||||
static PushPreferenceReply create() => PushPreferenceReply._();
|
||||
PushPreferenceReply createEmptyInstance() => create();
|
||||
static $pb.PbList<PushPreferenceReply> createRepeated() => $pb.PbList<PushPreferenceReply>();
|
||||
@$core.pragma('dart2js:noInline')
|
||||
static PushPreferenceReply getDefault() => _defaultInstance ??= $pb.GeneratedMessage.$_defaultFor<PushPreferenceReply>(create);
|
||||
static PushPreferenceReply? _defaultInstance;
|
||||
|
||||
@$pb.TagNumber(1)
|
||||
$core.bool get watchlistUpdatesEnabled => $_getBF(0);
|
||||
@$pb.TagNumber(1)
|
||||
set watchlistUpdatesEnabled($core.bool v) { $_setBool(0, v); }
|
||||
@$pb.TagNumber(1)
|
||||
$core.bool hasWatchlistUpdatesEnabled() => $_has(0);
|
||||
@$pb.TagNumber(1)
|
||||
void clearWatchlistUpdatesEnabled() => clearField(1);
|
||||
|
||||
@$pb.TagNumber(2)
|
||||
$core.bool get recallPushEnabled => $_getBF(1);
|
||||
@$pb.TagNumber(2)
|
||||
set recallPushEnabled($core.bool v) { $_setBool(1, v); }
|
||||
@$pb.TagNumber(2)
|
||||
$core.bool hasRecallPushEnabled() => $_has(1);
|
||||
@$pb.TagNumber(2)
|
||||
void clearRecallPushEnabled() => clearField(2);
|
||||
}
|
||||
|
||||
|
||||
const _omitFieldNames = $core.bool.fromEnvironment('protobuf.omit_field_names');
|
||||
const _omitMessageNames = $core.bool.fromEnvironment('protobuf.omit_message_names');
|
||||
@@ -0,0 +1,35 @@
|
||||
//
|
||||
// Generated code. Do not modify.
|
||||
// source: jinzhi/v1/push.proto
|
||||
//
|
||||
// @dart = 2.12
|
||||
|
||||
// ignore_for_file: annotate_overrides, camel_case_types, comment_references
|
||||
// ignore_for_file: constant_identifier_names, library_prefixes
|
||||
// ignore_for_file: non_constant_identifier_names, prefer_final_fields
|
||||
// ignore_for_file: unnecessary_import, unnecessary_this, unused_import
|
||||
|
||||
import 'dart:core' as $core;
|
||||
|
||||
import 'package:protobuf/protobuf.dart' as $pb;
|
||||
|
||||
/// Platform 标识 U-Push token 所属原生平台。
|
||||
class Platform extends $pb.ProtobufEnum {
|
||||
static const Platform PLATFORM_UNSPECIFIED = Platform._(0, _omitEnumNames ? '' : 'PLATFORM_UNSPECIFIED');
|
||||
static const Platform PLATFORM_IOS = Platform._(1, _omitEnumNames ? '' : 'PLATFORM_IOS');
|
||||
static const Platform PLATFORM_ANDROID = Platform._(2, _omitEnumNames ? '' : 'PLATFORM_ANDROID');
|
||||
|
||||
static const $core.List<Platform> values = <Platform> [
|
||||
PLATFORM_UNSPECIFIED,
|
||||
PLATFORM_IOS,
|
||||
PLATFORM_ANDROID,
|
||||
];
|
||||
|
||||
static final $core.Map<$core.int, Platform> _byValue = $pb.ProtobufEnum.initByValue(values);
|
||||
static Platform? valueOf($core.int value) => _byValue[value];
|
||||
|
||||
const Platform._($core.int v, $core.String n) : super(v, n);
|
||||
}
|
||||
|
||||
|
||||
const _omitEnumNames = $core.bool.fromEnvironment('protobuf.omit_enum_names');
|
||||
+58
-2
@@ -59,6 +59,38 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.0.3"
|
||||
app_links:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: app_links
|
||||
sha256: "5f88447519add627fe1cbcab4fd1da3d4fed15b9baf29f28b22535c95ecee3e8"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.4.1"
|
||||
app_links_linux:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: app_links_linux
|
||||
sha256: f5f7173a78609f3dfd4c2ff2c95bd559ab43c80a87dc6a095921d96c05688c81
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.3"
|
||||
app_links_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: app_links_platform_interface
|
||||
sha256: "05f5379577c513b534a29ddea68176a4d4802c46180ee8e2e966257158772a3f"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.0.2"
|
||||
app_links_web:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: app_links_web
|
||||
sha256: af060ed76183f9e2b87510a9480e56a5352b6c249778d07bd2c95fc35632a555
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.4"
|
||||
app_tracking_transparency:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -355,6 +387,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.2"
|
||||
dsbridge_flutter:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: dsbridge_flutter
|
||||
sha256: "887ee2b50f43be294815af33f9a8499f35ce7e7bbb8eeb2234a0e1984d1254e0"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.2.0"
|
||||
easy_refresh:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -583,7 +623,7 @@ packages:
|
||||
source: hosted
|
||||
version: "0.1.3"
|
||||
flutter_svg:
|
||||
dependency: transitive
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: flutter_svg
|
||||
sha256: "35882981abcbfb8c15b286f0cd690ff25bac12d95eff3e25ee207f37d4c42e7f"
|
||||
@@ -656,6 +696,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.3.2"
|
||||
gtk:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: gtk
|
||||
sha256: "4ff85b2a16724029dd9e5bbb5a94b6918f9973f74ba571c949d2002801879cf5"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.2.0"
|
||||
hooks_riverpod:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -872,6 +920,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.0.0"
|
||||
not_static_icons:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: not_static_icons
|
||||
sha256: "6150cea030b323eb0d18f9fe7a03d3693dd8a7ba18151fa8e560f099fd3e1db8"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.46.0"
|
||||
octo_image:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -1673,7 +1729,7 @@ packages:
|
||||
source: hosted
|
||||
version: "4.13.0"
|
||||
webview_flutter_android:
|
||||
dependency: transitive
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: webview_flutter_android
|
||||
sha256: ad5182eff9a550925330cb9f0cb038eddfdd5712aba8b77aa0f0400e50f6e688
|
||||
|
||||
+7
-1
@@ -2,7 +2,7 @@ name: jinzhi_flutter_app
|
||||
description: "金值 - 贵金属资产管家"
|
||||
publish_to: 'none'
|
||||
|
||||
version: 1.0.0+1
|
||||
version: 1.3.0+1
|
||||
|
||||
environment:
|
||||
sdk: ^3.9.2
|
||||
@@ -18,6 +18,7 @@ dependencies:
|
||||
flutter_hooks: ^0.21.3+1
|
||||
# 路由
|
||||
go_router: ^16.2.4
|
||||
app_links: ^6.4.1
|
||||
# 网络 + 序列化
|
||||
dio: ^5.9.0
|
||||
retrofit: ">=4.6.0 <4.9.2"
|
||||
@@ -35,12 +36,16 @@ dependencies:
|
||||
shadcn_ui: ^0.53.6
|
||||
google_fonts: ^6.2.1
|
||||
flutter_remix: ^0.0.3
|
||||
flutter_svg: ^2.2.3
|
||||
not_static_icons: ^0.46.0
|
||||
shimmer: ^3.0.0
|
||||
# 网络图片与磁盘缓存
|
||||
cached_network_image: ^3.4.1
|
||||
flutter_cache_manager: ^3.4.1
|
||||
webview_flutter: 4.13.0
|
||||
webview_flutter_android: 4.12.0
|
||||
webview_flutter_wkwebview: 3.25.0
|
||||
dsbridge_flutter: ^1.2.0
|
||||
flutter_app_update: ^3.2.1
|
||||
android_intent_plus: ^6.0.0
|
||||
permission_handler: ^12.0.1
|
||||
@@ -109,3 +114,4 @@ flutter:
|
||||
- shorebird.yaml
|
||||
- assets/env/.env
|
||||
- assets/launch/
|
||||
- assets/navigation/
|
||||
@@ -0,0 +1,74 @@
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:jinzhi_flutter_app/common/network/api_exception.dart';
|
||||
import 'package:jinzhi_flutter_app/common/network/kratos_error_interceptor.dart';
|
||||
import 'package:jinzhi_flutter_app/models/pb/errors/errors.pb.dart' as errors;
|
||||
|
||||
void main() {
|
||||
test('classifies Kratos invalid token and preserves the raw body', () async {
|
||||
final body = errors.Status(
|
||||
code: 401,
|
||||
reason: 'INVALID_TOKEN',
|
||||
message: 'expired',
|
||||
).writeToBuffer();
|
||||
final dio = Dio()
|
||||
..httpClientAdapter = _ErrorAdapter(body, 401)
|
||||
..interceptors.add(KratosErrorInterceptor());
|
||||
|
||||
try {
|
||||
await dio.get<List<int>>(
|
||||
'/me',
|
||||
options: Options(responseType: ResponseType.bytes),
|
||||
);
|
||||
fail('request should fail');
|
||||
} on DioException catch (error) {
|
||||
final exception = ApiException.from(error);
|
||||
expect(exception.kind, ApiExceptionKind.backend);
|
||||
expect(exception.reason, 'INVALID_TOKEN');
|
||||
expect(exception.isUnauthorized, isTrue);
|
||||
expect(error.response!.data, body);
|
||||
}
|
||||
});
|
||||
|
||||
test('classifies transport, retryable server errors, and cancellation', () {
|
||||
final request = RequestOptions(path: '/test');
|
||||
expect(
|
||||
ApiException.fromDio(DioException(requestOptions: request)).kind,
|
||||
ApiExceptionKind.transport,
|
||||
);
|
||||
expect(
|
||||
ApiException.fromDio(
|
||||
DioException(
|
||||
requestOptions: request,
|
||||
response: Response<void>(requestOptions: request, statusCode: 503),
|
||||
),
|
||||
).isRetryable,
|
||||
isTrue,
|
||||
);
|
||||
expect(
|
||||
ApiException.fromDio(
|
||||
DioException(requestOptions: request, type: DioExceptionType.cancel),
|
||||
).kind,
|
||||
ApiExceptionKind.cancelled,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
class _ErrorAdapter implements HttpClientAdapter {
|
||||
_ErrorAdapter(this.body, this.status);
|
||||
|
||||
final List<int> body;
|
||||
final int status;
|
||||
|
||||
@override
|
||||
Future<ResponseBody> fetch(
|
||||
RequestOptions options,
|
||||
Stream<Uint8List>? requestStream,
|
||||
Future<void>? cancelFuture,
|
||||
) async => ResponseBody.fromBytes(body, status);
|
||||
|
||||
@override
|
||||
void close({bool force = false}) {}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:jinzhi_flutter_app/common/network/auth_interceptor.dart';
|
||||
import 'package:jinzhi_flutter_app/common/storage/preferences_storage.dart';
|
||||
import 'package:jinzhi_flutter_app/common/storage/secure_identity_storage.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
test('external absolute URLs lose all backend identity headers', () async {
|
||||
SharedPreferences.setMockInitialValues({});
|
||||
final preferences = await SharedPreferences.getInstance();
|
||||
final session = AuthSessionStorage(
|
||||
SecureIdentityStorage(
|
||||
PreferencesStorage(preferences),
|
||||
secureStorage: _MemorySecureStorage(),
|
||||
),
|
||||
);
|
||||
final dio = Dio(BaseOptions(baseUrl: 'https://api.example.com'))
|
||||
..interceptors.add(
|
||||
AuthInterceptor(
|
||||
session,
|
||||
backendOrigin: Uri.parse('https://api.example.com'),
|
||||
),
|
||||
)
|
||||
..httpClientAdapter = _CaptureAdapter();
|
||||
|
||||
final response = await dio.get<void>(
|
||||
'https://cdn.example.net/ad.png',
|
||||
options: Options(
|
||||
headers: {
|
||||
'Authorization': 'Bearer leaked',
|
||||
'X-Device-Id': 'device',
|
||||
'User-iOS-IDFA': 'idfa',
|
||||
'User-DeviceToken': 'push',
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
final headers = response.requestOptions.headers.keys
|
||||
.map((key) => key.toLowerCase())
|
||||
.toSet();
|
||||
expect(headers, isNot(contains('authorization')));
|
||||
expect(headers, isNot(contains('x-device-id')));
|
||||
expect(headers, isNot(contains('user-ios-idfa')));
|
||||
expect(headers, isNot(contains('user-devicetoken')));
|
||||
});
|
||||
}
|
||||
|
||||
class _MemorySecureStorage implements SecureKeyValueStorage {
|
||||
final Map<String, String> values = {};
|
||||
|
||||
@override
|
||||
Future<void> delete(String key) async => values.remove(key);
|
||||
@override
|
||||
Future<String?> read(String key) async => values[key];
|
||||
@override
|
||||
Future<void> write(String key, String value) async => values[key] = value;
|
||||
}
|
||||
|
||||
class _CaptureAdapter implements HttpClientAdapter {
|
||||
@override
|
||||
Future<ResponseBody> fetch(
|
||||
RequestOptions options,
|
||||
Stream<Uint8List>? requestStream,
|
||||
Future<void>? cancelFuture,
|
||||
) async => ResponseBody.fromString('', 204);
|
||||
|
||||
@override
|
||||
void close({bool force = false}) {}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:jinzhi_flutter_app/common/network/api_error_message.dart';
|
||||
import 'package:jinzhi_flutter_app/common/network/kratos_error_interceptor.dart';
|
||||
import 'package:jinzhi_flutter_app/models/pb/errors/errors.pb.dart' as errors;
|
||||
|
||||
void main() {
|
||||
test('decodes a protobuf error body before it reaches the UI', () async {
|
||||
final dio = Dio()
|
||||
..httpClientAdapter = _ErrorAdapter(
|
||||
errors.Status(
|
||||
code: 401,
|
||||
reason: 'INVALID_CODE',
|
||||
message: '验证码错误',
|
||||
).writeToBuffer(),
|
||||
)
|
||||
..interceptors.add(KratosErrorInterceptor());
|
||||
|
||||
await expectLater(
|
||||
dio.delete<void>(
|
||||
'/api/v1/me',
|
||||
options: Options(responseType: ResponseType.bytes),
|
||||
),
|
||||
throwsA(
|
||||
isA<DioException>().having(
|
||||
(error) => kratosDisplayMessage(error, fallback: '注销失败,请重试'),
|
||||
'display message',
|
||||
'验证码错误',
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
class _ErrorAdapter implements HttpClientAdapter {
|
||||
_ErrorAdapter(this._body);
|
||||
|
||||
final List<int> _body;
|
||||
|
||||
@override
|
||||
Future<ResponseBody> fetch(
|
||||
RequestOptions options,
|
||||
Stream<Uint8List>? requestStream,
|
||||
Future<void>? cancelFuture,
|
||||
) async => ResponseBody.fromBytes(
|
||||
_body,
|
||||
401,
|
||||
headers: {
|
||||
'content-type': ['application/x-protobuf'],
|
||||
},
|
||||
);
|
||||
|
||||
@override
|
||||
void close({bool force = false}) {}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:jinzhi_flutter_app/common/push/push_api_client.dart';
|
||||
import 'package:jinzhi_flutter_app/models/pb/jinzhi/v1/push.pb.dart' as pb;
|
||||
|
||||
void main() {
|
||||
test(
|
||||
'Retrofit push client uses generated protobuf in both directions',
|
||||
() async {
|
||||
final adapter = _PushAdapter();
|
||||
final client = PushApiClient(
|
||||
Dio(BaseOptions(responseType: ResponseType.bytes))
|
||||
..httpClientAdapter = adapter,
|
||||
);
|
||||
|
||||
final reply = await client.registerDevice(
|
||||
pb.RegisterPushDeviceRequest(
|
||||
umengDeviceToken: 'push-token',
|
||||
platform: pb.Platform.PLATFORM_IOS,
|
||||
appVersion: '1.1.0',
|
||||
permissionGranted: true,
|
||||
),
|
||||
);
|
||||
|
||||
expect(reply.deviceRecordId, 'device-record');
|
||||
final request = pb.RegisterPushDeviceRequest.fromBuffer(adapter.body!);
|
||||
expect(request.umengDeviceToken, 'push-token');
|
||||
expect(request.platform, pb.Platform.PLATFORM_IOS);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
class _PushAdapter implements HttpClientAdapter {
|
||||
List<int>? body;
|
||||
|
||||
@override
|
||||
Future<ResponseBody> fetch(
|
||||
RequestOptions options,
|
||||
Stream<Uint8List>? requestStream,
|
||||
Future<void>? cancelFuture,
|
||||
) async {
|
||||
body = (options.data as List<int>).toList();
|
||||
return ResponseBody.fromBytes(
|
||||
pb.RegisterPushDeviceReply(
|
||||
deviceRecordId: 'device-record',
|
||||
).writeToBuffer(),
|
||||
200,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void close({bool force = false}) {}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:jinzhi_flutter_app/common/router/deep_link_service.dart';
|
||||
|
||||
void main() {
|
||||
setUp(DeepLinkService.resetForTest);
|
||||
|
||||
test('keeps FIFO events until the router is ready', () {
|
||||
DeepLinkService.configureTrustedHttpsHosts({'links.example.com'});
|
||||
DeepLinkService.handle('jinzhi://market');
|
||||
DeepLinkService.handleUri(Uri.parse('https://links.example.com/news?id=1'));
|
||||
expect(DeepLinkService.pendingCount, 2);
|
||||
|
||||
final navigated = <String>[];
|
||||
DeepLinkService.attachRouter((location) {
|
||||
navigated.add(location);
|
||||
return true;
|
||||
});
|
||||
|
||||
expect(navigated, ['/market', '/news?id=1']);
|
||||
expect(DeepLinkService.pendingCount, 0);
|
||||
});
|
||||
|
||||
test('rejects HTTPS links outside the exact trusted host allowlist', () {
|
||||
DeepLinkService.configureTrustedHttpsHosts({'links.example.com'});
|
||||
final navigated = <String>[];
|
||||
DeepLinkService.attachRouter((location) {
|
||||
navigated.add(location);
|
||||
return true;
|
||||
});
|
||||
|
||||
DeepLinkService.handleUri(Uri.parse('https://evil.example/news'));
|
||||
DeepLinkService.handleUri(Uri.parse('https://sub.links.example.com/news'));
|
||||
DeepLinkService.handleUri(Uri.parse('https://links.example.com:8443/news'));
|
||||
DeepLinkService.handleUri(Uri.parse('https://links.example.com/news'));
|
||||
|
||||
expect(navigated, ['/news']);
|
||||
expect(DeepLinkService.pendingCount, 0);
|
||||
});
|
||||
|
||||
test('disables HTTPS deep links when no production host is configured', () {
|
||||
final navigated = <String>[];
|
||||
DeepLinkService.attachRouter((location) {
|
||||
navigated.add(location);
|
||||
return true;
|
||||
});
|
||||
|
||||
DeepLinkService.handleUri(Uri.parse('https://links.example.com/news'));
|
||||
DeepLinkService.handle('jinzhi://news');
|
||||
|
||||
expect(navigated, ['/news']);
|
||||
});
|
||||
|
||||
test('deduplicates event ids even after the first event is consumed', () {
|
||||
final navigated = <String>[];
|
||||
DeepLinkService.attachRouter((location) {
|
||||
navigated.add(location);
|
||||
return true;
|
||||
});
|
||||
final payload = jsonEncode({
|
||||
'event_id': 'same-event',
|
||||
'deep_link': 'jinzhi://assets',
|
||||
});
|
||||
|
||||
DeepLinkService.handle(payload);
|
||||
DeepLinkService.handle(payload);
|
||||
|
||||
expect(navigated, ['/assets']);
|
||||
});
|
||||
|
||||
test('uses bounded queue and discards expired events', () {
|
||||
final old = DateTime.now().subtract(DeepLinkService.expiry * 2);
|
||||
DeepLinkService.enqueue(
|
||||
'jinzhi://assets',
|
||||
source: DeepLinkSource.appLink,
|
||||
receivedAt: old,
|
||||
);
|
||||
for (var index = 0; index < DeepLinkService.maxPending + 5; index++) {
|
||||
DeepLinkService.enqueue(
|
||||
'jinzhi://news?event_id=$index',
|
||||
source: DeepLinkSource.push,
|
||||
);
|
||||
}
|
||||
expect(DeepLinkService.pendingCount, DeepLinkService.maxPending);
|
||||
|
||||
final navigated = <String>[];
|
||||
DeepLinkService.attachRouter((location) {
|
||||
navigated.add(location);
|
||||
return true;
|
||||
});
|
||||
expect(navigated, hasLength(DeepLinkService.maxPending));
|
||||
expect(navigated, isNot(contains('/assets')));
|
||||
});
|
||||
|
||||
test('auth guard leaves the head pending until auth changes', () {
|
||||
var authenticated = false;
|
||||
final navigated = <String>[];
|
||||
DeepLinkService.attachRouter((location) {
|
||||
navigated.add(location);
|
||||
return true;
|
||||
}, authGuard: (_) => authenticated);
|
||||
DeepLinkService.handle('jinzhi://profile');
|
||||
expect(DeepLinkService.pendingCount, 1);
|
||||
|
||||
authenticated = true;
|
||||
DeepLinkService.notifyAuthChanged();
|
||||
expect(navigated, ['/profile']);
|
||||
});
|
||||
}
|
||||
@@ -1,6 +1,9 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:jinzhi_flutter_app/common/storage/secure_identity_storage.dart';
|
||||
import 'package:jinzhi_flutter_app/common/storage/storage_keys.dart';
|
||||
import 'package:jinzhi_flutter_app/common/storage/preferences_storage.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
void main() {
|
||||
@@ -14,7 +17,10 @@ void main() {
|
||||
});
|
||||
final preferences = await SharedPreferences.getInstance();
|
||||
final secure = _MemorySecureStorage();
|
||||
final storage = SecureIdentityStorage(preferences, secureStorage: secure);
|
||||
final storage = SecureIdentityStorage(
|
||||
PreferencesStorage(preferences),
|
||||
secureStorage: secure,
|
||||
);
|
||||
|
||||
expect(await storage.restoreAccessToken(), 'legacy');
|
||||
expect(await secure.read('access_token'), 'legacy');
|
||||
@@ -24,17 +30,109 @@ void main() {
|
||||
);
|
||||
|
||||
test(
|
||||
'migrates the previous secure jwt key when no current token exists',
|
||||
'rejects an orphaned Keychain token when the install marker is absent',
|
||||
() async {
|
||||
final preferences = await SharedPreferences.getInstance();
|
||||
final secure = _MemorySecureStorage({'jwt_token': 'secure-legacy'});
|
||||
final storage = SecureIdentityStorage(preferences, secureStorage: secure);
|
||||
final storage = SecureIdentityStorage(
|
||||
PreferencesStorage(preferences),
|
||||
secureStorage: secure,
|
||||
);
|
||||
|
||||
expect(await storage.restoreAccessToken(), 'secure-legacy');
|
||||
expect(await secure.read('access_token'), 'secure-legacy');
|
||||
expect(await storage.restoreAccessToken(), isNull);
|
||||
expect(await secure.read('access_token'), isNull);
|
||||
expect(await secure.read('jwt_token'), isNull);
|
||||
},
|
||||
);
|
||||
|
||||
test('restores a secure token only with the installation marker', () async {
|
||||
SharedPreferences.setMockInitialValues({storageAuthSessionActive: true});
|
||||
final preferences = await SharedPreferences.getInstance();
|
||||
final secure = _MemorySecureStorage({secureAccessToken: 'active'});
|
||||
final storage = SecureIdentityStorage(
|
||||
PreferencesStorage(preferences),
|
||||
secureStorage: secure,
|
||||
);
|
||||
|
||||
expect(await storage.restoreAccessToken(), 'active');
|
||||
});
|
||||
|
||||
test('logout clears session secrets but preserves device identity', () async {
|
||||
SharedPreferences.setMockInitialValues({storageAuthSessionActive: true});
|
||||
final preferences = await SharedPreferences.getInstance();
|
||||
final secure = _MemorySecureStorage({
|
||||
secureAccessToken: 'access',
|
||||
secureMallCredential: 'mall',
|
||||
secureRefreshToken: 'refresh',
|
||||
secureBiometricCredential: 'biometric',
|
||||
secureDeviceId: 'device',
|
||||
});
|
||||
final storage = SecureIdentityStorage(
|
||||
PreferencesStorage(preferences),
|
||||
secureStorage: secure,
|
||||
);
|
||||
|
||||
await storage.clearSessionCredentials();
|
||||
|
||||
expect(await secure.read(secureAccessToken), isNull);
|
||||
expect(await secure.read(secureMallCredential), isNull);
|
||||
expect(await secure.read(secureRefreshToken), isNull);
|
||||
expect(await secure.read(secureBiometricCredential), 'biometric');
|
||||
expect(await secure.read(secureDeviceId), 'device');
|
||||
|
||||
await storage.clearAccountCredentials();
|
||||
expect(await secure.read(secureBiometricCredential), isNull);
|
||||
expect(await secure.read(secureDeviceId), 'device');
|
||||
});
|
||||
|
||||
test(
|
||||
'serializes restore with logout so an old token cannot revive',
|
||||
() async {
|
||||
SharedPreferences.setMockInitialValues({storageAuthSessionActive: true});
|
||||
final preferences = await SharedPreferences.getInstance();
|
||||
final secure = _BlockingReadSecureStorage({
|
||||
secureAccessToken: 'old-token',
|
||||
});
|
||||
final session = AuthSessionStorage(
|
||||
SecureIdentityStorage(
|
||||
PreferencesStorage(preferences),
|
||||
secureStorage: secure,
|
||||
),
|
||||
);
|
||||
|
||||
final restoring = session.restore();
|
||||
await secure.readStarted.future;
|
||||
final clearing = session.clear();
|
||||
secure.releaseRead.complete();
|
||||
|
||||
expect(await restoring, 'old-token');
|
||||
await clearing;
|
||||
expect(session.accessToken, isNull);
|
||||
expect(await secure.read(secureAccessToken), isNull);
|
||||
},
|
||||
);
|
||||
|
||||
test('serializes restore with login so the new token wins', () async {
|
||||
SharedPreferences.setMockInitialValues({storageAuthSessionActive: true});
|
||||
final preferences = await SharedPreferences.getInstance();
|
||||
final secure = _BlockingReadSecureStorage({secureAccessToken: 'old-token'});
|
||||
final session = AuthSessionStorage(
|
||||
SecureIdentityStorage(
|
||||
PreferencesStorage(preferences),
|
||||
secureStorage: secure,
|
||||
),
|
||||
);
|
||||
|
||||
final restoring = session.restore();
|
||||
await secure.readStarted.future;
|
||||
final saving = session.save('new-token');
|
||||
secure.releaseRead.complete();
|
||||
|
||||
expect(await restoring, 'old-token');
|
||||
await saving;
|
||||
expect(session.accessToken, 'new-token');
|
||||
expect(await secure.read(secureAccessToken), 'new-token');
|
||||
});
|
||||
}
|
||||
|
||||
class _MemorySecureStorage implements SecureKeyValueStorage {
|
||||
@@ -54,3 +152,21 @@ class _MemorySecureStorage implements SecureKeyValueStorage {
|
||||
_values[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
class _BlockingReadSecureStorage extends _MemorySecureStorage {
|
||||
_BlockingReadSecureStorage(super.initial);
|
||||
|
||||
final readStarted = Completer<void>();
|
||||
final releaseRead = Completer<void>();
|
||||
var _blocked = false;
|
||||
|
||||
@override
|
||||
Future<String?> read(String key) async {
|
||||
if (key == secureAccessToken && !_blocked) {
|
||||
_blocked = true;
|
||||
readStarted.complete();
|
||||
await releaseRead.future;
|
||||
}
|
||||
return super.read(key);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:fixnum/fixnum.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:jinzhi_flutter_app/features/ads/data/ads_api_client.dart';
|
||||
import 'package:jinzhi_flutter_app/models/pb/jinzhi/v1/ads.pb.dart' as pb;
|
||||
|
||||
void main() {
|
||||
test(
|
||||
'Retrofit client decodes generated ad protobuf and encodes events',
|
||||
() async {
|
||||
final adapter = _AdsAdapter();
|
||||
final backend = AdsBackendApiClient(
|
||||
Dio(BaseOptions(responseType: ResponseType.bytes))
|
||||
..httpClientAdapter = adapter,
|
||||
);
|
||||
final client = AdsApiClient(backend);
|
||||
|
||||
final result = await client.getAdResult(splashAdPlacement);
|
||||
expect(result.enabled, isTrue);
|
||||
expect(result.ad?.title, '商城');
|
||||
expect(result.ad?.endAtMs, 4102444800000);
|
||||
|
||||
await client.report(result.ad!, 'impression');
|
||||
final report = pb.ReportAdEventRequest.fromBuffer(adapter.lastBody!);
|
||||
expect(report.eventName, 'impression');
|
||||
expect(report.adId, 'ad-1');
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
class _AdsAdapter implements HttpClientAdapter {
|
||||
List<int>? lastBody;
|
||||
|
||||
@override
|
||||
Future<ResponseBody> fetch(
|
||||
RequestOptions options,
|
||||
Stream<Uint8List>? requestStream,
|
||||
Future<void>? cancelFuture,
|
||||
) async {
|
||||
if (options.path == '/api/v1/ads') {
|
||||
return ResponseBody.fromBytes(
|
||||
pb.GetAdReply(
|
||||
enabled: true,
|
||||
dailyCap: 1,
|
||||
ad: pb.AdCreative(
|
||||
adId: 'ad-1',
|
||||
placement: splashAdPlacement,
|
||||
title: '商城',
|
||||
imageUrl: 'https://cdn.example.com/ad.png',
|
||||
h5Url: 'https://example.com',
|
||||
trackedUrl: 'https://example.com',
|
||||
mediaType: pb.MediaType.MEDIA_TYPE_IMAGE,
|
||||
openMode: pb.AdOpenMode.AD_OPEN_MODE_EXTERNAL,
|
||||
endAtMs: Int64(4102444800000),
|
||||
),
|
||||
).writeToBuffer(),
|
||||
200,
|
||||
);
|
||||
}
|
||||
lastBody = (options.data as List<int>).toList();
|
||||
return ResponseBody.fromBytes(pb.ReportAdEventReply().writeToBuffer(), 200);
|
||||
}
|
||||
|
||||
@override
|
||||
void close({bool force = false}) {}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:jinzhi_flutter_app/common/network/protobuf_wire_reader.dart';
|
||||
|
||||
void main() {
|
||||
test('protobuf strings are decoded as UTF-8', () {
|
||||
final value = utf8.encode('商城');
|
||||
final message = ProtobufWireMessage(<int>[10, value.length, ...value]);
|
||||
|
||||
expect(message.string(1), '商城');
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:jinzhi_flutter_app/common/storage/preferences_storage.dart';
|
||||
import 'package:jinzhi_flutter_app/features/assets/data/mock_asset_repository.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
void main() {
|
||||
setUp(() => SharedPreferences.setMockInitialValues({}));
|
||||
|
||||
test('account deletion stays empty after repository recreation', () async {
|
||||
final preferences = PreferencesStorage(
|
||||
await SharedPreferences.getInstance(),
|
||||
);
|
||||
final repository = MockAssetRepository(preferences);
|
||||
final accountScope = MockAssetRepository.accountScope(userId: 'user-1');
|
||||
final initial = repository.loadHoldings(accountScope);
|
||||
expect(initial, isNotEmpty);
|
||||
expect(
|
||||
repository.loadHoldings(MockAssetRepository.guestAccountScope),
|
||||
isNotEmpty,
|
||||
);
|
||||
await repository.saveHoldings(accountScope, initial);
|
||||
|
||||
await repository.deleteAccountHoldings(accountScope);
|
||||
final recreated = MockAssetRepository(preferences);
|
||||
|
||||
expect(recreated.loadHoldings(accountScope), isEmpty);
|
||||
expect(
|
||||
recreated.loadHoldings(MockAssetRepository.guestAccountScope),
|
||||
isEmpty,
|
||||
);
|
||||
});
|
||||
|
||||
test('holdings are isolated by account and deletion is scoped', () async {
|
||||
final preferences = PreferencesStorage(
|
||||
await SharedPreferences.getInstance(),
|
||||
);
|
||||
final repository = MockAssetRepository(preferences);
|
||||
final first = MockAssetRepository.accountScope(userId: 'user-1');
|
||||
final second = MockAssetRepository.accountScope(userId: 'user-2');
|
||||
final firstHoldings = repository.loadHoldings(first);
|
||||
final secondHoldings = repository.loadHoldings(second);
|
||||
await repository.saveHoldings(first, firstHoldings.take(1).toList());
|
||||
await repository.saveHoldings(second, secondHoldings.take(2).toList());
|
||||
|
||||
await repository.deleteAccountHoldings(first);
|
||||
|
||||
expect(repository.loadHoldings(first), isEmpty);
|
||||
expect(repository.loadHoldings(second), hasLength(2));
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:jinzhi_flutter_app/features/auth/data/auth_api.dart';
|
||||
import 'package:jinzhi_flutter_app/models/pb/jinzhi/v1/account.pb.dart' as pb;
|
||||
|
||||
void main() {
|
||||
test('keeps the verified one-key login contract', () async {
|
||||
String? requestToken;
|
||||
final dio = Dio()
|
||||
..interceptors.add(
|
||||
InterceptorsWrapper(
|
||||
onRequest: (options, handler) {
|
||||
requestToken = pb.VerifyOneKeyLoginRequest.fromBuffer(
|
||||
options.data as List<int>,
|
||||
).loginToken;
|
||||
handler.resolve(
|
||||
Response<List<int>>(
|
||||
requestOptions: options,
|
||||
statusCode: 200,
|
||||
data: pb.VerifySmsCodeReply(
|
||||
accessToken: 'access-token',
|
||||
user: pb.UserInfo(
|
||||
id: 'user-1',
|
||||
phone: '13800138000',
|
||||
createdAt: '2026-09-08T00:00:00Z',
|
||||
),
|
||||
).writeToBuffer(),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
final result = await AuthApi(dio).verifyOneKeyLogin('umeng-token');
|
||||
|
||||
expect(requestToken, 'umeng-token');
|
||||
expect(result.accessToken, 'access-token');
|
||||
expect(result.user.phone, '13800138000');
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,308 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:jinzhi_flutter_app/common/storage/secure_identity_storage.dart';
|
||||
import 'package:jinzhi_flutter_app/common/storage/storage_keys.dart';
|
||||
import 'package:jinzhi_flutter_app/common/storage/preferences_storage.dart';
|
||||
import 'package:jinzhi_flutter_app/features/auth/application/auth_controller.dart';
|
||||
import 'package:jinzhi_flutter_app/features/auth/application/auth_session_coordinator.dart';
|
||||
import 'package:jinzhi_flutter_app/features/auth/data/auth_models.dart';
|
||||
import 'package:jinzhi_flutter_app/features/assets/data/mock_asset_repository.dart';
|
||||
import 'package:jinzhi_flutter_app/models/pb/jinzhi/v1/account.pb.dart' as pb;
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
setUp(() => SharedPreferences.setMockInitialValues({}));
|
||||
|
||||
test('restores a token by loading the real profile', () async {
|
||||
final fixture = await _Fixture.create();
|
||||
await fixture.identity.saveJwt('token');
|
||||
|
||||
await fixture.controller.retryBootstrap();
|
||||
|
||||
final auth = fixture.controller.state.value! as LoggedInAuthState;
|
||||
expect(auth.token, 'token');
|
||||
expect(auth.user.id, 'user-1');
|
||||
expect(auth.user.phone, '13800138000');
|
||||
expect(fixture.preferences.getString(storageUserId), 'user-1');
|
||||
});
|
||||
|
||||
test('deletes the server account before clearing local identity', () async {
|
||||
final fixture = await _Fixture.create();
|
||||
await fixture.identity.saveJwt('token');
|
||||
await fixture.controller.retryBootstrap();
|
||||
|
||||
await fixture.controller.deleteAccount('123456');
|
||||
|
||||
expect(fixture.deletedWithCode, '123456');
|
||||
expect(fixture.controller.state.value, isA<GuestAuthState>());
|
||||
expect(await fixture.identity.readJwt(), isNull);
|
||||
expect(fixture.preferences.getString(storageUserId), isNull);
|
||||
});
|
||||
|
||||
test('keeps the local session when server deletion fails', () async {
|
||||
final fixture = await _Fixture.create();
|
||||
await fixture.identity.saveJwt('token');
|
||||
await fixture.controller.retryBootstrap();
|
||||
fixture.failDeletion = true;
|
||||
|
||||
await expectLater(
|
||||
fixture.controller.deleteAccount('123456'),
|
||||
throwsA(isA<DioException>()),
|
||||
);
|
||||
|
||||
expect(fixture.controller.state.value, isA<LoggedInAuthState>());
|
||||
expect(await fixture.identity.readJwt(), 'token');
|
||||
expect(fixture.preferences.getString(storageUserId), 'user-1');
|
||||
});
|
||||
|
||||
test('clears an unauthorized restored session', () async {
|
||||
final fixture = await _Fixture.create();
|
||||
await fixture.identity.saveJwt('expired');
|
||||
fixture.profileUnauthorized = true;
|
||||
|
||||
await fixture.controller.retryBootstrap();
|
||||
|
||||
expect(fixture.controller.state.value, isA<SessionExpiredAuthState>());
|
||||
expect(await fixture.identity.readJwt(), isNull);
|
||||
});
|
||||
|
||||
test('keeps a valid token and cached profile on a network failure', () async {
|
||||
final fixture = await _Fixture.create();
|
||||
await fixture.identity.saveJwt('token');
|
||||
await fixture.preferences.setString('jinzhi_mock_logged_in', '13900139000');
|
||||
fixture.failProfile = true;
|
||||
|
||||
await fixture.controller.retryBootstrap();
|
||||
|
||||
final auth = fixture.controller.state.value! as LoggedInAuthState;
|
||||
expect(auth.user.id, isEmpty);
|
||||
expect(auth.user.phone, '13900139000');
|
||||
expect(await fixture.identity.readJwt(), 'token');
|
||||
expect(fixture.preferences.getString(storageUserPhone), '13900139000');
|
||||
expect(fixture.preferences.containsKey('jinzhi_mock_logged_in'), isFalse);
|
||||
});
|
||||
|
||||
test('restores cached profile before the background profile reply', () async {
|
||||
final fixture = await _Fixture.create();
|
||||
await fixture.identity.saveJwt('token');
|
||||
await fixture.preferences.setString(storageUserId, 'cached-user');
|
||||
await fixture.preferences.setString(storageUserPhone, '13900139000');
|
||||
fixture.profileCompleter = Completer<void>();
|
||||
|
||||
final bootstrap = fixture.controller.retryBootstrap();
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
|
||||
final stale = fixture.controller.state.value! as LoggedInAuthState;
|
||||
expect(stale.user.id, 'cached-user');
|
||||
expect(stale.isVerified, isFalse);
|
||||
|
||||
fixture.profileCompleter!.complete();
|
||||
await bootstrap;
|
||||
expect(
|
||||
(fixture.controller.state.value! as LoggedInAuthState).isVerified,
|
||||
isTrue,
|
||||
);
|
||||
});
|
||||
|
||||
test('keeps cached login on 403, 429, 5xx, and decode failures', () async {
|
||||
for (final failure in [403, 429, 503, -1]) {
|
||||
final fixture = await _Fixture.create();
|
||||
await fixture.identity.saveJwt('token');
|
||||
await fixture.preferences.setString(storageUserId, 'cached-user');
|
||||
fixture.profileFailureStatus = failure;
|
||||
|
||||
await fixture.controller.retryBootstrap();
|
||||
|
||||
expect(fixture.controller.state.value, isA<LoggedInAuthState>());
|
||||
expect(await fixture.identity.readJwt(), 'token');
|
||||
}
|
||||
});
|
||||
|
||||
test('logout preserves assets while account deletion removes them', () async {
|
||||
final logoutFixture = await _Fixture.create();
|
||||
await logoutFixture.identity.saveJwt('token');
|
||||
await logoutFixture.preferences.setString(storageJinzhiHoldings, 'assets');
|
||||
await logoutFixture.controller.logout();
|
||||
expect(
|
||||
logoutFixture.preferences.getString(storageJinzhiHoldings),
|
||||
'assets',
|
||||
);
|
||||
|
||||
final deleteFixture = await _Fixture.create();
|
||||
await deleteFixture.identity.saveJwt('token');
|
||||
await deleteFixture.preferences.setString(storageJinzhiHoldings, 'assets');
|
||||
await deleteFixture.controller.deleteAccount('123456');
|
||||
expect(deleteFixture.preferences.getString(storageJinzhiHoldings), isNull);
|
||||
});
|
||||
|
||||
test('keeps the verified one-key login state shape', () async {
|
||||
final fixture = await _Fixture.create();
|
||||
|
||||
await fixture.controller.verifyOneKeyLogin('umeng-token');
|
||||
|
||||
final auth = fixture.controller.state.value! as LoggedInAuthState;
|
||||
expect(auth.token, 'one-key-token');
|
||||
expect(auth.user.phone, '13800138000');
|
||||
expect(await fixture.identity.readJwt(), 'one-key-token');
|
||||
});
|
||||
}
|
||||
|
||||
class _Fixture {
|
||||
_Fixture({
|
||||
required this.preferences,
|
||||
required this.identity,
|
||||
required this.controller,
|
||||
});
|
||||
|
||||
final SharedPreferences preferences;
|
||||
final SecureIdentityStorage identity;
|
||||
final AuthController controller;
|
||||
bool failDeletion = false;
|
||||
bool failProfile = false;
|
||||
bool profileUnauthorized = false;
|
||||
int? profileFailureStatus;
|
||||
Completer<void>? profileCompleter;
|
||||
String? deletedWithCode;
|
||||
|
||||
static Future<_Fixture> create() async {
|
||||
final preferences = await SharedPreferences.getInstance();
|
||||
final identity = SecureIdentityStorage(
|
||||
PreferencesStorage(preferences),
|
||||
secureStorage: _MemorySecureStorage(),
|
||||
);
|
||||
late _Fixture fixture;
|
||||
final dio = Dio()
|
||||
..interceptors.add(
|
||||
InterceptorsWrapper(
|
||||
onRequest: (options, handler) async {
|
||||
if (options.path == '/api/v1/me' && options.method == 'GET') {
|
||||
if (fixture.profileCompleter case final completer?) {
|
||||
await completer.future;
|
||||
}
|
||||
if (fixture.profileFailureStatus case final status?) {
|
||||
if (status == -1) {
|
||||
handler.resolve(
|
||||
Response<List<int>>(
|
||||
requestOptions: options,
|
||||
statusCode: 200,
|
||||
data: const [255],
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
handler.reject(
|
||||
DioException(
|
||||
requestOptions: options,
|
||||
response: Response<void>(
|
||||
requestOptions: options,
|
||||
statusCode: status,
|
||||
),
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (fixture.failProfile) {
|
||||
handler.reject(DioException(requestOptions: options));
|
||||
return;
|
||||
}
|
||||
if (fixture.profileUnauthorized) {
|
||||
handler.reject(
|
||||
DioException(
|
||||
requestOptions: options,
|
||||
response: Response<void>(
|
||||
requestOptions: options,
|
||||
statusCode: 401,
|
||||
),
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
handler.resolve(
|
||||
Response<List<int>>(
|
||||
requestOptions: options,
|
||||
statusCode: 200,
|
||||
data: pb.ProfileReply(
|
||||
user: pb.UserInfo(
|
||||
id: 'user-1',
|
||||
phone: '13800138000',
|
||||
createdAt: '2026-09-08T00:00:00Z',
|
||||
),
|
||||
).writeToBuffer(),
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (options.path == '/api/v1/me' && options.method == 'DELETE') {
|
||||
fixture.deletedWithCode = pb.DeleteAccountRequest.fromBuffer(
|
||||
options.data as List<int>,
|
||||
).smsCode;
|
||||
if (fixture.failDeletion) {
|
||||
handler.reject(DioException(requestOptions: options));
|
||||
} else {
|
||||
handler.resolve(
|
||||
Response<List<int>>(
|
||||
requestOptions: options,
|
||||
statusCode: 200,
|
||||
data: const <int>[],
|
||||
),
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (options.path == '/api/v1/auth/onekey/verify') {
|
||||
handler.resolve(
|
||||
Response<List<int>>(
|
||||
requestOptions: options,
|
||||
statusCode: 200,
|
||||
data: pb.VerifySmsCodeReply(
|
||||
accessToken: 'one-key-token',
|
||||
user: pb.UserInfo(phone: '13800138000'),
|
||||
).writeToBuffer(),
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
handler.next(options);
|
||||
},
|
||||
),
|
||||
);
|
||||
final preferencesStorage = PreferencesStorage(preferences);
|
||||
final sessionStorage = AuthSessionStorage(identity);
|
||||
final coordinator = AuthSessionCoordinator(
|
||||
sessionStorage,
|
||||
preferencesStorage,
|
||||
MockAssetRepository(preferencesStorage),
|
||||
);
|
||||
final controller = AuthController(
|
||||
dio,
|
||||
preferences: preferencesStorage,
|
||||
coordinator: coordinator,
|
||||
autoBootstrap: false,
|
||||
);
|
||||
fixture = _Fixture(
|
||||
preferences: preferences,
|
||||
identity: identity,
|
||||
controller: controller,
|
||||
);
|
||||
return fixture;
|
||||
}
|
||||
}
|
||||
|
||||
class _MemorySecureStorage implements SecureKeyValueStorage {
|
||||
final Map<String, String> _values = {};
|
||||
|
||||
@override
|
||||
Future<void> delete(String key) async => _values.remove(key);
|
||||
|
||||
@override
|
||||
Future<String?> read(String key) async => _values[key];
|
||||
|
||||
@override
|
||||
Future<void> write(String key, String value) async {
|
||||
_values[key] = value;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:jinzhi_flutter_app/common/network/api_exception.dart';
|
||||
import 'package:jinzhi_flutter_app/common/storage/preferences_storage.dart';
|
||||
import 'package:jinzhi_flutter_app/common/storage/secure_identity_storage.dart';
|
||||
import 'package:jinzhi_flutter_app/common/storage/storage_keys.dart';
|
||||
import 'package:jinzhi_flutter_app/features/auth/application/auth_session_coordinator.dart';
|
||||
import 'package:jinzhi_flutter_app/features/assets/data/mock_asset_repository.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
void main() {
|
||||
test(
|
||||
'concurrent unauthorized responses expire one session only once',
|
||||
() async {
|
||||
SharedPreferences.setMockInitialValues({});
|
||||
final preferences = PreferencesStorage(
|
||||
await SharedPreferences.getInstance(),
|
||||
);
|
||||
final identity = SecureIdentityStorage(
|
||||
preferences,
|
||||
secureStorage: _MemorySecureStorage(),
|
||||
);
|
||||
final session = AuthSessionStorage(identity);
|
||||
final assets = MockAssetRepository(preferences);
|
||||
final coordinator = AuthSessionCoordinator(session, preferences, assets);
|
||||
await coordinator.startSession('token');
|
||||
final generation = coordinator.generation;
|
||||
var notifications = 0;
|
||||
coordinator.addListener((_) => notifications++);
|
||||
final request = RequestOptions(
|
||||
path: '/protected',
|
||||
extra: {authSessionGenerationExtra: generation},
|
||||
);
|
||||
final exception = ApiException(
|
||||
kind: ApiExceptionKind.backend,
|
||||
httpStatus: 400,
|
||||
reason: 'TOKEN_EXPIRED',
|
||||
requestOptions: request,
|
||||
);
|
||||
|
||||
await Future.wait([
|
||||
coordinator.expireIfNeeded(exception),
|
||||
coordinator.expireIfNeeded(exception),
|
||||
]);
|
||||
|
||||
expect(notifications, 1);
|
||||
expect(await identity.readJwt(), isNull);
|
||||
expect(await coordinator.expireIfNeeded(exception), isFalse);
|
||||
},
|
||||
);
|
||||
|
||||
test('account deletion upgrades an in-flight expiration', () async {
|
||||
SharedPreferences.setMockInitialValues({
|
||||
storageUserId: 'user-1',
|
||||
storageUserPhone: '13800138000',
|
||||
storageJinzhiHoldings: 'legacy-assets',
|
||||
});
|
||||
final preferences = PreferencesStorage(
|
||||
await SharedPreferences.getInstance(),
|
||||
);
|
||||
final secure = _MemorySecureStorage({
|
||||
secureBiometricCredential: 'biometric',
|
||||
});
|
||||
final identity = SecureIdentityStorage(preferences, secureStorage: secure);
|
||||
final session = AuthSessionStorage(identity);
|
||||
final assets = MockAssetRepository(preferences);
|
||||
final accountScope = MockAssetRepository.accountScope(userId: 'user-1');
|
||||
await assets.saveHoldings(accountScope, assets.loadHoldings(accountScope));
|
||||
final coordinator = AuthSessionCoordinator(session, preferences, assets);
|
||||
await coordinator.startSession('token');
|
||||
final notifications = <SessionEndReason>[];
|
||||
coordinator.addListener(notifications.add);
|
||||
|
||||
final expiring = coordinator.end(SessionEndReason.expired);
|
||||
final deleting = coordinator.accountDeleted();
|
||||
await Future.wait([expiring, deleting]);
|
||||
|
||||
expect(notifications, [SessionEndReason.accountDeleted]);
|
||||
expect(await secure.read(secureBiometricCredential), isNull);
|
||||
expect(assets.loadHoldings(accountScope), isEmpty);
|
||||
expect(preferences.containsKey(storageJinzhiHoldings), isFalse);
|
||||
});
|
||||
|
||||
test('a new login waits for an in-flight session end', () async {
|
||||
SharedPreferences.setMockInitialValues({});
|
||||
final preferences = PreferencesStorage(
|
||||
await SharedPreferences.getInstance(),
|
||||
);
|
||||
final identity = SecureIdentityStorage(
|
||||
preferences,
|
||||
secureStorage: _MemorySecureStorage(),
|
||||
);
|
||||
final session = AuthSessionStorage(identity);
|
||||
final coordinator = AuthSessionCoordinator(
|
||||
session,
|
||||
preferences,
|
||||
MockAssetRepository(preferences),
|
||||
);
|
||||
final accountScope = MockAssetRepository.accountScope(userId: 'user-1');
|
||||
await preferences.setString(storageUserId, 'user-1');
|
||||
final assets = MockAssetRepository(preferences);
|
||||
await assets.saveHoldings(accountScope, assets.loadHoldings(accountScope));
|
||||
await coordinator.startSession('old-token');
|
||||
|
||||
final ending = coordinator.logout();
|
||||
final starting = coordinator.startSession('new-token');
|
||||
await Future.wait([ending, starting]);
|
||||
|
||||
expect(session.accessToken, 'new-token');
|
||||
expect(await identity.readJwt(), 'new-token');
|
||||
expect(assets.loadHoldings(accountScope), isNotEmpty);
|
||||
});
|
||||
}
|
||||
|
||||
class _MemorySecureStorage implements SecureKeyValueStorage {
|
||||
_MemorySecureStorage([Map<String, String>? initial]) : values = {...?initial};
|
||||
|
||||
final Map<String, String> values;
|
||||
|
||||
@override
|
||||
Future<void> delete(String key) async => values.remove(key);
|
||||
@override
|
||||
Future<String?> read(String key) async => values[key];
|
||||
@override
|
||||
Future<void> write(String key, String value) async => values[key] = value;
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:jinzhi_flutter_app/features/mall/data/mall_auth_api.dart';
|
||||
|
||||
void main() {
|
||||
test('sends an empty protobuf body when issuing a mall auth token', () async {
|
||||
Object? requestData;
|
||||
String? contentType;
|
||||
final dio = Dio()
|
||||
..interceptors.add(
|
||||
InterceptorsWrapper(
|
||||
onRequest: (options, handler) {
|
||||
requestData = options.data;
|
||||
contentType = options.contentType;
|
||||
handler.resolve(
|
||||
Response<List<int>>(
|
||||
requestOptions: options,
|
||||
statusCode: 200,
|
||||
data: _validResponse(),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
final token = await MallAuthApi(dio).issueAppMallAuthToken();
|
||||
|
||||
expect(requestData, isA<Uint8List>());
|
||||
expect(requestData, isEmpty);
|
||||
expect(contentType, 'application/x-protobuf');
|
||||
expect(token.authToken, 'mall-token');
|
||||
expect(token.expiresIn, 300);
|
||||
expect(token.sourceApp, 'jinzhi');
|
||||
expect(token.scene, 'web_mall');
|
||||
});
|
||||
|
||||
test('rejects an incomplete mall auth token response', () async {
|
||||
final dio = Dio()
|
||||
..interceptors.add(
|
||||
InterceptorsWrapper(
|
||||
onRequest: (options, handler) {
|
||||
handler.resolve(
|
||||
Response<List<int>>(
|
||||
requestOptions: options,
|
||||
statusCode: 200,
|
||||
data: _stringField(1, 'mall-token'),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
await expectLater(
|
||||
MallAuthApi(dio).issueAppMallAuthToken(),
|
||||
throwsFormatException,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
List<int> _validResponse() => <int>[
|
||||
..._stringField(1, 'mall-token'),
|
||||
..._integerField(2, 300),
|
||||
..._stringField(3, 'jinzhi'),
|
||||
..._stringField(4, 'web_mall'),
|
||||
];
|
||||
|
||||
List<int> _stringField(int field, String value) {
|
||||
final bytes = utf8.encode(value);
|
||||
return <int>[
|
||||
..._varint((field << 3) | 2),
|
||||
..._varint(bytes.length),
|
||||
...bytes,
|
||||
];
|
||||
}
|
||||
|
||||
List<int> _integerField(int field, int value) => <int>[
|
||||
..._varint(field << 3),
|
||||
..._varint(value),
|
||||
];
|
||||
|
||||
List<int> _varint(int value) {
|
||||
final bytes = <int>[];
|
||||
while (value > 0x7f) {
|
||||
bytes.add((value & 0x7f) | 0x80);
|
||||
value >>= 7;
|
||||
}
|
||||
bytes.add(value);
|
||||
return bytes;
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:jinzhi_flutter_app/features/mall/application/mall_tab_config.dart';
|
||||
|
||||
void main() {
|
||||
test('preserves Jinzhi attribution while syncing H5 login', () {
|
||||
final arguments = buildEnterWebMallBridgeArguments(
|
||||
config: const MallTabConfig(
|
||||
enabled: true,
|
||||
url: 'https://mall.example.com/',
|
||||
adId: 'latest-ad',
|
||||
trackingId: 'latest-tracking',
|
||||
clickId: 'latest-click',
|
||||
entryRequest: MallEntryRequest.quoteRetailGold,
|
||||
),
|
||||
currentUri: Uri.parse(
|
||||
'https://mall.example.com/?from=jinzhi&source_app=jinzhi'
|
||||
'&source_entry=market_quote&placement=mall_tab&hook_type=quote_cta'
|
||||
'&entry_scene=retail&entry_metal=au'
|
||||
'&tracking_id=tracking&click_id=click-1&ts=123&isNight=0',
|
||||
),
|
||||
logged: true,
|
||||
isDark: true,
|
||||
authToken: 'mall-token',
|
||||
scene: 'web_mall',
|
||||
sourceApp: 'jinzhi',
|
||||
);
|
||||
|
||||
expect(arguments['from'], 'jinzhi');
|
||||
expect(arguments['source_app'], 'jinzhi');
|
||||
expect(arguments['source_entry'], 'market_quote');
|
||||
expect(arguments['hook_type'], 'quote_cta');
|
||||
expect(arguments['entry_scene'], 'retail');
|
||||
expect(arguments['entry_metal'], 'au');
|
||||
expect(arguments['ad_id'], 'latest-ad');
|
||||
expect(arguments['tracking_id'], 'latest-tracking');
|
||||
expect(arguments['click_id'], 'latest-click');
|
||||
expect(arguments['isNight'], '1');
|
||||
expect(arguments['auth_token'], 'mall-token');
|
||||
expect(arguments['authToken'], 'mall-token');
|
||||
expect(arguments['scene'], 'web_mall');
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:jinzhi_flutter_app/common/storage/preferences_storage.dart';
|
||||
import 'package:jinzhi_flutter_app/features/mall/application/mall_entry_promo_store.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
void main() {
|
||||
test('records each first-install mall promotion exposure once', () async {
|
||||
SharedPreferences.setMockInitialValues(<String, Object>{});
|
||||
final store = MallEntryPromoStore(
|
||||
PreferencesStorage(await SharedPreferences.getInstance()),
|
||||
);
|
||||
|
||||
expect(store.hasOpenedMall, isFalse);
|
||||
expect(store.hasSeenHomeBadge, isFalse);
|
||||
|
||||
await store.markMallOpened();
|
||||
await store.markHomeBadgeSeen();
|
||||
|
||||
expect(store.hasOpenedMall, isTrue);
|
||||
expect(store.hasSeenHomeBadge, isTrue);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:jinzhi_flutter_app/features/mall/application/mall_tab_config.dart';
|
||||
import 'package:jinzhi_flutter_app/models/pb/jinzhi/v1/ads.pb.dart' as pb;
|
||||
|
||||
void main() {
|
||||
test('keeps click attribution and reports the full generated event', () async {
|
||||
pb.ReportAdEventRequest? clickEvent;
|
||||
final dio = Dio()
|
||||
..interceptors.add(
|
||||
InterceptorsWrapper(
|
||||
onRequest: (options, handler) {
|
||||
if (options.path == '/api/v1/ads') {
|
||||
handler.resolve(
|
||||
Response<List<int>>(
|
||||
requestOptions: options,
|
||||
statusCode: 200,
|
||||
data: pb.GetAdReply(
|
||||
enabled: true,
|
||||
ad: pb.AdCreative(
|
||||
adId: 'ad-1',
|
||||
h5Url: 'https://mall.example.com',
|
||||
trackingId: 'tracking-1',
|
||||
openMode: pb.AdOpenMode.AD_OPEN_MODE_IN_APP,
|
||||
),
|
||||
).writeToBuffer(),
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (options.path == '/api/v1/ads/events') {
|
||||
final event = pb.ReportAdEventRequest.fromBuffer(
|
||||
options.data as List<int>,
|
||||
);
|
||||
if (event.eventName == 'ad_click') clickEvent = event;
|
||||
handler.resolve(
|
||||
Response<List<int>>(
|
||||
requestOptions: options,
|
||||
statusCode: 200,
|
||||
data: pb.ReportAdEventReply().writeToBuffer(),
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
handler.next(options);
|
||||
},
|
||||
),
|
||||
);
|
||||
final controller = MallTabConfigController(dio, autoRefresh: false);
|
||||
await controller.refresh();
|
||||
|
||||
final clicked = await controller.recordClick(
|
||||
request: MallEntryRequest.quoteRetailGold,
|
||||
);
|
||||
|
||||
expect(clicked.clickId, isNotEmpty);
|
||||
expect(controller.state.clickId, clicked.clickId);
|
||||
expect(clickEvent?.clickId, clicked.clickId);
|
||||
expect(clickEvent?.attrs['source_app'], 'jinzhi');
|
||||
expect(clickEvent?.attrs['source_entry'], 'market_quote');
|
||||
expect(clickEvent?.attrs['hook_type'], 'quote_cta');
|
||||
expect(clickEvent?.attrs['entry_scene'], 'retail');
|
||||
expect(clickEvent?.attrs['entry_metal'], 'au');
|
||||
expect(clickEvent?.attrs['open_mode'], 'in_app');
|
||||
});
|
||||
}
|
||||
Loaded 100 of 108 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user