diff --git a/.gitignore b/.gitignore index 6eedae13..9756035c 100644 --- a/.gitignore +++ b/.gitignore @@ -12,4 +12,6 @@ *.xcbkptlist !/.idea/codeStyles/* !/.idea/inspectionProfiles/* -.kotlin \ No newline at end of file +.kotlin +ios/Pods +ios/KaMPKitiOS.xcworkspace diff --git a/2.0_ohos_test_publish.sh b/2.0_ohos_test_publish.sh new file mode 100755 index 00000000..bf41aee4 --- /dev/null +++ b/2.0_ohos_test_publish.sh @@ -0,0 +1,36 @@ +# 1.记录原始url +ORIGIN_DISTRIBUTION_URL=$(grep "distributionUrl" gradle/wrapper/gradle-wrapper.properties | cut -d "=" -f 2) +echo "origin gradle url: $ORIGIN_DISTRIBUTION_URL" +# 2.切换gradle版本 +NEW_DISTRIBUTION_URL="https\:\/\/services.gradle.org\/distributions\/gradle-8.0-bin.zip" +sed -i.bak "s/distributionUrl=.*$/distributionUrl=$NEW_DISTRIBUTION_URL/" gradle/wrapper/gradle-wrapper.properties +echo "new gradle url: " $(grep "distributionUrl" gradle/wrapper/gradle-wrapper.properties | cut -d "=" -f 2) + +# 3.开始发布 +KUIKLY_AGP_VERSION="7.4.2" KUIKLY_KOTLIN_VERSION="2.0.21-KBA-004" ./gradlew -c settings.2.0.ohos.gradle.kts :core-annotations:publishToMavenLocal --stacktrace +KUIKLY_AGP_VERSION="7.4.2" KUIKLY_KOTLIN_VERSION="2.0.21-KBA-004" ./gradlew -c settings.2.0.ohos.gradle.kts :core:publishToMavenLocal --stacktrace +KUIKLY_AGP_VERSION="7.4.2" KUIKLY_KOTLIN_VERSION="2.0.21-KBA-004" ./gradlew -c settings.2.0.ohos.gradle.kts :core-ksp:publishToMavenLocal --stacktrace +KUIKLY_AGP_VERSION="7.4.2" KUIKLY_KOTLIN_VERSION="2.0.21-KBA-004" ./gradlew -c settings.2.0.ohos.gradle.kts :core-render-android:publishToMavenLocal --stacktrace +KUIKLY_AGP_VERSION="7.4.2" KUIKLY_KOTLIN_VERSION="2.0.21-KBA-004" ./gradlew -c settings.2.0.ohos.gradle.kts :compose:publishToMavenLocal --stacktrace +KUIKLY_AGP_VERSION="7.4.2" KUIKLY_KOTLIN_VERSION="2.0.21-KBA-004" ./gradlew -c settings.2.0.ohos.gradle.kts :demo:linkSharedReleaseSharedOhosArm64 --stacktrace + + +# 4.还原文件 +mv gradle/wrapper/gradle-wrapper.properties.bak gradle/wrapper/gradle-wrapper.properties +mv gradle.properties.bak gradle.properties + + +# 5.拷贝so +echo "Copying artifact files:" +OHOS_RENDER_PROJECT_DIR=./ohosApp + +TARGET_SO_PATH=$PWD/demo/build/bin/ohosArm64/sharedReleaseShared/libshared.so +OHO_SO_PROJECT_PATH=$OHOS_RENDER_PROJECT_DIR/entry/libs/arm64-v8a +cp $TARGET_SO_PATH $OHO_SO_PROJECT_PATH +echo "libshared.so: copied from $TARGET_SO_PATH to ohos demo directory: $OHO_SO_PROJECT_PATH" + +TARGET_SO_HEADER_PATH=$PWD/demo/build/bin/ohosArm64/sharedReleaseShared/libshared_api.h +OHO_SO_HEADER_PATH=$OHOS_RENDER_PROJECT_DIR/entry/src/main/cpp/thirdparty/biz_entry +cp $TARGET_SO_HEADER_PATH $OHO_SO_HEADER_PATH +echo "libshared_api.h: copied from $TARGET_SO_HEADER_PATH to ohos demo directory: $OHO_SO_HEADER_PATH" +echo "Copy ops done!" \ No newline at end of file diff --git a/README.md b/README.md index 4149062f..9c6f45f8 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,10 @@ removed. Whereas KaMP Kit started with the goal of being a minimal sample, we now intend it to be less "getting started" and more "best practice model". Watch this repo and follow [@TouchlabHQ](https://twitter.com/TouchlabHQ) for updates! +### 2026 Update +Supported Ohos +![KaMP Kit Image](docs/ohos.jpg) + ### 2023 Update We updated `KaMPKit` to make sure of Touchlab's new [SKIE](https://skie.touchlab.co/) tool. SKIE allowed use to remove a lot of boilerplate code related to `ViewModel` sharing, and also we can now use Kotlin sealed classes as Swift enums in iOS code. Take a look at our detailed [migration case study](https://touchlabpro.touchlab.dev/touchlab/training/skie-architecture/migrating-kampkit-to-skie) diff --git a/build.2.0.ohos.gradle.kts b/build.2.0.ohos.gradle.kts new file mode 100644 index 00000000..38eea01c --- /dev/null +++ b/build.2.0.ohos.gradle.kts @@ -0,0 +1,20 @@ +plugins { + kotlin("multiplatform") version "2.0.21-KBA-004" apply false + kotlin("plugin.compose") version "2.0.21-KBA-004" apply false + id("com.android.application") version "7.4.2" apply false + id("com.android.library") version "7.4.2" apply false + id("org.jetbrains.compose") version "1.7.3" apply false + id("com.google.devtools.ksp") version "2.0.21-1.0.27" apply false +} + +allprojects { + tasks.withType().configureEach { + jvmTargetValidationMode.set(org.jetbrains.kotlin.gradle.dsl.jvm.JvmTargetValidationMode.WARNING) + } + configurations.all { + resolutionStrategy.dependencySubstitution { + substitute(module("${MavenConfig.GROUP}:core")).using(project(":core")) + substitute(module("${MavenConfig.GROUP}:core-annotations")).using(project(":core-annotations")) + } + } +} \ No newline at end of file diff --git a/build.kampkit.ohos.gradle.kts b/build.kampkit.ohos.gradle.kts new file mode 100644 index 00000000..5f950b19 --- /dev/null +++ b/build.kampkit.ohos.gradle.kts @@ -0,0 +1,7 @@ +/* + * KaMPKit root build for OHOS (KBA Kotlin 2.0.21). + * Minimal — only declares plugin versions; actual config is in shared/build.2.0.ohos.gradle.kts. + */ +plugins { + kotlin("multiplatform") version "2.0.21-KBA-004" apply false +} diff --git a/docs/ohos.jpg b/docs/ohos.jpg new file mode 100644 index 00000000..b3b183e7 Binary files /dev/null and b/docs/ohos.jpg differ diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 6d724fa9..4701e621 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -52,6 +52,7 @@ ktor-client-core = { module = "io.ktor:ktor-client-core", version.ref = "ktor" } ktor-client-ios = { module = "io.ktor:ktor-client-ios", version.ref = "ktor" } ktor-client-logging = { module = "io.ktor:ktor-client-logging", version.ref = "ktor" } ktor-client-okHttp = { module = "io.ktor:ktor-client-okhttp", version.ref = "ktor" } +ktor-client-js = { module = "io.ktor:ktor-client-js", version.ref = "ktor" } ktor-client-serialization = { module = "io.ktor:ktor-serialization-kotlinx-json", version.ref = "ktor" } ktor-client-contentNegotiation = { module = "io.ktor:ktor-client-content-negotiation", version.ref = "ktor" } ktor-client-mock = { module = "io.ktor:ktor-client-mock", version.ref = "ktor" } diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 37f78a6a..37f853b1 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-9.3.1-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-bin.zip networkTimeout=10000 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME diff --git a/ios/KaMPKitiOS.xcodeproj/project.pbxproj b/ios/KaMPKitiOS.xcodeproj/project.pbxproj index be06a8ad..06610e1a 100644 --- a/ios/KaMPKitiOS.xcodeproj/project.pbxproj +++ b/ios/KaMPKitiOS.xcodeproj/project.pbxproj @@ -279,7 +279,7 @@ ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; - shellScript = "# Type a script or drag a script file from your workspace to insert its path.\ncd \"$SRCROOT/..\"\n./gradlew embedAndSignAppleFrameworkForXcode\n"; + shellScript = "if [ \"YES\" = \"$OVERRIDE_KOTLIN_BUILD_IDE_SUPPORTED\" ]; then\n echo \"Skipping Gradle build task invocation due to OVERRIDE_KOTLIN_BUILD_IDE_SUPPORTED environment variable set to \\\"YES\\\"\"\n exit 0\nfi\n# Type a script or drag a script file from your workspace to insert its path.\ncd \"$SRCROOT/..\"\n./gradlew embedAndSignAppleFrameworkForXcode"; }; /* End PBXShellScriptBuildPhase section */ diff --git a/ios/KaMPKitiOS/BreedListScreen.swift b/ios/KaMPKitiOS/BreedListScreen.swift index fce06e06..90e199e2 100644 --- a/ios/KaMPKitiOS/BreedListScreen.swift +++ b/ios/KaMPKitiOS/BreedListScreen.swift @@ -99,41 +99,43 @@ struct BreedRowView: View { Button(action: onTap) { HStack { Text(breed.name) + .foregroundColor(.black) .padding(4.0) Spacer() Image(systemName: (!breed.favorite) ? "heart" : "heart.fill") + .foregroundColor(.black) .padding(4.0) } } } } - -struct BreedListScreen_Previews: PreviewProvider { - static var previews: some View { - Group { - BreedListContent( - state: .Content(breeds: [ - Breed(id: 0, name: "appenzeller", favorite: false), - Breed(id: 1, name: "australian", favorite: true) - ]), - onBreedFavorite: { _ in }, - refresh: {} - ) - BreedListContent( - state: .Initial.shared, - onBreedFavorite: { _ in }, - refresh: {} - ) - BreedListContent( - state: .Empty(), - onBreedFavorite: { _ in }, - refresh: {} - ) - BreedListContent( - state: .Error(error: "Something went wrong!"), - onBreedFavorite: { _ in }, - refresh: {} - ) - } - } -} +// +//struct BreedListScreen_Previews: PreviewProvider { +// static var previews: some View { +// Group { +// BreedListContent( +// state: .Content(breeds: [ +// Breed(id: 0, name: "appenzeller", favorite: false), +// Breed(id: 1, name: "australian", favorite: true) +// ]), +// onBreedFavorite: { _ in }, +// refresh: {} +// ) +// BreedListContent( +// state: .Initial.shared, +// onBreedFavorite: { _ in }, +// refresh: {} +// ) +// BreedListContent( +// state: .Empty(), +// onBreedFavorite: { _ in }, +// refresh: {} +// ) +// BreedListContent( +// state: .Error(error: "Something went wrong!"), +// onBreedFavorite: { _ in }, +// refresh: {} +// ) +// } +// } +//} diff --git a/ohos/.gitignore b/ohos/.gitignore new file mode 100644 index 00000000..8c91c2bb --- /dev/null +++ b/ohos/.gitignore @@ -0,0 +1,14 @@ +/node_modules +/oh_modules +/local.properties +/.idea +entry/build +/.hvigor +.cxx +/.clangd +/.clang-format +/.clang-tidy +**/.test +/.appanalyzer +/entry/libs/arm64-v8a/libshared.so +/entry/src/main/cpp/thirdparty/biz_entry/libshared_api.h \ No newline at end of file diff --git a/ohos/.ohpmrc b/ohos/.ohpmrc new file mode 100644 index 00000000..e69de29b diff --git a/ohos/AppScope/app.json5 b/ohos/AppScope/app.json5 new file mode 100644 index 00000000..0df1e3d9 --- /dev/null +++ b/ohos/AppScope/app.json5 @@ -0,0 +1,10 @@ +{ + "app": { + "bundleName": "com.tencent.kuiklyohosapp", + "vendor": "example", + "versionCode": 1000000, + "versionName": "1.0.0", + "icon": "$media:app_icon", + "label": "$string:app_name" + } +} diff --git a/ohos/AppScope/resources/base/element/string.json b/ohos/AppScope/resources/base/element/string.json new file mode 100644 index 00000000..7810f602 --- /dev/null +++ b/ohos/AppScope/resources/base/element/string.json @@ -0,0 +1,8 @@ +{ + "string": [ + { + "name": "app_name", + "value": "KaMPKit" + } + ] +} diff --git a/ohos/AppScope/resources/base/media/app_icon.png b/ohos/AppScope/resources/base/media/app_icon.png new file mode 100644 index 00000000..a39445dc Binary files /dev/null and b/ohos/AppScope/resources/base/media/app_icon.png differ diff --git a/ohos/build-profile.json5 b/ohos/build-profile.json5 new file mode 100644 index 00000000..de6596e0 --- /dev/null +++ b/ohos/build-profile.json5 @@ -0,0 +1,49 @@ +{ + "app": { + "signingConfigs": [ + { + "name": "default", + "type": "HarmonyOS", + "material": { + "certpath": "/Users/lory/.ohos/config/default_ohos_8NfQlnT5rns4SZCXIqn1LgbZK1UsYfGYI0JnUlXQ5oE=.cer", + "keyAlias": "debugKey", + "keyPassword": "0000001B4E5E8146BFA5436885037192DA650E2D54E5903B168621B8457BE088DBD641BE49AB5AEB289E44", + "profile": "/Users/lory/.ohos/config/default_ohos_8NfQlnT5rns4SZCXIqn1LgbZK1UsYfGYI0JnUlXQ5oE=.p7b", + "signAlg": "SHA256withECDSA", + "storeFile": "/Users/lory/.ohos/config/default_ohos_8NfQlnT5rns4SZCXIqn1LgbZK1UsYfGYI0JnUlXQ5oE=.p12", + "storePassword": "0000001B2CAD1755A9EFE7FEE585B7597E768E05788636AAC286AB2D41548E799D8DC661CEEAFD1A19C00A" + } + } + ], + "products": [ + { + "name": "default", + "signingConfig": "default", + "compatibleSdkVersion": "5.0.0(12)", + "runtimeOS": "HarmonyOS", + } + ], + "buildModeSet": [ + { + "name": "debug", + }, + { + "name": "release" + } + ] + }, + "modules": [ + { + "name": "entry", + "srcPath": "./entry", + "targets": [ + { + "name": "default", + "applyToProducts": [ + "default" + ] + } + ] + } + ] +} \ No newline at end of file diff --git a/ohos/build_libshared.sh b/ohos/build_libshared.sh new file mode 100755 index 00000000..9262f16d --- /dev/null +++ b/ohos/build_libshared.sh @@ -0,0 +1,128 @@ +#!/bin/sh +# 编译鸿蒙 libshared.so(Kotlin/Native OHOS 产物) +# +# 构建变体(默认 debug,支持 DevEco 打断点): +# ./ohos/build_libshared.sh # debug(含调试符号) +# LIBSHARED_VARIANT=release ./ohos/build_libshared.sh # release(优化,去符号) +# +# 优先级: +# 1. KuiklyUI/demo(根目录下的 KuiklyUI 子目录或符号链接,含 settings.2.0.ohos.gradle.kts) +# 2. demo/(根目录下的本地 demo 模块) +# 3. shared/(需已在 build.2.0.ohos.gradle.kts 中配置 ohosArm64 目标) +# +# 运行鸿蒙应用前可由 runOhosApp.sh 自动调用。 + +set -e +REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)" +cd "$REPO_ROOT" + +# Java 17 +export JAVA_HOME="${JAVA_HOME:-$(/usr/libexec/java_home -v 17 2>/dev/null)}" +if [ -z "$JAVA_HOME" ]; then + echo "[build_libshared] 跳过: 需要 Java 17 (JAVA_HOME)" + exit 0 +fi + +OHOS_ENTRY_LIBS="ohos/entry/libs/arm64-v8a" +OHOS_ENTRY_API="ohos/entry/src/main/cpp/thirdparty/biz_entry" +mkdir -p "$OHOS_ENTRY_LIBS" +mkdir -p "$OHOS_ENTRY_API" + +# 构建变体:debug(默认,含调试符号,支持 DevEco 打断点)或 release +LIBSHARED_VARIANT="${LIBSHARED_VARIANT:-debug}" +export KUIKLY_AGP_VERSION="${KUIKLY_AGP_VERSION:-7.4.2}" +export KUIKLY_KOTLIN_VERSION="${KUIKLY_KOTLIN_VERSION:-2.0.21-KBA-010}" + +# 根据变体确定 Gradle 任务名与产物路径中的大小写前缀 +# debug → Debug / debugShared;release → Release / releaseShared +if [ "$LIBSHARED_VARIANT" = "release" ]; then + VARIANT_CAP="Release" + VARIANT_DIR="sharedReleaseShared" + VARIANT_DIR_SHARED="releaseShared" + echo "[build_libshared] 构建 release 版 libshared.so(优化版,已剥离调试符号)" +else + VARIANT_CAP="Debug" + VARIANT_DIR="sharedDebugShared" + VARIANT_DIR_SHARED="debugShared" + echo "[build_libshared] 构建 debug 版 libshared.so(含调试符号,可在 DevEco 打断点)" +fi + +BUILD_DIR="" +BUILD_TASK="" +SO_SOURCE="" +HDR_SOURCE="" +SETTINGS_FILE="settings.2.0.ohos.gradle.kts" + +# 1. KaMPKit 自身 shared 模块(settings.kampkit.ohos.gradle.kts + build.2.0.ohos.gradle.kts) +# 生成包含 KaMPKit 业务逻辑(状态管理)的 libshared.so +if [ -f "settings.kampkit.ohos.gradle.kts" ] && [ -d "shared" ]; then + SETTINGS_FILE="settings.kampkit.ohos.gradle.kts" + BUILD_TASK=":shared:link${VARIANT_CAP}SharedOhosArm64" + SO_SOURCE="shared/build/bin/ohosArm64/${VARIANT_DIR_SHARED}/libshared.so" + HDR_SOURCE="shared/build/bin/ohosArm64/${VARIANT_DIR_SHARED}/libshared_api.h" +# 2. KuiklyUI 子目录(符号链接),从其目录执行(生成 KuiklyUI 框架的 libshared.so) +elif [ -d "KuiklyUI/demo" ] && [ -f "KuiklyUI/settings.2.0.ohos.gradle.kts" ]; then + BUILD_DIR="KuiklyUI" + BUILD_TASK=":demo:linkShared${VARIANT_CAP}SharedOhosArm64" + SO_SOURCE="KuiklyUI/demo/build/bin/ohosArm64/${VARIANT_DIR}/libshared.so" + HDR_SOURCE="KuiklyUI/demo/build/bin/ohosArm64/${VARIANT_DIR}/libshared_api.h" +# 3. 本地 demo 目录 +elif [ -d "demo" ]; then + SETTINGS_FILE="settings.2.0.ohos.gradle.kts" + BUILD_TASK=":demo:linkShared${VARIANT_CAP}SharedOhosArm64" + SO_SOURCE="demo/build/bin/ohosArm64/${VARIANT_DIR}/libshared.so" + HDR_SOURCE="demo/build/bin/ohosArm64/${VARIANT_DIR}/libshared_api.h" +fi + +if [ -z "$BUILD_TASK" ]; then + echo "[build_libshared] 跳过: 未找到可用的编译模块(KuiklyUI/demo、demo 或 shared)" + exit 0 +fi + +echo "[build_libshared] 执行任务: $BUILD_TASK" + +BUILD_OK=0 +if [ -n "$BUILD_DIR" ]; then + # 从子目录(KuiklyUI)执行,使用其自身的 settings + if (cd "$BUILD_DIR" && \ + KUIKLY_AGP_VERSION="$KUIKLY_AGP_VERSION" \ + KUIKLY_KOTLIN_VERSION="$KUIKLY_KOTLIN_VERSION" \ + ./gradlew -c settings.2.0.ohos.gradle.kts "$BUILD_TASK" --no-daemon -q 2>/dev/null); then + BUILD_OK=1 + fi +else + if KUIKLY_AGP_VERSION="$KUIKLY_AGP_VERSION" \ + KUIKLY_KOTLIN_VERSION="$KUIKLY_KOTLIN_VERSION" \ + ./gradlew -c "$SETTINGS_FILE" "$BUILD_TASK" --no-daemon -q 2>/dev/null; then + BUILD_OK=1 + fi +fi + +if [ "$BUILD_OK" != "1" ]; then + echo "[build_libshared] 编译未成功或任务不可用,跳过(将使用 entry 内置 NAPI stub)" + exit 0 +fi + +# 检查产物是否存在 +if [ -n "$SO_SOURCE" ] && [ ! -f "$SO_SOURCE" ]; then + SO_SOURCE="" +fi +if [ -n "$HDR_SOURCE" ] && [ ! -f "$HDR_SOURCE" ]; then + HDR_SOURCE="" +fi + +if [ -f "$SO_SOURCE" ]; then + cp "$SO_SOURCE" "$OHOS_ENTRY_LIBS/libshared.so" + echo "[build_libshared] 已拷贝 libshared.so ($LIBSHARED_VARIANT) -> $OHOS_ENTRY_LIBS" +fi +if [ -f "$HDR_SOURCE" ]; then + cp "$HDR_SOURCE" "$OHOS_ENTRY_API/libshared_api.h" + echo "[build_libshared] 已拷贝 libshared_api.h -> $OHOS_ENTRY_API" + # 删除旧的根目录头文件(若存在会优先于 thirdparty/biz_entry/ 被 CMake 找到,导致 ABI 不匹配) + OLD_HDR="ohos/entry/src/main/cpp/libshared_api.h" + if [ -f "$OLD_HDR" ]; then + rm "$OLD_HDR" + echo "[build_libshared] 已删除过期头文件 $OLD_HDR" + fi +fi +echo "[build_libshared] 完成 ($LIBSHARED_VARIANT)" diff --git a/ohos/copy_header.sh b/ohos/copy_header.sh new file mode 100755 index 00000000..6f31f808 --- /dev/null +++ b/ohos/copy_header.sh @@ -0,0 +1,23 @@ +#!/bin/bash + +src_dir="$PWD/ohos_render/src/main/cpp" +dest_dir="$PWD/include" +zip_file="$PWD/headers.zip" + +find "$src_dir" -type f -name "*.h" | while read -r header_file; do + # 获取源文件的相对路径 + rel_path="${header_file#$src_dir}" + + # 构造目标文件的路径 + dest_file="$dest_dir/$rel_path" + + # 创建目标文件所在的目录 + mkdir -p "$(dirname "$dest_file")" + + # 复制源文件到目标位置 + cp "$header_file" "$dest_file" +done + +mv "$dest_dir" "$src_dir/types" + +#cd "$dest_dir" && zip -r "$zip_file" . && cd - > /dev/null \ No newline at end of file diff --git a/ohos/dependencies/hvigor-4.3.0.tgz b/ohos/dependencies/hvigor-4.3.0.tgz new file mode 100644 index 00000000..4331aad9 Binary files /dev/null and b/ohos/dependencies/hvigor-4.3.0.tgz differ diff --git a/ohos/dependencies/hvigor-ohos-plugin-4.3.0.tgz b/ohos/dependencies/hvigor-ohos-plugin-4.3.0.tgz new file mode 100644 index 00000000..6fb89f74 Binary files /dev/null and b/ohos/dependencies/hvigor-ohos-plugin-4.3.0.tgz differ diff --git a/ohos/entry/.gitignore b/ohos/entry/.gitignore new file mode 100644 index 00000000..e2713a27 --- /dev/null +++ b/ohos/entry/.gitignore @@ -0,0 +1,6 @@ +/node_modules +/oh_modules +/.preview +/build +/.cxx +/.test \ No newline at end of file diff --git a/ohos/entry/build-profile.json5 b/ohos/entry/build-profile.json5 new file mode 100644 index 00000000..f204d186 --- /dev/null +++ b/ohos/entry/build-profile.json5 @@ -0,0 +1,46 @@ +{ + "apiType": "stageMode", + "buildOption": { + "externalNativeOptions": { + "path": "./src/main/cpp/CMakeLists.txt", + "arguments": "", + "cppFlags": "" + } + }, + "buildOptionSet": [ + { + "name": "release", + "arkOptions": { + "obfuscation": { + "ruleOptions": { + "enable": true, + "files": [ + "./obfuscation-rules.txt" + ] + } + } + }, + "nativeLib": {"debugSymbol": {"strip": true}} + }, + { + "name": "debug", + "arkOptions": { + "obfuscation": { + "ruleOptions": { + "enable": false, + "files": [] + } + } + }, + "nativeLib": {"debugSymbol": {"strip": false}} + } + ], + "targets": [ + { + "name": "default" + }, + { + "name": "ohosTest", + } + ] +} \ No newline at end of file diff --git a/ohos/entry/hvigorfile.ts b/ohos/entry/hvigorfile.ts new file mode 100644 index 00000000..b229ea31 --- /dev/null +++ b/ohos/entry/hvigorfile.ts @@ -0,0 +1,98 @@ +import { hapTasks } from '@ohos/hvigor-ohos-plugin'; +import * as fs from 'fs'; +import * as path from 'path'; +import * as https from 'https'; + +export default { + system: hapTasks, /* Built-in plugin of Hvigor. It cannot be modified. */ + plugins: [kuiklyPullOhosProduct(), kuiklyCopyAssets()] /* Custom plugin to extend the functionality of Hvigor. */ +} + +// 用于首次拉取ohos产物 +function kuiklyPullOhosProduct(): HvigorPlugin { + return { + pluginId: 'kuiklyPullOhosProductPlugin', + apply(node: HvigorNode) { + node.registerTask({ + name: 'kuikly_pull_ohos_product', + run: () => { + const soDir = path.join(node.getNodePath(), 'libs', 'arm64-v8a'); + const soFile = path.join(soDir, 'libshared.so'); + const apiDir = path.join(node.getNodePath(), 'src', 'main', 'cpp', 'thirdparty', 'biz_entry'); + const apiFile = path.join(apiDir, 'libshared_api.h'); + const soDownloadUrl = 'https://vfiles.gtimg.cn/wuji_dashboard/wupload/xy/starter/d88d0cf7.so'; + const apiDownloadUrl = 'https://vfiles.gtimg.cn/wuji_dashboard/wupload/xy/starter/3f86ae77.h'; + try { + const shouldDownload = !fs.existsSync(soFile) || !fs.existsSync(apiFile); + if (!shouldDownload) { + return; + } + console.log('KaMPKit entry: 无 libshared.so,使用内置 BreedList NAPI stub'); + return; + } catch (err) { + console.warn('Pull skip:', err); + } + }, + postDependencies: ['default@PreBuild'] + }) + } + } +} + + +function ensureFileExists(dir: String, file: String, url: String) { + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }); + console.log(`Created directory: ${dir}`); + } + return downloadFile(url, file); +} + +function downloadFile(url: string, dest: string): Promise { + return new Promise((resolve, reject) => { + const file = fs.createWriteStream(dest); + https.get(url, (response) => { + if (response.statusCode !== 200) { + fs.unlinkSync(dest); + return reject(new Error(`HTTP ${response.statusCode}`)); + } + + response.pipe(file); + file.on('finish', () => { + file.close((err) => err ? reject(err) : resolve()); + }); + }).on('error', (err) => { + fs.unlinkSync(dest); + reject(err); + }); + }); +} + +// 编译时copy assets +function kuiklyCopyAssets(): HvigorPlugin { + return { + pluginId: 'kuiklyCopyAssetsPlugin', + apply(node: HvigorNode) { + node.registerTask({ + name: 'kuikly_copy_assets', + run: (taskContext) => { + const sourceDir = path.join(node.getNodePath(), + '..', '..', 'demo', 'src', 'commonMain', 'assets'); + if (!fs.existsSync(sourceDir)) { + return; // 最小可运行无 demo assets 时跳过 + } + console.log('kuikly copy assets start'); + const destDir = path.join(node.getNodePath(), + 'build', 'default', 'intermediates', 'res', 'default', 'resources', 'resfile'); + if (!fs.existsSync(destDir)) { + fs.mkdirSync(destDir, { recursive: true }); + } + fs.cpSync(sourceDir, destDir, { recursive: true, force: true }); + console.log('kuikly copy assets finish'); + }, + dependencies: [`default@CompileResource`], + postDependencies: [`default@CompileArkTS`] + }) + } + } +} \ No newline at end of file diff --git a/ohos/entry/obfuscation-rules.txt b/ohos/entry/obfuscation-rules.txt new file mode 100644 index 00000000..a1dfa0bd --- /dev/null +++ b/ohos/entry/obfuscation-rules.txt @@ -0,0 +1,22 @@ +# Define project specific obfuscation rules here. +# You can include the obfuscation configuration files in the current module's build-profile.json5. +# +# For more details, see +# https://gitee.com/openharmony/arkcompiler_ets_frontend/blob/master/arkguard/README.md + +# Obfuscation options: +# -disable-obfuscation: disable all obfuscations +# -enable-property-obfuscation: obfuscate the property names +# -enable-toplevel-obfuscation: obfuscate the names in the global scope +# -compact: remove unnecessary blank spaces and all line feeds +# -remove-log: remove all console.* statements +# -print-namecache: print the name cache that contains the mapping from the old names to new names +# -apply-namecache: reuse the given cache file + +# Keep options: +# -keep-property-name: specifies property names that you want to keep +# -keep-global-name: specifies names that you want to keep in the global scope +-enable-property-obfuscation +-enable-toplevel-obfuscation +-enable-filename-obfuscation +-enable-export-obfuscation \ No newline at end of file diff --git a/ohos/entry/oh-package-lock.json5 b/ohos/entry/oh-package-lock.json5 new file mode 100644 index 00000000..8b398fae --- /dev/null +++ b/ohos/entry/oh-package-lock.json5 @@ -0,0 +1,19 @@ +{ + "meta": { + "stableOrder": true, + "enableUnifiedLockfile": false + }, + "lockfileVersion": 3, + "ATTENTION": "THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.", + "specifiers": { + "libkuikly_entry.so@src/main/cpp/types/kuikly_entry": "libkuikly_entry.so@src/main/cpp/types/kuikly_entry" + }, + "packages": { + "libkuikly_entry.so@src/main/cpp/types/kuikly_entry": { + "name": "libkuikly_entry.so", + "version": "1.0.0", + "resolved": "src/main/cpp/types/kuikly_entry", + "registryType": "local" + } + } +} \ No newline at end of file diff --git a/ohos/entry/oh-package.json5 b/ohos/entry/oh-package.json5 new file mode 100644 index 00000000..e801338c --- /dev/null +++ b/ohos/entry/oh-package.json5 @@ -0,0 +1,11 @@ +{ + "name": "entry", + "version": "1.0.0", + "description": "KaMPKit OHOS - BreedListPage", + "main": "", + "author": "", + "license": "", + "dependencies": { + "libkuikly_entry.so": "file:./src/main/cpp/types/kuikly_entry" + } +} diff --git a/ohos/entry/src/main/cpp/CMakeLists.txt b/ohos/entry/src/main/cpp/CMakeLists.txt new file mode 100644 index 00000000..629da6f5 --- /dev/null +++ b/ohos/entry/src/main/cpp/CMakeLists.txt @@ -0,0 +1,26 @@ +cmake_minimum_required(VERSION 3.5) +project(kuikly_entry) + +set(CMAKE_CXX_STANDARD 17) +if(DEFINED PACKAGE_FIND_FILE) + include(${PACKAGE_FIND_FILE}) +endif() + +add_library(kuikly_entry SHARED napi_init.cpp) +target_include_directories(kuikly_entry PRIVATE + ${CMAKE_CURRENT_SOURCE_DIR} + ${CMAKE_CURRENT_SOURCE_DIR}/thirdparty/biz_entry +) + +# Link libshared.so when present (Kotlin/Native ohosArm64 output) +set(LIBSHARED_DIR "${CMAKE_CURRENT_SOURCE_DIR}/../../../libs/arm64-v8a") +if(EXISTS "${LIBSHARED_DIR}/libshared.so") + target_link_libraries(kuikly_entry PUBLIC "${LIBSHARED_DIR}/libshared.so") + target_compile_definitions(kuikly_entry PRIVATE KAMPKIT_USE_LIBSHARED=1) +endif() + +target_link_libraries(kuikly_entry PUBLIC + libace_napi.z.so + libace_ndk.z.so + hilog_ndk.z.so +) diff --git a/ohos/entry/src/main/cpp/libs/arm64-v8a/libshared.so b/ohos/entry/src/main/cpp/libs/arm64-v8a/libshared.so new file mode 100755 index 00000000..6be6ef9d Binary files /dev/null and b/ohos/entry/src/main/cpp/libs/arm64-v8a/libshared.so differ diff --git a/ohos/entry/src/main/cpp/napi_init.cpp b/ohos/entry/src/main/cpp/napi_init.cpp new file mode 100644 index 00000000..716e0eb8 --- /dev/null +++ b/ohos/entry/src/main/cpp/napi_init.cpp @@ -0,0 +1,141 @@ +/* + * KaMPKit OHOS NAPI — network-layer bridge (no business logic). + * + * Exposes four generic network/event functions: + * httpResponse(body) HTTP success body (ArkTS → Kotlin) + * httpError(json) HTTP failure info (ArkTS → Kotlin) + * action(type, payload) Lifecycle/user action (ArkTS → Kotlin) + * subscribeEvents(callback) Event stream registration (Kotlin → ArkTS) + * + * All payloads are plain JSON strings. Business logic lives in Kotlin only. + */ +#include "napi/native_api.h" +#include + +#ifdef KAMPKIT_USE_LIBSHARED +#include "libshared_api.h" + +// Called from the JS thread (synchronously inside an active NAPI call), +// so g_env is always valid and we can call the JS callback directly. +static napi_env g_env = nullptr; +static napi_ref g_callback_ref = nullptr; + +// Kotlin/Native exports subscribeEvents(void* callback); use a named static function +// so we can reinterpret_cast it to void* — lambdas cannot convert to void* directly. +static void OnKotlinEvent(char* eventJson) { + if (!g_env || !g_callback_ref || !eventJson) return; + napi_value js_cb = nullptr; + napi_get_reference_value(g_env, g_callback_ref, &js_cb); + if (!js_cb) return; + napi_value arg; + napi_create_string_utf8(g_env, eventJson, NAPI_AUTO_LENGTH, &arg); + napi_value global, result; + napi_get_global(g_env, &global); + napi_call_function(g_env, global, js_cb, 1, &arg, &result); +} +#endif + +static std::string GetStringArg(napi_env env, napi_value val) { + size_t len = 0; + napi_get_value_string_utf8(env, val, nullptr, 0, &len); + std::string s(len + 1, '\0'); + napi_get_value_string_utf8(env, val, s.data(), len + 1, &len); + s.resize(len); + return s; +} + +// ── httpResponse(body: string) ──────────────────────────────────────────────── +static napi_value HttpResponse(napi_env env, napi_callback_info info) { +#ifdef KAMPKIT_USE_LIBSHARED + size_t argc = 1; napi_value args[1]; + napi_get_cb_info(env, info, &argc, args, nullptr, nullptr); + if (argc < 1) return nullptr; + auto body = GetStringArg(env, args[0]); + auto* sym = libshared_symbols(); + if (sym && sym->kotlin.root.httpResponse) + sym->kotlin.root.httpResponse(body.data()); +#else + (void)env; (void)info; +#endif + return nullptr; +} + +// ── httpError(json: string) ─────────────────────────────────────────────────── +static napi_value HttpError(napi_env env, napi_callback_info info) { +#ifdef KAMPKIT_USE_LIBSHARED + size_t argc = 1; napi_value args[1]; + napi_get_cb_info(env, info, &argc, args, nullptr, nullptr); + if (argc < 1) return nullptr; + auto json = GetStringArg(env, args[0]); + auto* sym = libshared_symbols(); + if (sym && sym->kotlin.root.httpError) + sym->kotlin.root.httpError(json.data()); +#else + (void)env; (void)info; +#endif + return nullptr; +} + +// ── action(type: string, payload: string) ───────────────────────────────────── +static napi_value Action(napi_env env, napi_callback_info info) { +#ifdef KAMPKIT_USE_LIBSHARED + size_t argc = 2; napi_value args[2]; + napi_get_cb_info(env, info, &argc, args, nullptr, nullptr); + if (argc < 2) return nullptr; + auto type = GetStringArg(env, args[0]); + auto payload = GetStringArg(env, args[1]); + auto* sym = libshared_symbols(); + if (sym && sym->kotlin.root.action) + sym->kotlin.root.action(type.data(), payload.data()); +#else + (void)env; (void)info; +#endif + return nullptr; +} + +// ── subscribeEvents(callback: (eventJson: string) => void) ─────────────────── +static napi_value SubscribeEvents(napi_env env, napi_callback_info info) { +#ifdef KAMPKIT_USE_LIBSHARED + size_t argc = 1; napi_value args[1]; + napi_get_cb_info(env, info, &argc, args, nullptr, nullptr); + if (argc < 1) return nullptr; + + // Release previous reference if re-subscribing + if (g_callback_ref) { napi_delete_reference(env, g_callback_ref); g_callback_ref = nullptr; } + + g_env = env; + napi_create_reference(env, args[0], 1, &g_callback_ref); + + auto* sym = libshared_symbols(); + if (sym && sym->kotlin.root.subscribeEvents) { + sym->kotlin.root.subscribeEvents(reinterpret_cast(OnKotlinEvent)); + } +#else + (void)env; (void)info; +#endif + return nullptr; +} + +// ── module init ─────────────────────────────────────────────────────────────── +EXTERN_C_START +static napi_value Init(napi_env env, napi_value exports) { + napi_property_descriptor desc[] = { + {"httpResponse", nullptr, HttpResponse, nullptr, nullptr, nullptr, napi_default, nullptr}, + {"httpError", nullptr, HttpError, nullptr, nullptr, nullptr, napi_default, nullptr}, + {"action", nullptr, Action, nullptr, nullptr, nullptr, napi_default, nullptr}, + {"subscribeEvents",nullptr, SubscribeEvents, nullptr, nullptr, nullptr, napi_default, nullptr}, + }; + napi_define_properties(env, exports, sizeof(desc) / sizeof(desc[0]), desc); + return exports; +} +EXTERN_C_END + +static napi_module entry_module = { + .nm_version = 1, .nm_flags = 0, .nm_filename = nullptr, + .nm_register_func = Init, .nm_modname = "kuikly_entry", + .nm_priv = nullptr, .reserved = {0}, +}; + +extern "C" __attribute__((constructor)) void RegisterKuikly_EntryModule(void) { + napi_module_register(&entry_module); +} diff --git a/ohos/entry/src/main/cpp/types/kuikly_entry/index.d.ts b/ohos/entry/src/main/cpp/types/kuikly_entry/index.d.ts new file mode 100644 index 00000000..3c7f2dbc --- /dev/null +++ b/ohos/entry/src/main/cpp/types/kuikly_entry/index.d.ts @@ -0,0 +1,12 @@ +/** + * KaMPKit OHOS — network-layer bridge to Kotlin/Native (libshared.so). + * + * httpResponse(body) Raw HTTP response body → Kotlin business logic + * httpError(json) {"error":"..."} → Kotlin business logic + * action(type, payload) Lifecycle/user action → Kotlin (type is app-defined, payload is JSON) + * subscribeEvents(callback) Kotlin pushes JSON events → ArkTS e.g. {"type":"state","payload":{...}} + */ +export function httpResponse(body: string): void; +export function httpError(errorJson: string): void; +export function action(type: string, payload: string): void; +export function subscribeEvents(callback: (eventJson: string) => void): void; diff --git a/ohos/entry/src/main/cpp/types/kuikly_entry/oh-package.json5 b/ohos/entry/src/main/cpp/types/kuikly_entry/oh-package.json5 new file mode 100644 index 00000000..b914e8f9 --- /dev/null +++ b/ohos/entry/src/main/cpp/types/kuikly_entry/oh-package.json5 @@ -0,0 +1,6 @@ +{ + "name": "libkuikly_entry.so", + "types": "./index.d.ts", + "version": "1.0.0", + "description": "Please describe the basic information." +} \ No newline at end of file diff --git a/ohos/entry/src/main/ets/data/DogApiDataSource.ets b/ohos/entry/src/main/ets/data/DogApiDataSource.ets new file mode 100644 index 00000000..5bba7e50 --- /dev/null +++ b/ohos/entry/src/main/ets/data/DogApiDataSource.ets @@ -0,0 +1,98 @@ +/* + * KaMPKit OHOS — ArkTS data source. + * + * HTTP execution: ArkTS (@kit.NetworkKit), like KuiklyUI's KRNetworkModule.ets + * Business logic: Kotlin/Native (libshared.so), via generic JSON bridge + * + * Bridge contract: + * callNative(method, paramsJson) ArkTS → Kotlin + * subscribeNativeEvent(callback) Kotlin → ArkTS (state events) + */ +import http from '@ohos.net.http'; +import nativeModule from 'libkuikly_entry.so'; + +export interface Breed { + id: number; + name: string; + favorite: boolean; +} + +export interface BreedViewStateInitial { kind: 'Initial'; isLoading: boolean; } +export interface BreedViewStateEmpty { kind: 'Empty'; isLoading: boolean; } +export interface BreedViewStateContent { kind: 'Content'; breeds: Breed[]; isLoading: boolean; } +export interface BreedViewStateError { kind: 'Error'; error: string; isLoading: boolean; } +export type BreedViewState = + | BreedViewStateInitial + | BreedViewStateEmpty + | BreedViewStateContent + | BreedViewStateError; + +const DOG_API_URL = 'https://dog.ceo/api/breeds/list/all'; + +// TODO: parseEvent() deserializes JSON that Kotlin just serialized in emitState() — unnecessary +// round-trip. Future improvement: use napi_create_object in C++ to pass a typed JS object +// directly from Kotlin/Native, eliminating the JSON encoding/decoding overhead entirely. +function parseEvent(eventJson: string): BreedViewState | null { + try { + const event = JSON.parse(eventJson) as Record; + if (event['type'] !== 'state') return null; + const p = event['payload'] as Record; + const kind = p['kind'] as string; + const isLoading = (p['isLoading'] as boolean) ?? false; + if (kind === 'Initial') return { kind: 'Initial', isLoading } as BreedViewStateInitial; + if (kind === 'Empty') return { kind: 'Empty', isLoading } as BreedViewStateEmpty; + if (kind === 'Error') return { kind: 'Error', error: (p['error'] as string) ?? '', isLoading } as BreedViewStateError; + if (kind === 'Content') return { kind: 'Content', breeds: p['breeds'] as Breed[], isLoading } as BreedViewStateContent; + } catch (_) {} + return null; +} + +/** Execute HTTP, pass raw body to Kotlin — no intermediate parse/re-serialize. */ +function fetchBreeds(onSuccess: (body: string) => void, onError: (msg: string) => void): void { + const req = http.createHttp(); + req.request(DOG_API_URL, { + method: http.RequestMethod.GET, + connectTimeout: 30000, + readTimeout: 30000, + }, (err, data) => { + req.destroy(); + if (err) { onError(err.message ?? 'HTTP error'); return; } + if (data.responseCode !== 200) { onError(`HTTP ${data.responseCode}`); return; } + const raw = data.result; + onSuccess(typeof raw === 'string' ? raw : JSON.stringify(raw)); + }); +} + +export class DogApiDataSource { + private stateCallback: (state: BreedViewState) => void = () => {}; + + setStateCallback(callback: (state: BreedViewState) => void): void { + this.stateCallback = callback; + nativeModule.subscribeEvents((eventJson: string) => { + const state = parseEvent(eventJson); + if (state) this.stateCallback(state); + }); + } + + load(): void { + nativeModule.action('init', '{}'); + fetchBreeds( + (body) => nativeModule.httpResponse(body), + (msg) => nativeModule.httpError(JSON.stringify({ error: msg })), + ); + } + + refresh(): void { + nativeModule.action('refresh', '{}'); + fetchBreeds( + (body) => nativeModule.httpResponse(body), + (msg) => nativeModule.httpError(JSON.stringify({ error: msg })), + ); + } + + updateFavorite(id: number, name: string, favorite: boolean): void { + nativeModule.action('updateFavorite', JSON.stringify({ id, name, favorite })); + } +} + +export const dogApiDataSource = new DogApiDataSource(); diff --git a/ohos/entry/src/main/ets/entryability/EntryAbility.ets b/ohos/entry/src/main/ets/entryability/EntryAbility.ets new file mode 100644 index 00000000..8ac182c1 --- /dev/null +++ b/ohos/entry/src/main/ets/entryability/EntryAbility.ets @@ -0,0 +1,42 @@ +/* + * KaMPKit OHOS 最小可运行:仅加载 BreedListPage,不依赖 Kuikly/Napi。 + */ + +import { AbilityConstant, UIAbility, Want } from '@kit.AbilityKit'; +import { hilog } from '@kit.PerformanceAnalysisKit'; +import { window } from '@kit.ArkUI'; + +export default class EntryAbility extends UIAbility { + onCreate(want: Want, launchParam: AbilityConstant.LaunchParam): void { + hilog.info(0x0000, 'KaMPKit', '%{public}s', 'Ability onCreate'); + } + + onDestroy(): void { + hilog.info(0x0000, 'KaMPKit', '%{public}s', 'Ability onDestroy'); + } + + onWindowStageCreate(windowStage: window.WindowStage): void { + hilog.info(0x0000, 'KaMPKit', '%{public}s', 'Ability onWindowStageCreate'); + windowStage.loadContent('pages/BreedListPage', (err) => { + if (err.code) { + hilog.error(0x0000, 'KaMPKit', 'Failed to load content. Cause: %{public}s', JSON.stringify(err) ?? ''); + return; + } + hilog.info(0x0000, 'KaMPKit', 'Succeeded in loading BreedListPage.'); + }); + const mainWindow = windowStage.getMainWindowSync(); + mainWindow.setWindowLayoutFullScreen(true); + } + + onWindowStageDestroy(): void { + hilog.info(0x0000, 'KaMPKit', '%{public}s', 'Ability onWindowStageDestroy'); + } + + onForeground(): void { + hilog.info(0x0000, 'KaMPKit', '%{public}s', 'Ability onForeground'); + } + + onBackground(): void { + hilog.info(0x0000, 'KaMPKit', '%{public}s', 'Ability onBackground'); + } +} diff --git a/ohos/entry/src/main/ets/pages/BreedListPage.ets b/ohos/entry/src/main/ets/pages/BreedListPage.ets new file mode 100644 index 00000000..60ebb6ef --- /dev/null +++ b/ohos/entry/src/main/ets/pages/BreedListPage.ets @@ -0,0 +1,70 @@ +import { dogApiDataSource, Breed, BreedViewState, BreedViewStateInitial } from '../data/DogApiDataSource'; + +@Entry +@Component +struct BreedListPage { + @State viewState: BreedViewState = { kind: 'Initial', isLoading: true } as BreedViewStateInitial; + + aboutToAppear(): void { + dogApiDataSource.setStateCallback((state: BreedViewState) => { + this.viewState = state; + }); + dogApiDataSource.load(); + } + + build() { + Column() { + if (this.viewState.isLoading) { + Text('Loading...') + .fontSize(14) + .margin({ top: 12 }) + .align(Alignment.Center); + } + + if (this.viewState.kind === 'Empty') { + Text('Sorry, no doggos found') + .fontSize(16) + .margin({ top: 48 }); + } else if (this.viewState.kind === 'Error') { + Text(this.viewState.error) + .fontSize(16) + .fontColor(Color.Black) + .margin({ top: 48 }); + } else if (this.viewState.kind === 'Content') { + List() { + ForEach(this.viewState.breeds, (breed: Breed) => { + ListItem() { + Row() { + Text(breed.name) + .fontSize(16) + .layoutWeight(1) + .padding({ left: 12, right: 8, top: 10, bottom: 10 }); + if (breed.favorite) { + Image($r('app.media.heart_fill')) + .width(32) + .height(32) + } else { + Image($r('app.media.heart')) + .width(32) + .height(32) + } + } + .padding({ left: 8, right: 12 }) + .width('100%') + } + .onClick(() => dogApiDataSource.updateFavorite(breed.id, breed.name, !breed.favorite)); + }, (breed: Breed) => `${breed.id}_${breed.favorite}`); + } + .layoutWeight(1) + .divider({ strokeWidth: 1, color: '#eee' }) + .margin(30) + } + + Button('Refresh') + .margin(16) + .onClick(() => dogApiDataSource.refresh()); + } + .width('100%') + .height('100%'); + } +} diff --git a/ohos/entry/src/main/module.json5 b/ohos/entry/src/main/module.json5 new file mode 100644 index 00000000..4430f0b2 --- /dev/null +++ b/ohos/entry/src/main/module.json5 @@ -0,0 +1,41 @@ +{ + "module": { + "name": "entry", + "type": "entry", + "description": "$string:module_desc", + "mainElement": "EntryAbility", + "deviceTypes": [ + "phone" + ], + "requestPermissions": [ + { + "name": "ohos.permission.INTERNET" + } + ], + "deliveryWithInstall": true, + "installationFree": false, + "pages": "$profile:main_pages", + "abilities": [ + { + "name": "EntryAbility", + "srcEntry": "./ets/entryability/EntryAbility.ets", + "description": "$string:EntryAbility_desc", + "icon": "$media:layered_image", + "label": "$string:EntryAbility_label", + "startWindowIcon": "$media:startIcon", + "startWindowBackground": "$color:start_window_background", + "exported": true, + "skills": [ + { + "entities": [ + "entity.system.home" + ], + "actions": [ + "action.system.home" + ] + } + ] + } + ] + } +} \ No newline at end of file diff --git a/ohos/entry/src/main/resources/base/element/color.json b/ohos/entry/src/main/resources/base/element/color.json new file mode 100644 index 00000000..3c712962 --- /dev/null +++ b/ohos/entry/src/main/resources/base/element/color.json @@ -0,0 +1,8 @@ +{ + "color": [ + { + "name": "start_window_background", + "value": "#FFFFFF" + } + ] +} \ No newline at end of file diff --git a/ohos/entry/src/main/resources/base/element/string.json b/ohos/entry/src/main/resources/base/element/string.json new file mode 100644 index 00000000..f9459551 --- /dev/null +++ b/ohos/entry/src/main/resources/base/element/string.json @@ -0,0 +1,16 @@ +{ + "string": [ + { + "name": "module_desc", + "value": "module description" + }, + { + "name": "EntryAbility_desc", + "value": "description" + }, + { + "name": "EntryAbility_label", + "value": "label" + } + ] +} \ No newline at end of file diff --git a/ohos/entry/src/main/resources/base/media/PicResource.ets b/ohos/entry/src/main/resources/base/media/PicResource.ets new file mode 100644 index 00000000..9983d817 --- /dev/null +++ b/ohos/entry/src/main/resources/base/media/PicResource.ets @@ -0,0 +1,52 @@ +export enum PicResource{ + TABBAR_HOME_HIGHTLIGHTED = "https://vfiles.gtimg.cn/wuji_dashboard/xy/componenthub/DKgngIvN.png", + TABBAR_VIDEO_HIGHTLIGHTED = "https://vfiles.gtimg.cn/wuji_dashboard/xy/componenthub/W61mecMV.png", + TABBAR_DISCOVER_HIGHTLIGHTED = "https://vfiles.gtimg.cn/wuji_dashboard/xy/componenthub/qKMr2sUG.png", + TABBAR_MESSAGE_CENTER_HIGHTLIGHTED = "https://vfiles.gtimg.cn/wuji_dashboard/xy/componenthub/eiUhJB2f.png", + TABBAR_PORFILE_HIGHTLIGHTED = "https://vfiles.gtimg.cn/wuji_dashboard/xy/componenthub/t17Z5CfM.png", + + TABBAR_HOME = "https://vfiles.gtimg.cn/wuji_dashboard/xy/componenthub/j7kA2RYg.png", + TABBAR_VIDEO = "https://vfiles.gtimg.cn/wuji_dashboard/xy/componenthub/hFKlUTVm.png", + TABBAR_DISCOVER = "https://vfiles.gtimg.cn/wuji_dashboard/xy/componenthub/birIlV1o.png", + TABBER_MESSAGE_CENTER = "https://vfiles.gtimg.cn/wuji_dashboard/xy/componenthub/ovqyQcQc.png", + TABBER_PROFILE = "https://vfiles.gtimg.cn/wuji_dashboard/xy/componenthub/6bHALG2t.png", + + HOME_MEMBER = "https://vfiles.gtimg.cn/wuji_dashboard/xy/componenthub/g0Bh3yn2.webp", + + IC_HOME_LIKE = "https://vfiles.gtimg.cn/wuji_dashboard/xy/componenthub/fYSsbfQN.webp", + IC_HOME_COMMENT = "https://vfiles.gtimg.cn/wuji_dashboard/xy/componenthub/NK0cJ7X3.webp", + IC_HOME_REWEET = "https://vfiles.gtimg.cn/wuji_dashboard/xy/componenthub/dyrkE1G6.png", + + AVATAR_1 = "https://vfiles.gtimg.cn/wuji_dashboard/xy/componenthub/a8k9zorT.webp", + AVATAR_2 = "https://vfiles.gtimg.cn/wuji_dashboard/xy/componenthub/AfL9LrMl.webp", + AVATAR_3 = "https://vfiles.gtimg.cn/wuji_dashboard/xy/componenthub/rDLDqUcV.webp", + AVATAR_4 = "https://vfiles.gtimg.cn/wuji_dashboard/xy/componenthub/lNgP0VyF.webp", + + PIC_1_1 = "https://vfiles.gtimg.cn/wuji_dashboard/xy/componenthub/onGXkfvA.webp", + PIC_1_2 = "https://vfiles.gtimg.cn/wuji_dashboard/xy/componenthub/VAPLhl4r.webp", + PIC_1_3 = "https://vfiles.gtimg.cn/wuji_dashboard/xy/componenthub/Re3lkLgO.webp", + PIC_1_4 = "https://vfiles.gtimg.cn/wuji_dashboard/xy/componenthub/vQjdREQJ.webp", + PIC_1_5 = "https://vfiles.gtimg.cn/wuji_dashboard/xy/componenthub/lua8gT9i.webp", + PIC_1_6 = "https://vfiles.gtimg.cn/wuji_dashboard/xy/componenthub/VYzWgpQU.webp", + PIC_2_1 = "https://vfiles.gtimg.cn/wuji_dashboard/xy/componenthub/V7erBybD.webp", + PIC_2_2 ="https://vfiles.gtimg.cn/wuji_dashboard/xy/componenthub/E82uaeqg.webp", + PIC_2_3 = "https://vfiles.gtimg.cn/wuji_dashboard/xy/componenthub/OzHKXxmS.webp", + PIC_2_4 = "https://vfiles.gtimg.cn/wuji_dashboard/xy/componenthub/2fDoC5cT.webp", + PIC_2_5 = "https://vfiles.gtimg.cn/wuji_dashboard/xy/componenthub/Lc9n38ci.webp", + PIC_2_6 = "https://vfiles.gtimg.cn/wuji_dashboard/xy/componenthub/39rBAbR1.webp", + PIC_2_7 = "https://vfiles.gtimg.cn/wuji_dashboard/xy/componenthub/FKyxoBic.webp", + PIC_2_8 = "https://vfiles.gtimg.cn/wuji_dashboard/xy/componenthub/30u9KgHg.webp", + PIC_2_9 = "https://vfiles.gtimg.cn/wuji_dashboard/xy/componenthub/ZL8L1HGx.webp", + PIC_3_1 = "https://vfiles.gtimg.cn/wuji_dashboard/xy/componenthub/LrSTJThz.webp", + PIC_3_2 = "https://vfiles.gtimg.cn/wuji_dashboard/xy/componenthub/ZlwDPyVh.webp", + PIC_3_3 = "https://vfiles.gtimg.cn/wuji_dashboard/xy/componenthub/8IWgv8GB.webp", + PIC_4_1 = "https://vfiles.gtimg.cn/wuji_dashboard/xy/componenthub/XILOzZyj.webp", + PIC_4_2 = "https://vfiles.gtimg.cn/wuji_dashboard/xy/componenthub/3kVO0E9n.webp", + PIC_4_3 = "https://vfiles.gtimg.cn/wuji_dashboard/xy/componenthub/1BAXAUMd.webp", + PIC_4_4 = "https://vfiles.gtimg.cn/wuji_dashboard/xy/componenthub/yn0Z8rHm.webp", + PIC_4_5 = "https://vfiles.gtimg.cn/wuji_dashboard/xy/componenthub/UoF9Tgu4.webp", + PIC_4_6 = "https://vfiles.gtimg.cn/wuji_dashboard/xy/componenthub/kWNDzNkS.webp", + PIC_4_7 = "https://vfiles.gtimg.cn/wuji_dashboard/xy/componenthub/JKeWr6O7.webp", + PIC_4_8 = "https://vfiles.gtimg.cn/wuji_dashboard/xy/componenthub/WVXWMMTk.webp", + PIC_4_9 = "https://vfiles.gtimg.cn/wuji_dashboard/xy/componenthub/FPOmkElZ.webp" +} \ No newline at end of file diff --git a/ohos/entry/src/main/resources/base/media/background.png b/ohos/entry/src/main/resources/base/media/background.png new file mode 100644 index 00000000..f939c9fa Binary files /dev/null and b/ohos/entry/src/main/resources/base/media/background.png differ diff --git a/ohos/entry/src/main/resources/base/media/foreground.png b/ohos/entry/src/main/resources/base/media/foreground.png new file mode 100644 index 00000000..4483ddad Binary files /dev/null and b/ohos/entry/src/main/resources/base/media/foreground.png differ diff --git a/ohos/entry/src/main/resources/base/media/heart.png b/ohos/entry/src/main/resources/base/media/heart.png new file mode 100644 index 00000000..fb1b6af4 Binary files /dev/null and b/ohos/entry/src/main/resources/base/media/heart.png differ diff --git a/ohos/entry/src/main/resources/base/media/heart_fill.png b/ohos/entry/src/main/resources/base/media/heart_fill.png new file mode 100644 index 00000000..a449aaee Binary files /dev/null and b/ohos/entry/src/main/resources/base/media/heart_fill.png differ diff --git a/ohos/entry/src/main/resources/base/media/icon.png b/ohos/entry/src/main/resources/base/media/icon.png new file mode 100644 index 00000000..ce307a88 Binary files /dev/null and b/ohos/entry/src/main/resources/base/media/icon.png differ diff --git a/ohos/entry/src/main/resources/base/media/layered_image.json b/ohos/entry/src/main/resources/base/media/layered_image.json new file mode 100644 index 00000000..fb499204 --- /dev/null +++ b/ohos/entry/src/main/resources/base/media/layered_image.json @@ -0,0 +1,7 @@ +{ + "layered-image": + { + "background" : "$media:background", + "foreground" : "$media:foreground" + } +} \ No newline at end of file diff --git a/ohos/entry/src/main/resources/base/media/startIcon.png b/ohos/entry/src/main/resources/base/media/startIcon.png new file mode 100644 index 00000000..205ad8b5 Binary files /dev/null and b/ohos/entry/src/main/resources/base/media/startIcon.png differ diff --git a/ohos/entry/src/main/resources/base/profile/main_pages.json b/ohos/entry/src/main/resources/base/profile/main_pages.json new file mode 100644 index 00000000..fad02fd5 --- /dev/null +++ b/ohos/entry/src/main/resources/base/profile/main_pages.json @@ -0,0 +1,5 @@ +{ + "src": [ + "pages/BreedListPage" + ] +} diff --git a/ohos/entry/src/main/resources/en_US/element/string.json b/ohos/entry/src/main/resources/en_US/element/string.json new file mode 100644 index 00000000..2b90a0a3 --- /dev/null +++ b/ohos/entry/src/main/resources/en_US/element/string.json @@ -0,0 +1,16 @@ +{ + "string": [ + { + "name": "module_desc", + "value": "module description" + }, + { + "name": "EntryAbility_desc", + "value": "description" + }, + { + "name": "EntryAbility_label", + "value": "KaMPKit" + } + ] +} \ No newline at end of file diff --git a/ohos/entry/src/main/resources/rawfile/Satisfy-Regular.ttf b/ohos/entry/src/main/resources/rawfile/Satisfy-Regular.ttf new file mode 100644 index 00000000..d4287888 Binary files /dev/null and b/ohos/entry/src/main/resources/rawfile/Satisfy-Regular.ttf differ diff --git a/ohos/entry/src/main/resources/zh_CN/element/string.json b/ohos/entry/src/main/resources/zh_CN/element/string.json new file mode 100644 index 00000000..0feb2f7b --- /dev/null +++ b/ohos/entry/src/main/resources/zh_CN/element/string.json @@ -0,0 +1,16 @@ +{ + "string": [ + { + "name": "module_desc", + "value": "模块描述" + }, + { + "name": "EntryAbility_desc", + "value": "description" + }, + { + "name": "EntryAbility_label", + "value": "KaMPKit" + } + ] +} \ No newline at end of file diff --git a/ohos/entry/src/mock/mock-config.json5 b/ohos/entry/src/mock/mock-config.json5 new file mode 100644 index 00000000..7a73a41b --- /dev/null +++ b/ohos/entry/src/mock/mock-config.json5 @@ -0,0 +1,2 @@ +{ +} \ No newline at end of file diff --git a/ohos/entry/src/ohosTest/ets/test/Ability.test.ets b/ohos/entry/src/ohosTest/ets/test/Ability.test.ets new file mode 100644 index 00000000..85c78f67 --- /dev/null +++ b/ohos/entry/src/ohosTest/ets/test/Ability.test.ets @@ -0,0 +1,35 @@ +import { hilog } from '@kit.PerformanceAnalysisKit'; +import { describe, beforeAll, beforeEach, afterEach, afterAll, it, expect } from '@ohos/hypium'; + +export default function abilityTest() { + describe('ActsAbilityTest', () => { + // Defines a test suite. Two parameters are supported: test suite name and test suite function. + beforeAll(() => { + // Presets an action, which is performed only once before all test cases of the test suite start. + // This API supports only one parameter: preset action function. + }) + beforeEach(() => { + // Presets an action, which is performed before each unit test case starts. + // The number of execution times is the same as the number of test cases defined by **it**. + // This API supports only one parameter: preset action function. + }) + afterEach(() => { + // Presets a clear action, which is performed after each unit test case ends. + // The number of execution times is the same as the number of test cases defined by **it**. + // This API supports only one parameter: clear action function. + }) + afterAll(() => { + // Presets a clear action, which is performed after all test cases of the test suite end. + // This API supports only one parameter: clear action function. + }) + it('assertContain', 0, () => { + // Defines a test case. This API supports three parameters: test case name, filter parameter, and test case function. + hilog.info(0x0000, 'testTag', '%{public}s', 'it begin'); + let a = 'abc'; + let b = 'b'; + // Defines a variety of assertion methods, which are used to declare expected boolean conditions. + expect(a).assertContain(b); + expect(a).assertEqual(a); + }) + }) +} \ No newline at end of file diff --git a/ohos/entry/src/ohosTest/ets/test/List.test.ets b/ohos/entry/src/ohosTest/ets/test/List.test.ets new file mode 100644 index 00000000..794c7dc4 --- /dev/null +++ b/ohos/entry/src/ohosTest/ets/test/List.test.ets @@ -0,0 +1,5 @@ +import abilityTest from './Ability.test'; + +export default function testsuite() { + abilityTest(); +} \ No newline at end of file diff --git a/ohos/entry/src/ohosTest/ets/testability/TestAbility.ets b/ohos/entry/src/ohosTest/ets/testability/TestAbility.ets new file mode 100644 index 00000000..9eadca66 --- /dev/null +++ b/ohos/entry/src/ohosTest/ets/testability/TestAbility.ets @@ -0,0 +1,47 @@ +import { AbilityConstant, UIAbility, Want } from '@kit.AbilityKit'; +import { abilityDelegatorRegistry } from '@kit.TestKit'; +import { hilog } from '@kit.PerformanceAnalysisKit'; +import { window } from '@kit.ArkUI'; +import { Hypium } from '@ohos/hypium'; +import testsuite from '../test/List.test'; + +export default class TestAbility extends UIAbility { + onCreate(want: Want, launchParam: AbilityConstant.LaunchParam) { + hilog.info(0x0000, 'testTag', '%{public}s', 'TestAbility onCreate'); + hilog.info(0x0000, 'testTag', '%{public}s', 'want param:' + JSON.stringify(want) ?? ''); + hilog.info(0x0000, 'testTag', '%{public}s', 'launchParam:' + JSON.stringify(launchParam) ?? ''); + let abilityDelegator: abilityDelegatorRegistry.AbilityDelegator; + abilityDelegator = abilityDelegatorRegistry.getAbilityDelegator(); + let abilityDelegatorArguments: abilityDelegatorRegistry.AbilityDelegatorArgs; + abilityDelegatorArguments = abilityDelegatorRegistry.getArguments(); + hilog.info(0x0000, 'testTag', '%{public}s', 'start run testcase!!!'); + Hypium.hypiumTest(abilityDelegator, abilityDelegatorArguments, testsuite); + } + + onDestroy() { + hilog.info(0x0000, 'testTag', '%{public}s', 'TestAbility onDestroy'); + } + + onWindowStageCreate(windowStage: window.WindowStage) { + hilog.info(0x0000, 'testTag', '%{public}s', 'TestAbility onWindowStageCreate'); + windowStage.loadContent('testability/pages/Index', (err) => { + if (err.code) { + hilog.error(0x0000, 'testTag', 'Failed to load the content. Cause: %{public}s', JSON.stringify(err) ?? ''); + return; + } + hilog.info(0x0000, 'testTag', 'Succeeded in loading the content.'); + }); + } + + onWindowStageDestroy() { + hilog.info(0x0000, 'testTag', '%{public}s', 'TestAbility onWindowStageDestroy'); + } + + onForeground() { + hilog.info(0x0000, 'testTag', '%{public}s', 'TestAbility onForeground'); + } + + onBackground() { + hilog.info(0x0000, 'testTag', '%{public}s', 'TestAbility onBackground'); + } +}; \ No newline at end of file diff --git a/ohos/entry/src/ohosTest/ets/testability/pages/Index.ets b/ohos/entry/src/ohosTest/ets/testability/pages/Index.ets new file mode 100644 index 00000000..078a2cfd --- /dev/null +++ b/ohos/entry/src/ohosTest/ets/testability/pages/Index.ets @@ -0,0 +1,17 @@ +@Entry +@Component +struct Index { + @State message: string = 'Hello World'; + + build() { + Row() { + Column() { + Text(this.message) + .fontSize(50) + .fontWeight(FontWeight.Bold); + } + .width('100%'); + } + .height('100%'); + } +} \ No newline at end of file diff --git a/ohos/entry/src/ohosTest/ets/testrunner/OpenHarmonyTestRunner.ets b/ohos/entry/src/ohosTest/ets/testrunner/OpenHarmonyTestRunner.ets new file mode 100644 index 00000000..b7ceb3d2 --- /dev/null +++ b/ohos/entry/src/ohosTest/ets/testrunner/OpenHarmonyTestRunner.ets @@ -0,0 +1,92 @@ +import { abilityDelegatorRegistry, TestRunner } from '@kit.TestKit'; +import { UIAbility, Want } from '@kit.AbilityKit'; +import { BusinessError } from '@kit.BasicServicesKit'; +import { hilog } from '@kit.PerformanceAnalysisKit'; +import { resourceManager } from '@kit.LocalizationKit'; +import { util } from '@kit.ArkTS'; + +let abilityDelegator: abilityDelegatorRegistry.AbilityDelegator; +let abilityDelegatorArguments: abilityDelegatorRegistry.AbilityDelegatorArgs; +let jsonPath: string = 'mock/mock-config.json'; +let tag: string = 'testTag'; + +async function onAbilityCreateCallback(data: UIAbility) { + hilog.info(0x0000, 'testTag', 'onAbilityCreateCallback, data: ${}', JSON.stringify(data)); +} + +async function addAbilityMonitorCallback(err: BusinessError) { + hilog.info(0x0000, 'testTag', 'addAbilityMonitorCallback : %{public}s', JSON.stringify(err) ?? ''); +} + +export default class OpenHarmonyTestRunner implements TestRunner { + constructor() { + } + + onPrepare() { + hilog.info(0x0000, 'testTag', '%{public}s', 'OpenHarmonyTestRunner OnPrepare'); + } + + async onRun() { + let tag = 'testTag'; + hilog.info(0x0000, tag, '%{public}s', 'OpenHarmonyTestRunner onRun run'); + abilityDelegatorArguments = abilityDelegatorRegistry.getArguments(); + abilityDelegator = abilityDelegatorRegistry.getAbilityDelegator(); + let moduleName = abilityDelegatorArguments.parameters['-m']; + let context = abilityDelegator.getAppContext().getApplicationContext().createModuleContext(moduleName); + let mResourceManager = context.resourceManager; + await checkMock(abilityDelegator, mResourceManager); + const bundleName = abilityDelegatorArguments.bundleName; + const testAbilityName: string = 'TestAbility'; + let lMonitor: abilityDelegatorRegistry.AbilityMonitor = { + abilityName: testAbilityName, + onAbilityCreate: onAbilityCreateCallback, + moduleName: moduleName + }; + abilityDelegator.addAbilityMonitor(lMonitor, addAbilityMonitorCallback); + const want: Want = { + bundleName: bundleName, + abilityName: testAbilityName, + moduleName: moduleName + }; + abilityDelegator.startAbility(want, (err: BusinessError, data: void) => { + hilog.info(0x0000, tag, 'startAbility : err : %{public}s', JSON.stringify(err) ?? ''); + hilog.info(0x0000, tag, 'startAbility : data : %{public}s', JSON.stringify(data) ?? ''); + }); + hilog.info(0x0000, tag, '%{public}s', 'OpenHarmonyTestRunner onRun end'); + } +}; + +async function checkMock(abilityDelegator: abilityDelegatorRegistry.AbilityDelegator, + resourceManager: resourceManager.ResourceManager) { + let rawFile: Uint8Array; + try { + rawFile = resourceManager.getRawFileContentSync(jsonPath); + hilog.info(0x0000, tag, 'MockList file exists'); + let mockStr: string = util.TextDecoder.create("utf-8", { ignoreBOM: true }).decodeWithStream(rawFile); + let mockMap: Record = getMockList(mockStr); + try { + abilityDelegator.setMockList(mockMap); + } catch (error) { + let code = (error as BusinessError).code; + let message = (error as BusinessError).message; + hilog.error(0x0000, tag, `abilityDelegator.setMockList failed, error code: ${code}, message: ${message}.`); + } + } catch (error) { + let code = (error as BusinessError).code; + let message = (error as BusinessError).message; + hilog.error(0x0000, tag, + `ResourceManager:callback getRawFileContent failed, error code: ${code}, message: ${message}.`); + } +} + +function getMockList(jsonStr: string) { + let jsonObj: Record = JSON.parse(jsonStr); + let map: Map = new Map(Object.entries(jsonObj)); + let mockList: Record = {}; + map.forEach((value: object, key: string) => { + let realValue: string = value['source'].toString(); + mockList[key] = realValue; + }); + hilog.info(0x0000, tag, '%{public}s', 'mock-json value:' + JSON.stringify(mockList) ?? ''); + return mockList; +} \ No newline at end of file diff --git a/ohos/entry/src/ohosTest/module.json5 b/ohos/entry/src/ohosTest/module.json5 new file mode 100644 index 00000000..87631d3a --- /dev/null +++ b/ohos/entry/src/ohosTest/module.json5 @@ -0,0 +1,36 @@ +{ + "module": { + "name": "entry_test", + "type": "feature", + "description": "$string:module_test_desc", + "mainElement": "TestAbility", + "deviceTypes": [ + "phone" + ], + "deliveryWithInstall": true, + "installationFree": false, + "pages": "$profile:test_pages", + "abilities": [ + { + "name": "TestAbility", + "srcEntry": "./ets/testability/TestAbility.ets", + "description": "$string:TestAbility_desc", + "icon": "$media:icon", + "label": "$string:TestAbility_label", + "exported": true, + "startWindowIcon": "$media:icon", + "startWindowBackground": "$color:start_window_background", + "skills": [ + { + "actions": [ + "action.system.home" + ], + "entities": [ + "entity.system.home" + ] + } + ] + } + ] + } +} diff --git a/ohos/entry/src/ohosTest/resources/base/element/color.json b/ohos/entry/src/ohosTest/resources/base/element/color.json new file mode 100644 index 00000000..3c712962 --- /dev/null +++ b/ohos/entry/src/ohosTest/resources/base/element/color.json @@ -0,0 +1,8 @@ +{ + "color": [ + { + "name": "start_window_background", + "value": "#FFFFFF" + } + ] +} \ No newline at end of file diff --git a/ohos/entry/src/ohosTest/resources/base/element/string.json b/ohos/entry/src/ohosTest/resources/base/element/string.json new file mode 100644 index 00000000..65d8fa5a --- /dev/null +++ b/ohos/entry/src/ohosTest/resources/base/element/string.json @@ -0,0 +1,16 @@ +{ + "string": [ + { + "name": "module_test_desc", + "value": "test ability description" + }, + { + "name": "TestAbility_desc", + "value": "the test ability" + }, + { + "name": "TestAbility_label", + "value": "test label" + } + ] +} \ No newline at end of file diff --git a/ohos/entry/src/ohosTest/resources/base/media/icon.png b/ohos/entry/src/ohosTest/resources/base/media/icon.png new file mode 100644 index 00000000..a39445dc Binary files /dev/null and b/ohos/entry/src/ohosTest/resources/base/media/icon.png differ diff --git a/ohos/entry/src/ohosTest/resources/base/profile/test_pages.json b/ohos/entry/src/ohosTest/resources/base/profile/test_pages.json new file mode 100644 index 00000000..b7e7343c --- /dev/null +++ b/ohos/entry/src/ohosTest/resources/base/profile/test_pages.json @@ -0,0 +1,5 @@ +{ + "src": [ + "testability/pages/Index" + ] +} diff --git a/ohos/entry/src/test/List.test.ets b/ohos/entry/src/test/List.test.ets new file mode 100644 index 00000000..bb5b5c37 --- /dev/null +++ b/ohos/entry/src/test/List.test.ets @@ -0,0 +1,5 @@ +import localUnitTest from './LocalUnit.test'; + +export default function testsuite() { + localUnitTest(); +} \ No newline at end of file diff --git a/ohos/entry/src/test/LocalUnit.test.ets b/ohos/entry/src/test/LocalUnit.test.ets new file mode 100644 index 00000000..ed22d4dc --- /dev/null +++ b/ohos/entry/src/test/LocalUnit.test.ets @@ -0,0 +1,33 @@ +import { describe, beforeAll, beforeEach, afterEach, afterAll, it, expect } from '@ohos/hypium'; + +export default function localUnitTest() { + describe('localUnitTest',() => { + // Defines a test suite. Two parameters are supported: test suite name and test suite function. + beforeAll(() => { + // Presets an action, which is performed only once before all test cases of the test suite start. + // This API supports only one parameter: preset action function. + }); + beforeEach(() => { + // Presets an action, which is performed before each unit test case starts. + // The number of execution times is the same as the number of test cases defined by **it**. + // This API supports only one parameter: preset action function. + }); + afterEach(() => { + // Presets a clear action, which is performed after each unit test case ends. + // The number of execution times is the same as the number of test cases defined by **it**. + // This API supports only one parameter: clear action function. + }); + afterAll(() => { + // Presets a clear action, which is performed after all test cases of the test suite end. + // This API supports only one parameter: clear action function. + }); + it('assertContain', 0, () => { + // Defines a test case. This API supports three parameters: test case name, filter parameter, and test case function. + let a = 'abc'; + let b = 'b'; + // Defines a variety of assertion methods, which are used to declare expected boolean conditions. + expect(a).assertContain(b); + expect(a).assertEqual(a); + }); + }); +} \ No newline at end of file diff --git a/ohos/hvigor/hvigor-config.json5 b/ohos/hvigor/hvigor-config.json5 new file mode 100644 index 00000000..b8204557 --- /dev/null +++ b/ohos/hvigor/hvigor-config.json5 @@ -0,0 +1,22 @@ +{ + + "modelVersion": "5.0.0", + "dependencies": { + }, + "execution": { + // "analyze": "default", /* Define the build analyze mode. Value: [ "default" | "verbose" | false ]. Default: "default" */ + // "daemon": true, /* Enable daemon compilation. Value: [ true | false ]. Default: true */ + // "incremental": true, /* Enable incremental compilation. Value: [ true | false ]. Default: true */ + // "parallel": true, /* Enable parallel compilation. Value: [ true | false ]. Default: true */ + // "typeCheck": false, /* Enable typeCheck. Value: [ true | false ]. Default: false */ + }, + "logging": { + // "level": "info" /* Define the log level. Value: [ "debug" | "info" | "warn" | "error" ]. Default: "info" */ + }, + "debugging": { + // "stacktrace": false /* Disable stacktrace compilation. Value: [ true | false ]. Default: false */ + }, + "nodeOptions": { + // "maxOldSpaceSize": 4096 /* Enable nodeOptions maxOldSpaceSize compilation. Unit M. Used for the daemon process */ + } +} \ No newline at end of file diff --git a/ohos/hvigorfile.ts b/ohos/hvigorfile.ts new file mode 100644 index 00000000..20a8c119 --- /dev/null +++ b/ohos/hvigorfile.ts @@ -0,0 +1,6 @@ +import { appTasks } from '@ohos/hvigor-ohos-plugin'; + +export default { + system: appTasks, /* Built-in plugin of Hvigor. It cannot be modified. */ + plugins: [] /* Custom plugin to extend the functionality of Hvigor. */ +} diff --git a/ohos/oh-package-lock.json5 b/ohos/oh-package-lock.json5 new file mode 100644 index 00000000..dbd926d5 --- /dev/null +++ b/ohos/oh-package-lock.json5 @@ -0,0 +1,28 @@ +{ + "meta": { + "stableOrder": true, + "enableUnifiedLockfile": false + }, + "lockfileVersion": 3, + "ATTENTION": "THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.", + "specifiers": { + "@ohos/hamock@1.0.0": "@ohos/hamock@1.0.0", + "@ohos/hypium@1.0.16": "@ohos/hypium@1.0.16" + }, + "packages": { + "@ohos/hamock@1.0.0": { + "name": "@ohos/hamock", + "version": "1.0.0", + "integrity": "sha512-K6lDPYc6VkKe6ZBNQa9aoG+ZZMiwqfcR/7yAVFSUGIuOAhPvCJAo9+t1fZnpe0dBRBPxj2bxPPbKh69VuyAtDg==", + "resolved": "https://ohpm.openharmony.cn/ohpm/@ohos/hamock/-/hamock-1.0.0.har", + "registryType": "ohpm" + }, + "@ohos/hypium@1.0.16": { + "name": "@ohos/hypium", + "version": "1.0.16", + "integrity": "sha512-PC3jpwKERg68V+4dmKU+SLjNps9i5JcQH57rQriaTsh62NBgVZs4SceMmNOtrIOyldbEJ5mXSwoZwiG/nkRmTw==", + "resolved": "https://ohpm.openharmony.cn/ohpm/@ohos/hypium/-/hypium-1.0.16.har", + "registryType": "ohpm" + } + } +} \ No newline at end of file diff --git a/ohos/oh-package.json5 b/ohos/oh-package.json5 new file mode 100644 index 00000000..a3268b1f --- /dev/null +++ b/ohos/oh-package.json5 @@ -0,0 +1,16 @@ +{ + "name": "ohosApp", + "version": "1.0.0", + "description": "Please describe the basic information.", + "main": "", + "author": "", + "license": "", + "dependencies": { + }, + "devDependencies": { + "@ohos/hypium": "1.0.16", + "@ohos/hamock": "1.0.0" + }, + + "modelVersion":"5.0.0" +} \ No newline at end of file diff --git a/ohos/publish.sh b/ohos/publish.sh new file mode 100644 index 00000000..ab68e22e --- /dev/null +++ b/ohos/publish.sh @@ -0,0 +1,9 @@ +pwd +which ohpm +ohpm -v +which hvigorw +ohpm install --all --strict_ssl true +hvigorw clean --no-daemon +hvigorw assembleHar --mode module -p module=render@default -p product=default -p buildMode=release --no-daemon +#ohpm dist-tags remove @kuikly-open/render alpha +ohpm publish ../core-render-ohos/build/default/outputs/default/render.har diff --git a/ohos/runOhosApp.sh b/ohos/runOhosApp.sh new file mode 100755 index 00000000..748937ab --- /dev/null +++ b/ohos/runOhosApp.sh @@ -0,0 +1,62 @@ +#!/bin/sh +# KaMPKit OHOS:先编译鸿蒙 libshared.so(若有),再构建并安装 +# 使用前:1) 启动模拟器或连接真机 2) 若签名失败,用 DevEco Studio 打开 ohos 目录并 Run + +set -e +OHOS_ROOT="$(cd "$(dirname "$0")" && pwd)" +REPO_ROOT="$(cd "$OHOS_ROOT/.." && pwd)" + +# [0/3] 自动编译鸿蒙 libshared.so(demo 或 shared 产出后拷贝到 entry) +if [ -x "$OHOS_ROOT/build_libshared.sh" ]; then + "$OHOS_ROOT/build_libshared.sh" || true +fi + +cd "$OHOS_ROOT" + +# 优先使用环境变量,否则常见 DevEco 路径 +SDK_HOME="${DEVECO_SDK_HOME:-/Applications/Apps/DevEco-Studio.app/Contents/sdk}" +if [ ! -d "$SDK_HOME" ]; then + SDK_HOME="/Applications/DevEco-Studio.app/Contents/sdk" +fi +export DEVECO_SDK_HOME="$SDK_HOME" +export PATH="$SDK_HOME:$SDK_HOME/../jbr/Contents/Home/bin:$SDK_HOME/../tools/node/bin:$SDK_HOME/../tools/ohpm/bin:$SDK_HOME/../tools/hvigor/bin:$PATH" + +echo "[1/3] ohpm install..." +ohpm install --all + +echo "[2/3] build HAP..." +node "$SDK_HOME/../tools/hvigor/bin/hvigorw.js" --mode module -p module=entry@default -p product=default -p requiredDeviceType=phone assembleHap --analyze=normal --parallel + +HAP_DIR="entry/build/default/outputs/default" +SIGNED_HAP="$HAP_DIR/entry-default-signed.hap" +UNSIGNED_HAP="$HAP_DIR/entry-default-unsigned.hap" + +if [ -f "$SIGNED_HAP" ]; then + HAP="$SIGNED_HAP" +elif [ -f "$UNSIGNED_HAP" ]; then + HAP="$UNSIGNED_HAP" + echo "Note: 使用未签名 HAP;若安装失败请在 DevEco Studio 中打开 ohos 目录并 Run(自动签名)。" +else + echo "Error: 未找到 HAP,请检查构建日志。" + exit 1 +fi + +HDC_BIN="$SDK_HOME/default/openharmony/toolchains/hdc" +if [ ! -x "$HDC_BIN" ]; then + HDC_BIN="hdc" +fi + +targets=$("$HDC_BIN" list targets 2>/dev/null || true) +if [ -z "$targets" ]; then + echo "Error: 未检测到设备。请先启动模拟器或连接真机并开启 USB 调试。" + exit 2 +fi + +echo "[3/3] 安装并启动..." +BUNDLE="com.tencent.kuiklyohosapp" +for tid in $targets; do + echo " -> 设备 $tid" + "$HDC_BIN" -t "$tid" install -r "$HAP" 2>/dev/null || "$HDC_BIN" -t "$tid" install "$HAP" + "$HDC_BIN" -t "$tid" shell aa start -a EntryAbility -b "$BUNDLE" +done +echo "Done. 若启动失败,请用 DevEco Studio 打开 ohos 目录并点击 Run(会使用自动签名)。" diff --git a/settings.2.0.ohos.gradle.kts b/settings.2.0.ohos.gradle.kts new file mode 100644 index 00000000..2ec630a5 --- /dev/null +++ b/settings.2.0.ohos.gradle.kts @@ -0,0 +1,54 @@ +pluginManagement { + repositories { + // mavenLocal() + google() + gradlePluginPortal() + mavenCentral() + maven { + url = uri("https://mirrors.tencent.com/nexus/repository/maven-public/") + } + } +} + +dependencyResolutionManagement { + repositories { + mavenLocal() + google() + gradlePluginPortal() + mavenCentral() + maven { + url = uri("https://mirrors.tencent.com/nexus/repository/maven-public/") + } + } +} + +val buildFileName = "build.2.0.ohos.gradle.kts" +rootProject.buildFileName = buildFileName + + +include(":core-annotations") +project(":core-annotations").buildFileName = buildFileName + +include(":core-ksp") +project(":core-ksp").buildFileName = buildFileName + +include(":core") +project(":core").buildFileName = buildFileName + +include(":core-render-android") +project(":core-render-android").buildFileName = buildFileName + +include(":compose") +project(":compose").buildFileName = buildFileName + +include(":demo") +project(":demo").buildFileName = buildFileName + +include(":shared") +project(":shared").buildFileName = "build.gradle.kts" + +include(":compose") +project(":compose").buildFileName = buildFileName + +// include(":androidApp") + diff --git a/settings.kampkit.ohos.gradle.kts b/settings.kampkit.ohos.gradle.kts new file mode 100644 index 00000000..26af8b53 --- /dev/null +++ b/settings.kampkit.ohos.gradle.kts @@ -0,0 +1,27 @@ +/* + * KaMPKit standalone OHOS settings — only builds :shared for ohosArm64. + * Use with: ./gradlew -c settings.kampkit.ohos.gradle.kts :shared:link[Debug|Release]SharedLibOhosArm64 + */ +pluginManagement { + repositories { + google() + gradlePluginPortal() + mavenCentral() + maven { url = uri("https://mirrors.tencent.com/nexus/repository/maven-public/") } + } +} + +dependencyResolutionManagement { + repositories { + mavenLocal() + google() + gradlePluginPortal() + mavenCentral() + maven { url = uri("https://mirrors.tencent.com/nexus/repository/maven-public/") } + } +} + +rootProject.buildFileName = "build.kampkit.ohos.gradle.kts" + +include(":shared") +project(":shared").buildFileName = "build.2.0.ohos.gradle.kts" diff --git a/shared/build.2.0.ohos.gradle.kts b/shared/build.2.0.ohos.gradle.kts new file mode 100644 index 00000000..541eda4f --- /dev/null +++ b/shared/build.2.0.ohos.gradle.kts @@ -0,0 +1,39 @@ +/* + * KaMPKit shared — OHOS Kotlin/Native build (KBA Kotlin 2.0.21). + * + * Produces libshared.so for ohosArm64. + * Only compiles src/ohosMain/kotlin/ — OhosExports.kt is fully self-contained. + * commonMain is cleared to avoid unresolvable dependencies (Koin, Ktor, SQLDelight, etc.) + */ +plugins { + kotlin("multiplatform") +} + +kotlin { + ohosArm64 { + binaries.sharedLib { + baseName = "shared" + freeCompilerArgs += "-Xadd-light-debug=enable" + if (debuggable) { + freeCompilerArgs += "-Xbinary=sourceInfoType=libbacktrace" + } + } + } + + sourceSets { + // Exclude commonMain — its dependencies have no ohos_arm64 variant + val commonMain by getting { + kotlin.setSrcDirs(emptyList()) + } + // ohosArm64Main is the leaf native source set — has access to kotlinx.cinterop + // Sources live in ohos/native/ohosMain/kotlin/ so DWARF paths end with + // "ohos/native/ohosMain/kotlin/..." — matching the paths DevEco uses for breakpoints. + val ohosArm64Main by getting { + kotlin.setSrcDirs(listOf("../ohos/native/ohosMain/kotlin")) + languageSettings { + optIn("kotlinx.cinterop.ExperimentalForeignApi") + optIn("kotlin.experimental.ExperimentalNativeApi") + } + } + } +} diff --git a/shared/build.gradle.kts b/shared/build.gradle.kts index a7047379..34f7de66 100644 --- a/shared/build.gradle.kts +++ b/shared/build.gradle.kts @@ -54,6 +54,17 @@ kotlin { } } + // HarmonyOS / OpenHarmony: 业务逻辑共享于 commonMain,OHOS 必须依赖 shared(见 docs/OHOS_SHARED_BUSINESS_LOGIC.md) + // 方案一:Kotlin/JS 作为 Platform Main(当前用于 jsMain 层) + js(org.jetbrains.kotlin.gradle.plugin.KotlinJsCompilerType.IR) { + browser() + binaries.executable() + } + // 方案二:Kotlin-OHOS 产出 libshared.so 供 NAPI 链接(接入后取消注释并配置 repo) + // harmonyOSArm64 { + // binaries.sharedLib { baseName = "shared" } + // } + sourceSets { all { languageSettings.apply { @@ -90,6 +101,10 @@ kotlin { implementation(libs.ktor.client.ios) api(libs.touchlab.kermit.simple) } + // jsMain = HarmonyOS Platform Main(华为 Common + Platform Main 思路) + getByName("jsMain").dependencies { + implementation(libs.ktor.client.js) + } } } diff --git a/shared/src/androidMain/kotlin/co/touchlab/kampkit/KoinAndroid.kt b/shared/src/androidMain/kotlin/co/touchlab/kampkit/KoinAndroid.kt index 6b92a205..55b8f3e5 100644 --- a/shared/src/androidMain/kotlin/co/touchlab/kampkit/KoinAndroid.kt +++ b/shared/src/androidMain/kotlin/co/touchlab/kampkit/KoinAndroid.kt @@ -3,9 +3,16 @@ package co.touchlab.kampkit import app.cash.sqldelight.db.SqlDriver import app.cash.sqldelight.driver.android.AndroidSqliteDriver import co.touchlab.kampkit.db.KaMPKitDb +import co.touchlab.kampkit.models.BreedRepository +import co.touchlab.kampkit.models.IBreedRepository +import co.touchlab.kermit.Logger +import co.touchlab.kermit.StaticConfig +import co.touchlab.kermit.platformLogWriter import com.russhwolf.settings.Settings import com.russhwolf.settings.SharedPreferencesSettings import io.ktor.client.engine.okhttp.OkHttp +import kotlin.time.Clock +import kotlinx.coroutines.Dispatchers import org.koin.core.module.Module import org.koin.dsl.module @@ -26,3 +33,34 @@ actual val platformModule: Module = module { OkHttp.create() } } + +actual val coreModule: Module = module { + single { + DatabaseHelper( + get(), + getWith("DatabaseHelper"), + Dispatchers.Default, + ) + } + single { + co.touchlab.kampkit.ktor.DogApiImpl( + getWith("DogApiImpl"), + get(), + ) + } + single { + Clock.System + } + val baseLogger = + Logger(config = StaticConfig(logWriterList = listOf(platformLogWriter())), "KampKit") + factory { (tag: String?) -> if (tag != null) baseLogger.withTag(tag) else baseLogger } + single { + BreedRepository( + get(), + get(), + get(), + getWith("BreedRepository"), + get(), + ) + } +} diff --git a/shared/src/commonMain/kotlin/co/touchlab/kampkit/Koin.kt b/shared/src/commonMain/kotlin/co/touchlab/kampkit/Koin.kt index 4bc0afb1..ee52cf7c 100644 --- a/shared/src/commonMain/kotlin/co/touchlab/kampkit/Koin.kt +++ b/shared/src/commonMain/kotlin/co/touchlab/kampkit/Koin.kt @@ -1,13 +1,6 @@ package co.touchlab.kampkit -import co.touchlab.kampkit.ktor.DogApi -import co.touchlab.kampkit.ktor.DogApiImpl -import co.touchlab.kampkit.models.BreedRepository import co.touchlab.kermit.Logger -import co.touchlab.kermit.StaticConfig -import co.touchlab.kermit.platformLogWriter -import kotlin.time.Clock -import kotlinx.coroutines.Dispatchers import org.koin.core.KoinApplication import org.koin.core.component.KoinComponent import org.koin.core.component.inject @@ -15,7 +8,6 @@ import org.koin.core.context.startKoin import org.koin.core.module.Module import org.koin.core.parameter.parametersOf import org.koin.core.scope.Scope -import org.koin.dsl.module fun initKoin(appModule: Module): KoinApplication { val koinApplication = startKoin { @@ -25,6 +17,7 @@ fun initKoin(appModule: Module): KoinApplication { coreModule, ) } + // coreModule 由各端 actual 提供(Android/iOS 用 SqlDelight,Harmony 用网络+内存) // Dummy initialization logic, making use of appModule declarations for demonstration purposes. val koin = koinApplication.koin @@ -40,46 +33,10 @@ fun initKoin(appModule: Module): KoinApplication { return koinApplication } -private val coreModule = module { - single { - DatabaseHelper( - get(), - getWith("DatabaseHelper"), - Dispatchers.Default, - ) - } - single { - DogApiImpl( - getWith("DogApiImpl"), - get(), - ) - } - single { - Clock.System - } - - // platformLogWriter() is a relatively simple config option, useful for local debugging. For production - // uses you *may* want to have a more robust configuration from the native platform. In KaMP Kit, - // that would likely go into platformModule expect/actual. - // See https://github.com/touchlab/Kermit - val baseLogger = - Logger(config = StaticConfig(logWriterList = listOf(platformLogWriter())), "KampKit") - factory { (tag: String?) -> if (tag != null) baseLogger.withTag(tag) else baseLogger } - - single { - BreedRepository( - get(), - get(), - get(), - getWith("BreedRepository"), - get(), - ) - } -} - internal inline fun Scope.getWith(vararg params: Any?): T = get(parameters = { parametersOf(*params) }) // Simple function to clean up the syntax a bit fun KoinComponent.injectLogger(tag: String): Lazy = inject { parametersOf(tag) } expect val platformModule: Module +expect val coreModule: Module diff --git a/shared/src/commonMain/kotlin/co/touchlab/kampkit/models/BreedRepository.kt b/shared/src/commonMain/kotlin/co/touchlab/kampkit/models/BreedRepository.kt index a21a6bf2..ffce43f8 100644 --- a/shared/src/commonMain/kotlin/co/touchlab/kampkit/models/BreedRepository.kt +++ b/shared/src/commonMain/kotlin/co/touchlab/kampkit/models/BreedRepository.kt @@ -14,7 +14,7 @@ class BreedRepository( private val dogApi: DogApi, log: Logger, private val clock: Clock, -) { +) : IBreedRepository { private val log = log.withTag("BreedModel") @@ -22,15 +22,15 @@ class BreedRepository( internal const val DB_TIMESTAMP_KEY = "DbTimestampKey" } - fun getBreeds(): Flow> = dbHelper.selectAllItems() + override fun getBreeds(): Flow> = dbHelper.selectAllItems() - suspend fun refreshBreedsIfStale() { + override suspend fun refreshBreedsIfStale() { if (isBreedListStale()) { refreshBreeds() } } - suspend fun refreshBreeds() { + override suspend fun refreshBreeds() { val breedResult = dogApi.getJsonFromApi() log.v { "Breed network result: ${breedResult.status}" } val breedList = breedResult.message.keys.sorted().toList() @@ -42,7 +42,7 @@ class BreedRepository( } } - suspend fun updateBreedFavorite(breed: Breed) { + override suspend fun updateBreedFavorite(breed: Breed) { dbHelper.updateFavorite(breed.id, !breed.favorite) } diff --git a/shared/src/commonMain/kotlin/co/touchlab/kampkit/models/BreedViewModel.kt b/shared/src/commonMain/kotlin/co/touchlab/kampkit/models/BreedViewModel.kt index 58e663d0..5db3ea78 100644 --- a/shared/src/commonMain/kotlin/co/touchlab/kampkit/models/BreedViewModel.kt +++ b/shared/src/commonMain/kotlin/co/touchlab/kampkit/models/BreedViewModel.kt @@ -10,7 +10,7 @@ import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.update -class BreedViewModel(private val breedRepository: BreedRepository, private val log: Logger) : ViewModel() { +class BreedViewModel(private val breedRepository: IBreedRepository, private val log: Logger) : ViewModel() { private val mutableBreedState: MutableStateFlow = MutableStateFlow(BreedViewState.Initial) diff --git a/shared/src/commonMain/kotlin/co/touchlab/kampkit/models/IBreedRepository.kt b/shared/src/commonMain/kotlin/co/touchlab/kampkit/models/IBreedRepository.kt new file mode 100644 index 00000000..0a809691 --- /dev/null +++ b/shared/src/commonMain/kotlin/co/touchlab/kampkit/models/IBreedRepository.kt @@ -0,0 +1,15 @@ +package co.touchlab.kampkit.models + +import co.touchlab.kampkit.db.Breed +import kotlinx.coroutines.flow.Flow + +/** + * 与 BreedRepository 同构的接口,便于 Harmony/JS 等平台用网络+内存实现(无需 SqlDelight)。 + * 华为 Common + Platform Main 思路下,各端可提供不同实现。 + */ +interface IBreedRepository { + fun getBreeds(): Flow> + suspend fun refreshBreedsIfStale() + suspend fun refreshBreeds() + suspend fun updateBreedFavorite(breed: Breed) +} diff --git a/shared/src/iosMain/kotlin/co/touchlab/kampkit/KoinIOS.kt b/shared/src/iosMain/kotlin/co/touchlab/kampkit/KoinIOS.kt index 7538916f..8ae1fbfe 100644 --- a/shared/src/iosMain/kotlin/co/touchlab/kampkit/KoinIOS.kt +++ b/shared/src/iosMain/kotlin/co/touchlab/kampkit/KoinIOS.kt @@ -3,11 +3,17 @@ package co.touchlab.kampkit import app.cash.sqldelight.db.SqlDriver import app.cash.sqldelight.driver.native.NativeSqliteDriver import co.touchlab.kampkit.db.KaMPKitDb +import co.touchlab.kampkit.models.BreedRepository import co.touchlab.kampkit.models.BreedViewModel +import co.touchlab.kampkit.models.IBreedRepository import co.touchlab.kermit.Logger +import co.touchlab.kermit.StaticConfig +import co.touchlab.kermit.platformLogWriter import com.russhwolf.settings.NSUserDefaultsSettings import com.russhwolf.settings.Settings import io.ktor.client.engine.darwin.Darwin +import kotlin.time.Clock +import kotlinx.coroutines.Dispatchers import org.koin.core.Koin import org.koin.core.KoinApplication import org.koin.core.component.KoinComponent @@ -31,6 +37,37 @@ actual val platformModule = module { single { BreedViewModel(get(), getWith("BreedViewModel")) } } +actual val coreModule = module { + single { + DatabaseHelper( + get(), + getWith("DatabaseHelper"), + Dispatchers.Default, + ) + } + single { + co.touchlab.kampkit.ktor.DogApiImpl( + getWith("DogApiImpl"), + get(), + ) + } + single { + Clock.System + } + val baseLogger = + Logger(config = StaticConfig(logWriterList = listOf(platformLogWriter())), "KampKit") + factory { (tag: String?) -> if (tag != null) baseLogger.withTag(tag) else baseLogger } + single { + BreedRepository( + get(), + get(), + get(), + getWith("BreedRepository"), + get(), + ) + } +} + // Access from Swift to create a logger @Suppress("unused") fun Koin.loggerWithTag(tag: String) = get(qualifier = null) { parametersOf(tag) } diff --git a/shared/src/jsMain/kotlin/co/touchlab/kampkit/HarmonyBreedRepository.kt b/shared/src/jsMain/kotlin/co/touchlab/kampkit/HarmonyBreedRepository.kt new file mode 100644 index 00000000..59d7576b --- /dev/null +++ b/shared/src/jsMain/kotlin/co/touchlab/kampkit/HarmonyBreedRepository.kt @@ -0,0 +1,75 @@ +package co.touchlab.kampkit + +import co.touchlab.kampkit.db.Breed +import co.touchlab.kampkit.models.IBreedRepository +import co.touchlab.kampkit.ktor.DogApi +import co.touchlab.kermit.Logger +import com.russhwolf.settings.Settings +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlin.time.Clock + +/** + * Harmony/JS 端 Breed 数据源:网络 + 内存,与 commonMain BreedRepository 同构 API。 + * 不依赖 SqlDelight,符合华为 Common + Platform Main 思路。 + */ +class HarmonyBreedRepository( + private val settings: Settings, + private val dogApi: DogApi, + log: Logger, + private val clock: kotlin.time.Clock, +) : IBreedRepository { + + private val log = log.withTag("BreedModel") + private val mutex = Mutex() + private val cache = MutableStateFlow>(emptyList()) + + companion object { + private const val DB_TIMESTAMP_KEY = "DbTimestampKey" + } + + override fun getBreeds(): Flow> = cache + + override suspend fun refreshBreedsIfStale() { + if (isBreedListStale()) { + refreshBreeds() + } + } + + override suspend fun refreshBreeds() { + val result = dogApi.getJsonFromApi() + log.v { "Breed network result: ${result.status}" } + val names = result.message.keys.sorted() + log.v { "Fetched ${names.size} breeds from network" } + settings.putLong(DB_TIMESTAMP_KEY, clock.now().toEpochMilliseconds()) + mutex.withLock { + val nextId = (cache.value.maxOfOrNull { it.id } ?: 0L) + 1 + val existing = cache.value.associateBy { it.name } + val list = names.mapIndexed { index, name -> + existing[name] ?: Breed( + id = nextId + index, + name = name, + favorite = false, + ) + } + cache.value = list + } + } + + override suspend fun updateBreedFavorite(breed: Breed) { + mutex.withLock { + cache.value = cache.value.map { + if (it.id == breed.id) Breed(it.id, it.name, !it.favorite) else it + } + } + } + + private fun isBreedListStale(): Boolean { + val last = settings.getLong(DB_TIMESTAMP_KEY, 0) + val oneHour = 60 * 60 * 1000 + return last + oneHour < clock.now().toEpochMilliseconds() + } +} diff --git a/shared/src/jsMain/kotlin/co/touchlab/kampkit/KoinHarmony.kt b/shared/src/jsMain/kotlin/co/touchlab/kampkit/KoinHarmony.kt new file mode 100644 index 00000000..95a1da78 --- /dev/null +++ b/shared/src/jsMain/kotlin/co/touchlab/kampkit/KoinHarmony.kt @@ -0,0 +1,60 @@ +package co.touchlab.kampkit + +import co.touchlab.kampkit.models.BreedViewModel +import co.touchlab.kampkit.models.IBreedRepository +import co.touchlab.kermit.Logger +import co.touchlab.kermit.StaticConfig +import co.touchlab.kermit.platformLogWriter +import com.russhwolf.settings.Settings +import io.ktor.client.engine.js.Js +import kotlin.time.Clock +import org.koin.core.component.KoinComponent +import org.koin.core.module.Module +import org.koin.dsl.module + +/** + * HarmonyOS / OpenHarmony 入口:Common + Platform Main。 + * OHOS 端先调用 initKoinHarmony(appInfo, doOnStartup),再通过 KotlinDependenciesHarmony.getBreedViewModel() 获取数据。 + */ +fun initKoinHarmony(appModule: Module) = initKoin(appModule) + +fun initKoinHarmony(appInfo: AppInfo, doOnStartup: () -> Unit) = initKoin( + module { + single { appInfo } + single { doOnStartup } + }, +) + +@Suppress("unused") +object KotlinDependenciesHarmony : KoinComponent { + fun getBreedViewModel(): BreedViewModel = org.koin.core.context.GlobalContext.get().get() +} + +actual val platformModule: Module = module { + single { MemorySettings() } + single { Js.create() } + single { BreedViewModel(get(), getWith("BreedViewModel")) } +} + +actual val coreModule: Module = module { + single { + co.touchlab.kampkit.ktor.DogApiImpl( + getWith("DogApiImpl"), + get(), + ) + } + single { + Clock.System + } + val baseLogger = + Logger(config = StaticConfig(logWriterList = listOf(platformLogWriter())), "KampKit") + factory { (tag: String?) -> if (tag != null) baseLogger.withTag(tag) else baseLogger } + single { + HarmonyBreedRepository( + get(), + get(), + getWith("BreedRepository"), + get(), + ) + } +} diff --git a/shared/src/jsMain/kotlin/co/touchlab/kampkit/MemorySettings.kt b/shared/src/jsMain/kotlin/co/touchlab/kampkit/MemorySettings.kt new file mode 100644 index 00000000..d68ccfe4 --- /dev/null +++ b/shared/src/jsMain/kotlin/co/touchlab/kampkit/MemorySettings.kt @@ -0,0 +1,39 @@ +package co.touchlab.kampkit + +import com.russhwolf.settings.Settings + +/** + * JS/Harmony 端内存 Settings,实现 BreedRepository 所需的 getLong/putLong 等。 + * 与 multiplatform-settings 1.3 Settings 接口一致。 + */ +class MemorySettings(private val map: MutableMap = mutableMapOf()) : Settings { + override fun putLong(key: String, value: Long) { map[key] = value.toString() } + override fun getLong(key: String, defaultValue: Long): Long = map[key]?.toLongOrNull() ?: defaultValue + override fun getLongOrNull(key: String): Long? = map[key]?.toLongOrNull() + + override fun putInt(key: String, value: Int) { map[key] = value.toString() } + override fun getInt(key: String, defaultValue: Int): Int = map[key]?.toIntOrNull() ?: defaultValue + override fun getIntOrNull(key: String): Int? = map[key]?.toIntOrNull() + + override fun putString(key: String, value: String) { map[key] = value } + override fun getString(key: String, defaultValue: String): String = map[key] ?: defaultValue + override fun getStringOrNull(key: String): String? = map[key] + + override fun putFloat(key: String, value: Float) { map[key] = value.toString() } + override fun getFloat(key: String, defaultValue: Float): Float = map[key]?.toFloatOrNull() ?: defaultValue + override fun getFloatOrNull(key: String): Float? = map[key]?.toFloatOrNull() + + override fun putDouble(key: String, value: Double) { map[key] = value.toString() } + override fun getDouble(key: String, defaultValue: Double): Double = map[key]?.toDoubleOrNull() ?: defaultValue + override fun getDoubleOrNull(key: String): Double? = map[key]?.toDoubleOrNull() + + override fun putBoolean(key: String, value: Boolean) { map[key] = value.toString() } + override fun getBoolean(key: String, defaultValue: Boolean): Boolean = map[key]?.toBooleanStrictOrNull() ?: defaultValue + override fun getBooleanOrNull(key: String): Boolean? = map[key]?.toBooleanStrictOrNull() + + override fun remove(key: String) { map.remove(key) } + override fun clear() { map.clear() } + override val keys: Set get() = map.keys.toSet() + override val size: Int get() = map.size + override fun hasKey(key: String): Boolean = map.containsKey(key) +} diff --git a/shared/src/jsMain/kotlin/co/touchlab/kampkit/models/ViewModel.kt b/shared/src/jsMain/kotlin/co/touchlab/kampkit/models/ViewModel.kt new file mode 100644 index 00000000..cedb308b --- /dev/null +++ b/shared/src/jsMain/kotlin/co/touchlab/kampkit/models/ViewModel.kt @@ -0,0 +1,5 @@ +package co.touchlab.kampkit.models + +actual abstract class ViewModel actual constructor() { + protected actual open fun onCleared() {} +} diff --git a/shared/src/ohosMain/kotlin/OhosExports.kt b/shared/src/ohosMain/kotlin/OhosExports.kt new file mode 100644 index 00000000..279313d0 --- /dev/null +++ b/shared/src/ohosMain/kotlin/OhosExports.kt @@ -0,0 +1,136 @@ +/* + * KaMPKit OHOS — Kotlin/Native business logic (ohosArm64 root package). + * + * Bridge contract (generic, no business naming): + * ArkTS → Kotlin : action(type, payloadJson) / httpResponse(body) / httpError(errorJson) + * Kotlin → ArkTS : subscribeEvents(callback(eventJson)) + * + * All business logic lives here. The C++ NAPI layer is a dumb JSON pipe. + * JSON parsing uses co.touchlab.kampkit.json.JSONObject (ported from KuiklyUI, zero deps). + */ + +import co.touchlab.kampkit.json.JSONObject +import co.touchlab.kampkit.json.JSONException +import kotlinx.cinterop.* + +// ── shared state ────────────────────────────────────────────────────────────── + +@OptIn(ExperimentalForeignApi::class) +private var g_eventCallback: CPointer?) -> Unit>>? = null + +private var lastBreedNames: List = emptyList() +private val favorites = mutableMapOf() + +// ── emit helpers ────────────────────────────────────────────────────────────── + +@OptIn(ExperimentalForeignApi::class) +private fun emit(eventJson: String) { + val cb = g_eventCallback ?: return + memScoped { + val ptr: CPointer = eventJson.cstr.getPointer(this) + cb.invoke(ptr) + } +} + +// TODO: BreedViewState is serialized to JSON here and deserialized again in ArkTS parseEvent(). +// This is an unnecessary round-trip. A better approach would be a typed shared memory or +// direct struct mapping via NAPI (napi_create_object / napi_set_named_property) so ArkTS +// receives a native JS object without any JSON encoding overhead. +private fun emitState(stateJson: String) = + emit("""{"type":"state","payload":$stateJson}""") + +private fun emitContent(isLoading: Boolean) { + if (lastBreedNames.isEmpty()) { + emitState("""{"kind":"Empty","isLoading":false}""") + return + } + val breeds = lastBreedNames.mapIndexed { i, name -> + val fav = favorites[name] ?: false + """{"id":${i + 1},"name":${JSONObject.quote(name)},"favorite":$fav}""" + }.joinToString(",") + emitState("""{"kind":"Content","breeds":[$breeds],"isLoading":$isLoading}""") +} + +// ── business logic ──────────────────────────────────────────────────────────── + +private fun handleInit() { + emitState("""{"kind":"Initial","isLoading":true}""") +} + +private fun handleBreedsLoaded(body: String?) { + if (body.isNullOrBlank()) { + emitState("""{"kind":"Error","error":"Empty response","isLoading":false}""") + return + } + try { + // Raw HTTP body from ArkTS — parse Dog API format once, here, no intermediate serialization + // Format: {"message":{"breedName":[],...},"status":"success"} + val message = JSONObject(body).optJSONObject("message") + if (message == null || message.length() == 0) { + emitState("""{"kind":"Empty","isLoading":false}""") + return + } + lastBreedNames = message.keySet().sorted() + emitContent(false) + } catch (_: JSONException) { + emitState("""{"kind":"Error","error":"Parse error","isLoading":false}""") + } +} + +private fun handleBreedsError(errorJson: String?) { + val msg = if (!errorJson.isNullOrBlank()) { + try { JSONObject(errorJson).optString("error", "Network error") } + catch (_: JSONException) { "Network error" } + } else "Network error" + emitState("""{"kind":"Error","error":${JSONObject.quote(msg.take(200))},"isLoading":false}""") +} + +private fun handleRefresh() { + emitContent(true) +} + +private fun handleUpdateFavorite(paramsJson: String?) { + if (paramsJson.isNullOrBlank()) return + try { + val obj = JSONObject(paramsJson) + val name = obj.optString("name").takeIf { it.isNotEmpty() } ?: return + // ArkTS already sends the desired new state (!breed.favorite), store it directly + favorites[name] = obj.optBoolean("favorite", false) + emitContent(false) + } catch (_: JSONException) { /* ignore */ } +} + +// ── exported functions (kotlin.root.*) ──────────────────────────────────────── + +/** Raw HTTP body from ArkTS → Kotlin parses Dog API format, manages state, emits BreedViewState. */ +@OptIn(ExperimentalForeignApi::class) +fun httpResponse(body: CPointer?) { + handleBreedsLoaded(body?.toKString()) +} + +/** HTTP failure info {"error":"..."} from ArkTS → Kotlin emits Error state. */ +@OptIn(ExperimentalForeignApi::class) +fun httpError(json: CPointer?) { + handleBreedsError(json?.toKString()) +} + +/** + * Lifecycle / user action from ArkTS. + * type : action name ("init" | "refresh" | "updateFavorite") + * payload : JSON string with action-specific data + */ +@OptIn(ExperimentalForeignApi::class) +fun action(type: CPointer?, payload: CPointer?) { + val p = payload?.toKString() + when (type?.toKString()) { + "init" -> handleInit() + "refresh" -> handleRefresh() + "updateFavorite" -> handleUpdateFavorite(p) + } +} + +/** ArkTS subscribes once; Kotlin pushes all state changes as JSON events. */ +@OptIn(ExperimentalForeignApi::class) +fun subscribeEvents(callback: CPointer?) -> Unit>>?) { + g_eventCallback = callback +} diff --git a/shared/src/ohosMain/kotlin/co/touchlab/kampkit/json/JSON.kt b/shared/src/ohosMain/kotlin/co/touchlab/kampkit/json/JSON.kt new file mode 100644 index 00000000..f716d498 --- /dev/null +++ b/shared/src/ohosMain/kotlin/co/touchlab/kampkit/json/JSON.kt @@ -0,0 +1,115 @@ +package co.touchlab.kampkit.json + +/** + * Created by kam on 2022/4/11. + */ +object JSON { + + var useNativeMethod: Boolean = false + + fun toBoolean(value: Any?): Boolean? { + if (value is Boolean) { + return value + } else if (value is String) { + if ("true".equals(value, ignoreCase = true)) { + return true + } else if ("false".equals(value, ignoreCase = true)) { + return false + } + } else if (value is Number) { + return value.toInt() != 0 + } + return null + } + + fun toDouble(value: Any?): Double? { + when (value) { + is Double -> { + return value + } + is Number -> { + return value.toDouble() + } + is String -> { + try { + return value.toDouble() + } catch (ignored: NumberFormatException) { + //TODO:LOG + } + } + } + return null + } + + fun toInteger(value: Any?): Int? { + when (value) { + is Int -> { + return value + } + is Number -> { + return value.toInt() + } + is String -> { + try { + return value.toInt() + } catch (ignored: NumberFormatException) { + //TODO LOG + } + } + } + return null + } + + fun toLong(value: Any?): Long? { + when (value) { + is Long -> { + return value + } + is Number -> { + return value.toLong() + } + is String -> { + try { + return value.toLong() + } catch (ignored: NumberFormatException) { + // TODO: LOG + } + } + } + return null + } + + fun toString(value: Any?): String? { + if (value is String) { + return value + } else if (value != null) { + return value.toString() + } + return null + } + + @Throws(JSONException::class) + fun numberToString(number: Number): String { + val doubleValue = number.toDouble() + val longValue = number.toLong() + return if (doubleValue == longValue.toDouble()) { + longValue.toString() + } else { + number.toString() + } + } + + @Throws(JSONException::class) + fun typeMismatch(actual: Any?, requiredType: String): JSONException { + if (actual == null) { + throw JSONException("Value is null.") + } else { + throw JSONException( + "Value " + actual + + " of type " + actual::class.simpleName + + " cannot be converted to " + requiredType + ) + } + } + +} \ No newline at end of file diff --git a/shared/src/ohosMain/kotlin/co/touchlab/kampkit/json/JSONArray.kt b/shared/src/ohosMain/kotlin/co/touchlab/kampkit/json/JSONArray.kt new file mode 100644 index 00000000..f660e61e --- /dev/null +++ b/shared/src/ohosMain/kotlin/co/touchlab/kampkit/json/JSONArray.kt @@ -0,0 +1,207 @@ +package co.touchlab.kampkit.json + +/** + * Created by kam on 2022/4/11. + */ +class JSONArray internal constructor(values: MutableList) { + + internal var values: MutableList + + init { + this.values = values + } + + constructor(): this(JSONEngine.getMutableList()) + + @Throws(JSONException::class) + constructor(json: String) : this( + (JSONEngine.parse(json).let { it as? JSONArray + ?: throw JSON.typeMismatch(it, "JSONArray") }).values + ) + + @Throws(JSONException::class) + constructor(jsonTokener: JSONTokener) : this( + (jsonTokener.nextValue().let { it as? JSONArray + ?: throw JSON.typeMismatch(it, "JSONArray") }).values + ) + + /** + * Returns the number of values in this array. + */ + fun length(): Int { + return values.size + } + + fun put(value: Boolean): JSONArray { + values.add(value) + return this + } + + fun put(value: Double): JSONArray { + values.add(value) + return this + } + + fun put(value: Int): JSONArray { + values.add(value) + return this + } + + fun put(value: Long): JSONArray { + values.add(value) + return this + } + + fun put(value: Any?): JSONArray { + values.add(value) + return this + } + + fun opt(index: Int): Any? { + return if (index < 0 || index >= values.size) { + null + } else { + values[index] + } + } + + fun remove(index: Int): Any? { + return if (index < 0 || index >= values.size) { + null + } else { + values.removeAt(index) + } + } + + fun optBoolean(index: Int): Boolean { + return optBoolean(index, false) + } + + fun optBoolean(index: Int, fallback: Boolean): Boolean { + val o = opt(index) + val result = JSON.toBoolean(o) + return result ?: fallback + } + + fun optDouble(index: Int): Double { + return optDouble(index, Double.NaN) + } + + fun optDouble(index: Int, fallback: Double): Double { + val o = opt(index) + val result = JSON.toDouble(o) + return result ?: fallback + } + + fun optInt(index: Int): Int { + return optInt(index, 0) + } + + fun optInt(index: Int, fallback: Int): Int { + val o = opt(index) + val result = JSON.toInteger(o) + return result ?: fallback + } + + fun optLong(index: Int): Long { + return optLong(index, 0L) + } + + fun optLong(index: Int, fallback: Long): Long { + val o = opt(index) + val result = JSON.toLong(o) + return result ?: fallback + } + + fun optString(index: Int): String? { + return optString(index, "") + } + + fun optString(index: Int, fallback: String?): String? { + val o = opt(index) + val result = JSON.toString(o) + return result ?: fallback + } + + fun optJSONArray(index: Int): JSONArray? { + val o = opt(index) + return if (o is JSONArray) { + o + } else { + null + } + } + + fun optJSONObject(index: Int): JSONObject? { + val o = opt(index) + return if (o is JSONObject) { + o + } else { + null + } + } + + override fun toString(): String { + return try { + JSONEngine.stringify(this) + } catch (e: JSONException) { + "[]" + } + } + + fun toList(): MutableList { + val list = mutableListOf() + val size = length() + for (i in 0 until size) { + when (val value = opt(i)) { + is Int -> { + list.add(value) + } + is Long -> { + list.add(value) + } + is Float -> { + list.add(value) + } + is Double -> { + list.add(value) + } + is String -> { + list.add(value) + } + is Boolean -> { + list.add(value) + } + is JSONObject -> { + list.add(value.toMap()) + } + is JSONArray -> { + list.add(value.toList()) + } + } + } + return list + } + + @Throws(JSONException::class) + internal fun writeTo(stringer: JSONStringer) { + stringer.startArray() + for (value in values) { + if (value == null) { + stringer.value(null) + } else { + stringer.value(value) + } + } + stringer.endArray() + } + + override fun equals(other: Any?): Boolean { + return other is JSONArray && other.values == values + } + + override fun hashCode(): Int { + // diverge from the original, which doesn't implement hashCode + return values.hashCode() + } +} \ No newline at end of file diff --git a/shared/src/ohosMain/kotlin/co/touchlab/kampkit/json/JSONEngine.kt b/shared/src/ohosMain/kotlin/co/touchlab/kampkit/json/JSONEngine.kt new file mode 100644 index 00000000..af07558b --- /dev/null +++ b/shared/src/ohosMain/kotlin/co/touchlab/kampkit/json/JSONEngine.kt @@ -0,0 +1,28 @@ +package co.touchlab.kampkit.json + +internal object JSONEngine { + + fun parse(jsonStr: String): Any? { + return JSONTokener(jsonStr).nextValue() + } + + fun stringify(jsonObject: JSONObject) = commonStringify(jsonObject) + + fun stringify(jsonArray: JSONArray) = commonStringify(jsonArray) + + fun getMutableMap(): MutableMap = mutableMapOf() + + fun getMutableList(): MutableList = mutableListOf() +} + +fun commonStringify(jsonObject: JSONObject): String { + val stringer = JSONStringer() + jsonObject.writeTo(stringer) + return stringer.toString() +} + +fun commonStringify(jsonArray: JSONArray): String { + val stringer = JSONStringer() + jsonArray.writeTo(stringer) + return stringer.toString() +} diff --git a/shared/src/ohosMain/kotlin/co/touchlab/kampkit/json/JSONException.kt b/shared/src/ohosMain/kotlin/co/touchlab/kampkit/json/JSONException.kt new file mode 100644 index 00000000..5a4d2519 --- /dev/null +++ b/shared/src/ohosMain/kotlin/co/touchlab/kampkit/json/JSONException.kt @@ -0,0 +1,10 @@ +package co.touchlab.kampkit.json + +/** + * Created by kam on 2022/4/11. + */ +class JSONException : Exception { + + constructor(s: String) : super(s) + +} \ No newline at end of file diff --git a/shared/src/ohosMain/kotlin/co/touchlab/kampkit/json/JSONObject.kt b/shared/src/ohosMain/kotlin/co/touchlab/kampkit/json/JSONObject.kt new file mode 100644 index 00000000..264842c5 --- /dev/null +++ b/shared/src/ohosMain/kotlin/co/touchlab/kampkit/json/JSONObject.kt @@ -0,0 +1,201 @@ +package co.touchlab.kampkit.json + +/** + * Created by kam on 2022/4/11. + */ +class JSONObject internal constructor(nameValuePairs: MutableMap) { + companion object { + fun quote(data: String?): String { + if (data == null) { + return "\"\"" + } + val stringer = JSONStringer() + stringer.open(JSONStringer.Scope.NULL_OBJ, "") + stringer.value(data) + stringer.close(JSONStringer.Scope.NULL_OBJ, JSONStringer.Scope.NULL_OBJ, "") + return stringer.toString() + } + } + + internal var nameValuePairs: MutableMap + + init { + this.nameValuePairs = nameValuePairs + } + + constructor(): this(JSONEngine.getMutableMap()) + + @Throws(JSONException::class) + constructor(json: String) : this( + (JSONEngine.parse(json).let { it as? JSONObject + ?: throw JSON.typeMismatch(it, "JSONObject") }).nameValuePairs + ) + + @Throws(JSONException::class) + constructor(jsonTokener: JSONTokener) : this( + (jsonTokener.nextValue().let { it as? JSONObject + ?: throw JSON.typeMismatch(it, "JSONObject") }).nameValuePairs + ) + + fun length(): Int { + return nameValuePairs.size + } + + fun put(name: String, value: Boolean): JSONObject { + nameValuePairs[name] = value + return this + } + + fun put(name: String, value: Int): JSONObject { + nameValuePairs[name] = value + return this + } + + fun put(name: String, value: Long): JSONObject { + nameValuePairs[name] = value + return this + } + + fun put(name: String, value: Double): JSONObject { + nameValuePairs[name] = value + return this + } + + fun put(name: String, value: Any?): JSONObject { + nameValuePairs[name] = value + return this + } + + fun has(name: String): Boolean { + return nameValuePairs.containsKey(name) + } + + fun opt(name: String): Any? { + return nameValuePairs[name] + } + + fun optBoolean(name: String): Boolean { + return optBoolean(name, false) + } + + fun optBoolean(name: String, fallback: Boolean): Boolean { + val o = opt(name) + val result = JSON.toBoolean(o) + return result ?: fallback + } + + fun optDouble(name: String): Double { + return optDouble(name, 0.0) + } + + fun optDouble(name: String, fallback: Double): Double { + val o = opt(name) + val result = JSON.toDouble(o) + return result ?: fallback + } + + fun optInt(name: String): Int { + return optInt(name, 0) + } + + fun optInt(name: String, fallback: Int): Int { + val o = opt(name) + val result = JSON.toInteger(o) + return result ?: fallback + } + + fun optLong(name: String): Long { + return optLong(name, 0L) + } + + fun optLong(name: String, fallback: Long): Long { + val o = opt(name) + val result = JSON.toLong(o) + return result ?: fallback + } + + fun optString(name: String): String { + return optString(name, "") + } + + fun optString(name: String, fallback: String): String { + val o = opt(name) + val result = JSON.toString(o) + return result ?: fallback + } + + fun optJSONArray(name: String): JSONArray? { + return when (val value = opt(name)) { + is JSONArray -> value + is String -> try { JSONArray(value) } catch (_: JSONException) { null } + else -> null + } + } + + fun optJSONObject(name: String): JSONObject? { + return when (val value = opt(name)) { + is JSONObject -> value + is String -> try { JSONObject(value) } catch (_: JSONException) { null } + else -> null + } + } + + fun keys(): Iterator { + return nameValuePairs.keys.iterator() + } + + fun keySet(): Set { + return nameValuePairs.keys + } + + override fun toString(): String { + return try { + JSONEngine.stringify(this) + } catch (e: JSONException) { + "{}" + } + } + + fun toMap(): MutableMap { + val map = mutableMapOf() + val keys = keys() + for (key in keys) { + when (val value = opt(key)) { + is Int -> { + map[key] = value + } + is Long -> { + map[key] = value + } + is Double -> { + map[key] = value + } + is Float -> { + map[key] = value + } + is String -> { + map[key] = value + } + is Boolean -> { + map[key] = value + } + is JSONObject -> { + map[key] = value.toMap() + } + is JSONArray -> { + map[key] = value.toList() + } + } + } + return map + } + + @Throws(JSONException::class) + internal fun writeTo(stringer: JSONStringer) { + stringer.startObject() + for ((key, value) in nameValuePairs) { + stringer.key(key).value(value) + } + stringer.endObject() + } +} \ No newline at end of file diff --git a/shared/src/ohosMain/kotlin/co/touchlab/kampkit/json/JSONStringer.kt b/shared/src/ohosMain/kotlin/co/touchlab/kampkit/json/JSONStringer.kt new file mode 100644 index 00000000..d8232526 --- /dev/null +++ b/shared/src/ohosMain/kotlin/co/touchlab/kampkit/json/JSONStringer.kt @@ -0,0 +1,186 @@ +package co.touchlab.kampkit.json + +class JSONStringer { + + private val stack = arrayListOf() + private val out = StringBuilder() + + enum class Scope { + EMPTY_ARRAY, + NONEMPTY_ARRAY, + EMPTY_OBJECT, + DANGLING_KEY, + NONEMPTY_OBJECT, + NULL_OBJ + } + + @Throws(JSONException::class) + fun startObject(): JSONStringer { + return open(Scope.EMPTY_OBJECT, "{") + } + + @Throws(JSONException::class) + fun endObject(): JSONStringer? { + return close(Scope.EMPTY_OBJECT, Scope.NONEMPTY_OBJECT, "}") + } + + @Throws(JSONException::class) + fun startArray(): JSONStringer { + return open(Scope.EMPTY_ARRAY, "[") + } + + @Throws(JSONException::class) + fun endArray(): JSONStringer { + return close(Scope.EMPTY_ARRAY, Scope.NONEMPTY_ARRAY, "]") + } + + @Throws(JSONException::class) + fun key(name: String): JSONStringer { + beforeKey() + string(name) + return this + } + + @Throws(JSONException::class) + fun value(value: Any?): JSONStringer { + if (stack.isEmpty()) { + throw JSONException("Nesting problem") + } + if (value is JSONArray) { + value.writeTo(this) + return this + } else if (value is JSONObject) { + value.writeTo(this) + return this + } + beforeValue() + when (value) { + is Boolean -> { + out.append(value) + } + is Number -> { + out.append(JSON.numberToString(value)) + } + else -> { + if (value == null) { + out.append("null") + } else { + string(value.toString()) + } + } + } + return this + } + + @Throws(JSONException::class) + fun open(empty: Scope, openBracket: String): JSONStringer { + if (stack.isEmpty() && out.isNotEmpty()) { + throw JSONException("Nesting problem: multiple top-level roots") + } + beforeValue() + stack.add(empty) + out.append(openBracket) + return this + } + + @Throws(JSONException::class) + private fun beforeValue() { + if (stack.isEmpty()) { + return + } + val context = peek() + when { + context == Scope.EMPTY_ARRAY -> { // first in array + replaceTop(Scope.NONEMPTY_ARRAY) + newline() + } + context == Scope.NONEMPTY_ARRAY -> { // another in array + out.append(',') + newline() + } + context == Scope.DANGLING_KEY -> { // value for key + out.append(": ") + replaceTop(Scope.NONEMPTY_OBJECT) + } + context != Scope.NULL_OBJ -> { + throw JSONException("Nesting problem") + } + } + } + + @Throws(JSONException::class) + private fun peek(): Scope { + if (stack.isEmpty()) { + throw JSONException("Nesting problem") + } + return stack[stack.size - 1] + } + + private fun replaceTop(topOfStack: Scope) { + stack[stack.size - 1] = topOfStack + } + + private fun newline() { + } + + @Throws(JSONException::class) + private fun beforeKey() { + val context = peek() + if (context == Scope.NONEMPTY_OBJECT) { // first in object + out.append(',') + } else if (context != Scope.EMPTY_OBJECT) { // not in an object! + throw JSONException("Nesting problem") + } + newline() + replaceTop(Scope.DANGLING_KEY) + } + + private fun string(value: String) { + out.append("\"") + var i = 0 + val length = value.length + while (i < length) { + val c = value[i] + when (c) { + '"', '\\', '/' -> out.append('\\').append(c) + '\t' -> out.append("\\t") + '\b' -> out.append("\\b") + '\n' -> out.append("\\n") + '\r' -> out.append("\\r") + else -> if (c.toInt() <= 0x1F) { + out.append("\\u${c.toInt().toString(16).padStart(4, '0')}") + } else { + out.append(c) + } + } + i++ + } + out.append("\"") + } + + @Throws(JSONException::class) + fun close( + empty: Scope, + nonempty: Scope, + closeBracket: String + ): JSONStringer { + val context = peek() + if (context != nonempty && context != empty) { + throw JSONException("Nesting problem") + } + stack.removeAt(stack.size - 1) + if (context == nonempty) { + newline() + } + out.append(closeBracket) + return this + } + + override fun toString(): String { + return if (out.isEmpty()) { + "{}" + } else { + out.toString() + } + } +} \ No newline at end of file diff --git a/shared/src/ohosMain/kotlin/co/touchlab/kampkit/json/JSONTokener.kt b/shared/src/ohosMain/kotlin/co/touchlab/kampkit/json/JSONTokener.kt new file mode 100644 index 00000000..92c5e372 --- /dev/null +++ b/shared/src/ohosMain/kotlin/co/touchlab/kampkit/json/JSONTokener.kt @@ -0,0 +1,292 @@ +package co.touchlab.kampkit.json + +/** + * Created by kam on 2022/4/12. + */ +class JSONTokener(json: String) { + + private var jsonStr: String = json + + private var pos = 0 + + init { + if (json.startsWith("\uFEFF")) { + jsonStr = jsonStr.substring(1) + } + } + + @Throws(JSONException::class) + fun nextValue(): Any? { + val c: Int = nextCleanInternal() + return when (c) { + -1 -> throw syntaxError("End of input") + '{'.toInt() -> readObject() + '['.toInt() -> readArray() + '\''.toInt(), '"'.toInt() -> nextString(c.toChar()) + else -> { + pos-- + readLiteral() + } + } + } + + @Throws(JSONException::class) + private fun nextCleanInternal(): Int { + loop@ while (pos < jsonStr.length) { + val c = jsonStr[pos++] + return when (c) { + '\t', ' ', '\n', '\r' -> continue@loop + '/' -> { + if (pos == jsonStr.length) { + return c.toInt() + } + val peek = jsonStr[pos] + when (peek) { + '*' -> { + // skip a /* c-style comment */ + pos++ + val commentEnd: Int = jsonStr.indexOf("*/", pos) + if (commentEnd == -1) { + throw syntaxError("Unterminated comment") + } + pos = commentEnd + 2 + continue@loop + } + '/' -> { + // skip a // end-of-line comment + pos++ + skipToEndOfLine() + continue@loop + } + else -> c.toInt() + } + } + '#' -> { + /* + * Skip a # hash end-of-line comment. The JSON RFC doesn't + * specify this behavior, but it's required to parse + * existing documents. See http://b/2571423. + */skipToEndOfLine() + continue@loop + } + else -> c.toInt() + } + } + + return -1 + } + + private fun syntaxError(message: String): JSONException { + return JSONException(message + this) + } + + private fun skipToEndOfLine() { + while (pos < jsonStr.length) { + val c: Char = jsonStr[pos] + if (c == '\r' || c == '\n') { + pos++ + break + } + pos++ + } + } + + @Throws(JSONException::class) + private fun readObject(): JSONObject { + val result = JSONObject() + + /* Peek to see if this is the empty object. */ + val first = nextCleanInternal() + if (first == '}'.toInt()) { + return result + } else if (first != -1) { + pos-- + } + loop@ while (true) { + val name = nextValue() + if (name !is String) { + throw syntaxError( + "Names must be strings, but " + name + + " is of type " + + if (name === null) { + "null" + } else { + name::class.simpleName + } + ) + } + + /* + * Expect the name/value separator to be either a colon ':', an + * equals sign '=', or an arrow "=>". The last two are bogus but we + * include them because that's what the original implementation did. + */ + val separator = nextCleanInternal() + if (separator != ':'.toInt() && separator != '='.toInt()) { + throw syntaxError("Expected ':' after $name") + } + if (pos < jsonStr.length && jsonStr[pos].equals('>')) { + pos++ + } + result.put((name as String?)!!, nextValue()) + when (nextCleanInternal()) { + '}'.toInt() -> return result + ';'.toInt(), ','.toInt() -> continue@loop + else -> throw syntaxError("Unterminated object") + } + } + } + + @Throws(JSONException::class) + private fun readArray(): JSONArray { + val result = JSONArray() + + /* to cover input that ends with ",]". */ + + /* to cover input that ends with ",]". */ + var hasTrailingSeparator = false + + loop@ while (true) { + when (nextCleanInternal()) { + -1 -> throw syntaxError("Unterminated array") + ']'.toInt() -> { + return result + } + ','.toInt(), ';'.toInt() -> continue@loop + else -> pos-- + } + result.put(nextValue()) + return when (nextCleanInternal()) { + ']'.toInt() -> result + ','.toInt(), ';'.toInt() -> continue@loop + else -> throw syntaxError("Unterminated array") + } + } + } + + @Throws(JSONException::class) + fun nextString(quote: Char): String { + /* + * For strings that are free of escape sequences, we can just extract + * the result as a substring of the input. But if we encounter an escape + * sequence, we need to use a StringBuilder to compose the result. + */ + var builder: StringBuilder? = null + + /* the index of the first character not yet appended to the builder. */ + var start = pos + while (pos < jsonStr.length) { + val c = jsonStr[pos++] + if (c.toInt() == quote.toInt()) { + return if (builder == null) { + jsonStr.substring(start, pos - 1) + "" //保证生成字符串对象,避免内存泄漏 + } else { + builder.append(jsonStr, start, pos - 1) + builder.toString() + } + } + if (c.toInt() == '\\'.toInt()) { + if (pos == jsonStr.length) { + throw syntaxError("Unterminated escape sequence") + } + if (builder == null) { + builder = StringBuilder() + } + builder.append(jsonStr, start, pos - 1) + builder.append(readEscapeCharacter()) + start = pos + } + } + throw syntaxError("Unterminated string") + } + + @Throws(JSONException::class) + private fun readEscapeCharacter(): Char { + val escaped = jsonStr[pos++] + return when (escaped) { + 'u' -> { + if (pos + 4 > jsonStr.length) { + throw syntaxError("Unterminated escape sequence") + } + val hex: String = jsonStr.substring(pos, pos + 4) + pos += 4 + return try { + hex.toInt(16).toChar() + } catch (nfe: NumberFormatException) { + throw syntaxError("Invalid escape sequence: $hex") + } + } + 't' -> '\t' + 'b' -> '\b' + 'n' -> '\n' + 'r' -> '\r' + 'f' -> '\u000C' // '\f' + '\'', '"', '\\' -> escaped + else -> escaped + } + } + + @Throws(JSONException::class) + private fun readLiteral(): Any? { + val literal: String = nextToInternal("{}[]/\\:,=;# \t") + when { + literal.isEmpty() -> { + throw syntaxError("Expected literal value") + } + "true" == literal -> { + return true + } + "false" == literal -> { + return false + } + "null" == literal -> { + return null + } + literal.indexOf('.') == -1 -> { + var base = 10 + var number = literal + if (number.startsWith("0x") || number.startsWith("0X")) { + number = number.substring(2) + base = 16 + } else if (number.startsWith("0") && number.length > 1) { + number = number.substring(1) + base = 8 + } + try { + val longValue = number.toLong(base) + return if (longValue <= Int.MAX_VALUE && longValue >= Int.MIN_VALUE) { + longValue.toInt() + } else { + longValue + } + } catch (e: NumberFormatException) { + /* + * This only happens for integral numbers greater than + * Long.MAX_VALUE, numbers in exponential form (5e-10) and + * unquoted strings. Fall through to try floating point. + */ + } + } + } + + try { + return literal.toDouble() + } catch (ignored: NumberFormatException) { + } + + return literal + "" // a new string avoids leaking memory + } + + private fun nextToInternal(excluded: String): String { + val start = pos + while (pos < jsonStr.length) { + val c: Char = jsonStr[pos] + if (c == '\r' || c == '\n' || excluded.indexOf(c) != -1) { + return jsonStr.substring(start, pos) + } + pos++ + } + return jsonStr.substring(start) + } + +} \ No newline at end of file