Compare commits
1 Commits
feature/pu
...
cf466a3959
| Author | SHA1 | Date | |
|---|---|---|---|
| cf466a3959 |
@@ -1,15 +0,0 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(python -c \"import sys,json; d=json.load\\(sys.stdin\\); deps={**d.get\\('dependencies',{}\\), **d.get\\('devDependencies',{}\\)}; [print\\(f'{k}: {v}'\\) for k,v in deps.items\\(\\) if 'navigation' in k or 'tab' in k.lower\\(\\)]\")",
|
||||
"Bash(npx tsc *)",
|
||||
"Bash(echo \"EXIT: $?\")",
|
||||
"Bash(npm view *)",
|
||||
"Bash(npx patch-package *)",
|
||||
"Bash(rm -rf node_modules/expo-router)",
|
||||
"Bash(npm install *)",
|
||||
"Bash(python -c \"import struct, os; paths=['D:/codes/carrot_bbs/frontend/assets/splash-icon.png','D:/codes/carrot_bbs/frontend/assets/icon.png','D:/codes/carrot_bbs/frontend/assets/android-icon-foreground.png','D:/codes/carrot_bbs/frontend/assets/android-icon-background.png','D:/codes/carrot_bbs/frontend/android/app/src/main/res/drawable-mdpi/splashscreen_logo.png','D:/codes/carrot_bbs/frontend/android/app/src/main/res/drawable-hdpi/splashscreen_logo.png','D:/codes/carrot_bbs/frontend/android/app/src/main/res/drawable-xhdpi/splashscreen_logo.png','D:/codes/carrot_bbs/frontend/android/app/src/main/res/drawable-xxhdpi/splashscreen_logo.png','D:/codes/carrot_bbs/frontend/android/app/src/main/res/drawable-xxxhdpi/splashscreen_logo.png'];\\\\nfor p in paths:\\\\n try:\\\\n with open\\(p,'rb'\\) as f: data=f.read\\(33\\)\\\\n sig=data[:8]; w=h=None\\\\n if sig==b'\\\\\\\\x89PNG\\\\\\\\r\\\\\\\\n\\\\\\\\x1a\\\\\\\\n': w,h=struct.unpack\\('>II', data[16:24]\\)\\\\n print\\(f'{p} | exists={os.path.exists\\(p\\)} | bytes={os.path.getsize\\(p\\)} | png={sig==b\"\\\\\\\\x89PNG\\\\\\\\r\\\\\\\\n\\\\\\\\x1a\\\\\\\\n\"} | size={w}x{h}'\\)\\\\n except Exception as e:\\\\n print\\(f'{p} | ERROR {e}'\\)\")",
|
||||
"Bash(python -c 'import struct, os; paths=[\"D:/codes/carrot_bbs/frontend/assets/splash-icon.png\",\"D:/codes/carrot_bbs/frontend/assets/icon.png\",\"D:/codes/carrot_bbs/frontend/assets/android-icon-foreground.png\",\"D:/codes/carrot_bbs/frontend/assets/android-icon-background.png\",\"D:/codes/carrot_bbs/frontend/android/app/src/main/res/drawable-mdpi/splashscreen_logo.png\",\"D:/codes/carrot_bbs/frontend/android/app/src/main/res/drawable-hdpi/splashscreen_logo.png\",\"D:/codes/carrot_bbs/frontend/android/app/src/main/res/drawable-xhdpi/splashscreen_logo.png\",\"D:/codes/carrot_bbs/frontend/android/app/src/main/res/drawable-xxhdpi/splashscreen_logo.png\",\"D:/codes/carrot_bbs/frontend/android/app/src/main/res/drawable-xxxhdpi/splashscreen_logo.png\"]; sig_png=b\"\\\\x89PNG\\\\r\\\\n\\\\x1a\\\\n\"; [print\\(f\"{p} | bytes={os.path.getsize\\(p\\)} | png={open\\(p,\"rb\"\\).read\\(8\\)==sig_png} | size={struct.unpack\\(\">II\", open\\(p,\"rb\"\\).read\\(24\\)[16:24]\\) if open\\(p,\"rb\"\\).read\\(8\\)==sig_png else None}\"\\) for p in paths]')"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -1,40 +1,10 @@
|
||||
# Dependencies
|
||||
node_modules
|
||||
|
||||
# Source control
|
||||
.git
|
||||
.gitignore
|
||||
|
||||
# Expo caches
|
||||
.expo
|
||||
.expo-shared
|
||||
|
||||
# Build artifacts
|
||||
dist
|
||||
dist-web
|
||||
dist-android
|
||||
dist-ios
|
||||
dist-android-update.zip
|
||||
dist-ios-update.zip
|
||||
android
|
||||
ios
|
||||
web-build
|
||||
|
||||
# Native credentials (never ship into image)
|
||||
*.jks
|
||||
*.p8
|
||||
*.p12
|
||||
*.key
|
||||
*.mobileprovision
|
||||
*.orig.*
|
||||
|
||||
# Misc
|
||||
screenshots
|
||||
*.log
|
||||
.vscode
|
||||
.idea
|
||||
.DS_Store
|
||||
*.swp
|
||||
*.swo
|
||||
coverage
|
||||
__tests__
|
||||
|
||||
@@ -21,18 +21,16 @@ on:
|
||||
env:
|
||||
REGISTRY: code.littlelan.cn
|
||||
IMAGE_NAME: carrot_bbs/frontend-web
|
||||
OTA_PLATFORM: android
|
||||
OTA_PUBLISH_URL: https://updates.littlelan.cn/admin/publish
|
||||
OTA_MANIFEST_URL: https://updates.littlelan.cn/api/manifest
|
||||
|
||||
jobs:
|
||||
ota:
|
||||
ota-android:
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event_name != 'pull_request' && (github.event_name != 'workflow_dispatch' || github.event.inputs.publish_ota == 'true')
|
||||
permissions:
|
||||
contents: read
|
||||
strategy:
|
||||
matrix:
|
||||
platform: [android, ios]
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
@@ -41,6 +39,7 @@ jobs:
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '25.6.1'
|
||||
cache: 'npm'
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
@@ -48,61 +47,63 @@ jobs:
|
||||
- name: Resolve runtime version
|
||||
id: runtime
|
||||
run: |
|
||||
RUNTIME_VERSION="$(node -p "require('./app.json').expo.runtimeVersion")"
|
||||
RUNTIME_VERSION="$(node -p "require('./app.json').expo.version")"
|
||||
echo "runtime_version=${RUNTIME_VERSION}" >> "$GITHUB_OUTPUT"
|
||||
echo "Resolved runtimeVersion: ${RUNTIME_VERSION}"
|
||||
|
||||
- name: Export update bundle
|
||||
- name: Export Android update bundle
|
||||
run: |
|
||||
rm -rf dist-${{ matrix.platform }} dist-${{ matrix.platform }}-update.zip
|
||||
npx expo export --platform ${{ matrix.platform }} --output-dir dist-${{ matrix.platform }}
|
||||
npx expo config --type public --json > dist-${{ matrix.platform }}/expoConfig.json
|
||||
rm -rf dist-android dist-android-update.zip
|
||||
npx expo export --platform android --output-dir dist-android
|
||||
npx expo config --type public --json > dist-android/expoConfig.json
|
||||
|
||||
- name: Archive update bundle
|
||||
- name: Archive Android update bundle
|
||||
run: |
|
||||
python -c "
|
||||
import os, zipfile
|
||||
p = '${{ matrix.platform }}'
|
||||
dist = f'dist-{p}'
|
||||
with zipfile.ZipFile(f'dist-{p}-update.zip', 'w', zipfile.ZIP_DEFLATED) as zf:
|
||||
for root, _, files in os.walk(dist):
|
||||
python - <<'PY'
|
||||
import os
|
||||
import zipfile
|
||||
|
||||
with zipfile.ZipFile('dist-android-update.zip', 'w', zipfile.ZIP_DEFLATED) as zf:
|
||||
for root, _, files in os.walk('dist-android'):
|
||||
for name in files:
|
||||
src = os.path.join(root, name)
|
||||
arc = os.path.relpath(src, dist)
|
||||
arc = os.path.relpath(src, 'dist-android')
|
||||
zf.write(src, arc)
|
||||
"
|
||||
PY
|
||||
|
||||
- name: Publish OTA
|
||||
env:
|
||||
OTA_AUTH_TOKEN: ${{ secrets.OTA_AUTH_TOKEN }}
|
||||
run: |
|
||||
test -n "${OTA_AUTH_TOKEN}" || (echo "Missing secret OTA_AUTH_TOKEN" && exit 1)
|
||||
curl -fSs -X POST "${OTA_PUBLISH_URL}?runtimeVersion=${{ steps.runtime.outputs.runtime_version }}&platform=${{ matrix.platform }}" \
|
||||
curl -fSs -X POST "${OTA_PUBLISH_URL}?runtimeVersion=${{ steps.runtime.outputs.runtime_version }}&platform=${OTA_PLATFORM}" \
|
||||
-H "Authorization: Bearer ${OTA_AUTH_TOKEN}" \
|
||||
-H "Content-Type: application/zip" \
|
||||
--data-binary @"dist-${{ matrix.platform }}-update.zip"
|
||||
--data-binary @"dist-android-update.zip"
|
||||
|
||||
- name: Verify OTA manifest
|
||||
run: |
|
||||
REMOTE="$(curl -sS "${OTA_MANIFEST_URL}" \
|
||||
-H "expo-platform: ${{ matrix.platform }}" \
|
||||
-H "expo-platform: ${OTA_PLATFORM}" \
|
||||
-H "expo-runtime-version: ${{ steps.runtime.outputs.runtime_version }}" \
|
||||
-H "expo-protocol-version: 1" \
|
||||
| python -c "import sys, json, re; s=sys.stdin.read(); m=re.search(r'\\{\"id\":.*\"extra\":\{.*\}\}', s); j=json.loads(m.group(0)); print(j['launchAsset']['url'].split('/')[-1].split('&')[0])")"
|
||||
LOCAL="$(python -c "import json; m=json.load(open('dist-${{ matrix.platform }}/metadata.json')); print(m['fileMetadata']['${{ matrix.platform }}']['bundle'].split('/')[-1])")"
|
||||
| python -c "import sys, json, re; s=sys.stdin.read(); m=re.search(r'\\{\\\"id\\\":.*\\\"extra\\\":\\{.*\\}\\}', s); j=json.loads(m.group(0)); print(j['launchAsset']['url'].split('/')[-1].split('&')[0])")"
|
||||
LOCAL="$(python -c "import json; m=json.load(open('dist-android/metadata.json')); print(m['fileMetadata']['android']['bundle'].split('/')[-1])")"
|
||||
echo "Remote bundle: ${REMOTE}"
|
||||
echo "Local bundle: ${LOCAL}"
|
||||
test "${REMOTE}" = "${LOCAL}"
|
||||
|
||||
build-android-apk:
|
||||
runs-on: android-builder
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: reactnativecommunity/react-native-android:latest
|
||||
env:
|
||||
GRADLE_OPTS: "-Dorg.gradle.daemon=false -Dorg.gradle.parallel=false -Dorg.gradle.workers.max=1"
|
||||
NODE_OPTIONS: "--max-old-space-size=2048"
|
||||
GRADLE_OPTS: "-Dorg.gradle.daemon=false -Dorg.gradle.parallel=true -Dorg.gradle.workers.max=4 -Xmx8g -XX:MaxMetaspaceSize=1g -XX:+UseG1GC -XX:SoftRefLRUPolicyMSPerMB=0 -XX:ReservedCodeCacheSize=512m"
|
||||
_JAVA_OPTIONS: "-Xmx8g -XX:MaxMetaspaceSize=1g -XX:+UseG1GC -XX:SoftRefLRUPolicyMSPerMB=0 -XX:ReservedCodeCacheSize=512m"
|
||||
NODE_OPTIONS: "--max-old-space-size=8192"
|
||||
NODE_ENV: "production"
|
||||
GRADLE_USER_HOME: /root/.gradle
|
||||
NDK_NUM_JOBS: "4"
|
||||
CMAKE_BUILD_PARALLEL_LEVEL: "4"
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
@@ -119,6 +120,7 @@ jobs:
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '25.6.1'
|
||||
cache: 'npm'
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
@@ -126,21 +128,21 @@ jobs:
|
||||
- name: Generate Android native project
|
||||
run: npx expo prebuild --platform android
|
||||
|
||||
- name: Configure Gradle with signing
|
||||
- name: Configure Gradle with Aliyun Maven mirror
|
||||
run: |
|
||||
cd android
|
||||
|
||||
# Decode Android signing keystore
|
||||
echo "${{ secrets.ANDROID_KEYSTORE_BASE64 }}" | base64 -d > app/withyou-release-key.keystore
|
||||
|
||||
# Update settings.gradle
|
||||
# Update settings.gradle with Aliyun Maven mirror
|
||||
cat > settings.gradle << 'SETTINGS_EOF'
|
||||
pluginManagement {
|
||||
repositories {
|
||||
maven { url 'https://developer.huawei.com/repo/' }
|
||||
google()
|
||||
mavenCentral()
|
||||
gradlePluginPortal()
|
||||
maven { url 'https://maven.aliyun.com/repository/public' }
|
||||
maven { url 'https://maven.aliyun.com/repository/google' }
|
||||
maven { url 'https://maven.aliyun.com/repository/central' }
|
||||
maven { url 'https://maven.aliyun.com/repository/gradle-plugin' }
|
||||
}
|
||||
def reactNativeGradlePlugin = new File(
|
||||
providers.exec {
|
||||
@@ -163,16 +165,6 @@ jobs:
|
||||
plugins {
|
||||
id("com.facebook.react.settings")
|
||||
id("expo-autolinking-settings")
|
||||
id("org.gradle.toolchains.foojay-resolver-convention") version "0.5.0"
|
||||
}
|
||||
|
||||
dependencyResolutionManagement {
|
||||
repositories {
|
||||
maven { url 'https://developer.huawei.com/repo/' }
|
||||
google()
|
||||
mavenCentral()
|
||||
maven { url 'https://www.jitpack.io' }
|
||||
}
|
||||
}
|
||||
|
||||
extensions.configure(com.facebook.react.ReactSettingsExtension) { ex ->
|
||||
@@ -184,7 +176,7 @@ jobs:
|
||||
}
|
||||
expoAutolinking.useExpoModules()
|
||||
|
||||
rootProject.name = '威友'
|
||||
rootProject.name = '萝卜社区'
|
||||
|
||||
expoAutolinking.useExpoVersionCatalog()
|
||||
|
||||
@@ -192,22 +184,21 @@ jobs:
|
||||
includeBuild(expoAutolinking.reactNativeGradlePlugin)
|
||||
SETTINGS_EOF
|
||||
|
||||
# Update build.gradle
|
||||
# Update build.gradle with Aliyun Maven mirror
|
||||
cat > build.gradle << 'BUILD_EOF'
|
||||
// Top-level build file where you can add configuration options common to all sub-projects/modules.
|
||||
|
||||
buildscript {
|
||||
repositories {
|
||||
maven { url 'https://developer.huawei.com/repo/' }
|
||||
google()
|
||||
mavenCentral()
|
||||
maven { url 'https://maven.aliyun.com/repository/public' }
|
||||
maven { url 'https://maven.aliyun.com/repository/google' }
|
||||
maven { url 'https://maven.aliyun.com/repository/central' }
|
||||
maven { url 'https://maven.aliyun.com/repository/gradle-plugin' }
|
||||
}
|
||||
dependencies {
|
||||
classpath('com.huawei.agconnect:agcp:1.9.1.301') {
|
||||
exclude group: 'com.huawei.agconnect', module: 'agconnect-apms-plugin'
|
||||
}
|
||||
classpath('com.google.gms:google-services:4.4.4')
|
||||
classpath('com.android.tools.build:gradle:8.12.0')
|
||||
classpath('com.android.tools.build:gradle')
|
||||
classpath('com.facebook.react:react-native-gradle-plugin')
|
||||
classpath('org.jetbrains.kotlin:kotlin-gradle-plugin')
|
||||
}
|
||||
@@ -215,10 +206,12 @@ jobs:
|
||||
|
||||
allprojects {
|
||||
repositories {
|
||||
maven { url 'https://developer.huawei.com/repo/' }
|
||||
google()
|
||||
mavenCentral()
|
||||
maven { url 'https://www.jitpack.io' }
|
||||
maven { url 'https://maven.aliyun.com/repository/public' }
|
||||
maven { url 'https://maven.aliyun.com/repository/google' }
|
||||
maven { url 'https://maven.aliyun.com/repository/central' }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -226,14 +219,13 @@ jobs:
|
||||
apply plugin: "com.facebook.react.rootproject"
|
||||
BUILD_EOF
|
||||
|
||||
# Update gradle.properties (without secrets for better cache hit rate)
|
||||
# Update gradle.properties
|
||||
cat > gradle.properties << 'PROPS_EOF'
|
||||
org.gradle.daemon=false
|
||||
org.gradle.parallel=false
|
||||
org.gradle.configureondemand=false
|
||||
org.gradle.workers.max=1
|
||||
org.gradle.jvmargs=-Xmx4g -XX:MaxMetaspaceSize=1g -XX:+UseG1GC -XX:ReservedCodeCacheSize=256m -XX:+HeapDumpOnOutOfMemoryError
|
||||
kotlin.daemon.jvmargs=-Xmx4g -XX:MaxMetaspaceSize=1g -XX:+UseG1GC -XX:ReservedCodeCacheSize=256m
|
||||
org.gradle.parallel=true
|
||||
org.gradle.configureondemand=true
|
||||
org.gradle.workers.max=4
|
||||
org.gradle.jvmargs=-Xmx8g -XX:MaxMetaspaceSize=1g -XX:+UseG1GC -XX:SoftRefLRUPolicyMSPerMB=0 -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError
|
||||
android.enableJetifier=false
|
||||
android.useAndroidX=true
|
||||
hermesEnabled=true
|
||||
@@ -243,72 +235,21 @@ jobs:
|
||||
expo.gif.enabled=true
|
||||
expo.webp.enabled=true
|
||||
expo.webp.animated=false
|
||||
ndkVersion=27.1.12297006
|
||||
expo.useLegacyPackaging=false
|
||||
systemProp.org.gradle.internal.http.connectionTimeout=30000
|
||||
systemProp.org.gradle.internal.http.socketTimeout=30000
|
||||
PROPS_EOF
|
||||
|
||||
# Append signing properties (secrets appended, not cached)
|
||||
cat >> gradle.properties << SIGNING_PROPS
|
||||
MYAPP_UPLOAD_STORE_FILE=withyou-release-key.keystore
|
||||
MYAPP_UPLOAD_STORE_PASSWORD=${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
|
||||
MYAPP_UPLOAD_KEY_ALIAS=withyou-key
|
||||
MYAPP_UPLOAD_KEY_PASSWORD=${{ secrets.ANDROID_KEY_PASSWORD }}
|
||||
SIGNING_PROPS
|
||||
|
||||
# Verify signing config in app/build.gradle
|
||||
echo "=== app/build.gradle signing section ==="
|
||||
grep -A 20 'signingConfigs' app/build.gradle || echo "signingConfigs not found"
|
||||
|
||||
- name: Build Android release APK (arm64 only)
|
||||
run: |
|
||||
cd android
|
||||
chmod +x gradlew
|
||||
taskset -c 0-7 ./gradlew :app:assembleRelease -PreactNativeArchitectures=arm64-v8a --max-workers=1 -Dkotlin.daemon.jvm.options="-Xmx2g,XX:MaxMetaspaceSize=1g"
|
||||
./gradlew :app:assembleRelease -PreactNativeArchitectures=arm64-v8a --max-workers=4 --parallel
|
||||
|
||||
- name: Upload APK artifact
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: withyou-android-release-apk
|
||||
name: carrot-bbs-android-release-apk
|
||||
path: android/app/build/outputs/apk/release/app-release.apk
|
||||
|
||||
- name: Resolve runtime version
|
||||
id: runtime
|
||||
run: |
|
||||
RUNTIME_VERSION="$(node -p "require('./app.json').expo.runtimeVersion")"
|
||||
echo "runtime_version=${RUNTIME_VERSION}" >> "$GITHUB_OUTPUT"
|
||||
echo "Resolved runtimeVersion: ${RUNTIME_VERSION}"
|
||||
|
||||
- name: Upload APK to Updates Server
|
||||
env:
|
||||
OTA_AUTH_TOKEN: ${{ secrets.OTA_AUTH_TOKEN }}
|
||||
UPDATES_SERVER_URL: https://updates.littlelan.cn
|
||||
run: |
|
||||
test -n "${OTA_AUTH_TOKEN}" || (echo "Missing secret OTA_AUTH_TOKEN" && exit 1)
|
||||
|
||||
APK_PATH="android/app/build/outputs/apk/release/app-release.apk"
|
||||
RUNTIME_VERSION="${{ steps.runtime.outputs.runtime_version }}"
|
||||
|
||||
echo "Uploading APK for runtime version: ${RUNTIME_VERSION}"
|
||||
|
||||
HTTP_CODE=$(curl -s -o response.json -w "%{http_code}" \
|
||||
-X POST \
|
||||
"${UPDATES_SERVER_URL}/admin/apk/upload?runtimeVersion=${RUNTIME_VERSION}" \
|
||||
-H "Authorization: Bearer ${OTA_AUTH_TOKEN}" \
|
||||
-H "Content-Type: application/vnd.android.package-archive" \
|
||||
--data-binary @"${APK_PATH}")
|
||||
|
||||
echo "HTTP Response Code: ${HTTP_CODE}"
|
||||
cat response.json
|
||||
|
||||
if [ "${HTTP_CODE}" -ne 200 ]; then
|
||||
echo "Failed to upload APK"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "APK uploaded successfully!"
|
||||
|
||||
build-and-push-web:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
|
||||
3
.gitignore
vendored
@@ -51,6 +51,3 @@ dist-android-update.zip
|
||||
# Backend
|
||||
backend/data/
|
||||
backend/logs/
|
||||
|
||||
doc/
|
||||
docs/
|
||||
|
||||
10
.idea/.gitignore
generated
vendored
Normal file
@@ -0,0 +1,10 @@
|
||||
# 默认忽略的文件
|
||||
/shelf/
|
||||
/workspace.xml
|
||||
# 基于编辑器的 HTTP 客户端请求
|
||||
/httpRequests/
|
||||
# Datasource local storage ignored files
|
||||
/dataSources/
|
||||
/dataSources.local.xml
|
||||
# Screenshots
|
||||
screenshots/
|
||||
9
.idea/frontend.iml
generated
Normal file
@@ -0,0 +1,9 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<module type="JAVA_MODULE" version="4">
|
||||
<component name="NewModuleRootManager" inherit-compiler-output="true">
|
||||
<exclude-output />
|
||||
<content url="file://$MODULE_DIR$" />
|
||||
<orderEntry type="inheritedJdk" />
|
||||
<orderEntry type="sourceFolder" forTests="false" />
|
||||
</component>
|
||||
</module>
|
||||
448
.idea/gradle.xml
generated
Normal file
@@ -0,0 +1,448 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="GradleSettings">
|
||||
<option name="linkedExternalProjectsSettings">
|
||||
<GradleProjectSettings>
|
||||
<option name="externalProjectPath" value="$PROJECT_DIR$/node_modules/@expo/dom-webview/android" />
|
||||
<option name="gradleJvm" value="ms-21" />
|
||||
<option name="modules">
|
||||
<set>
|
||||
<option value="$PROJECT_DIR$/node_modules/@expo/dom-webview/android" />
|
||||
</set>
|
||||
</option>
|
||||
</GradleProjectSettings>
|
||||
<GradleProjectSettings>
|
||||
<option name="externalProjectPath" value="$PROJECT_DIR$/node_modules/@expo/log-box/android" />
|
||||
<option name="gradleJvm" value="ms-21" />
|
||||
<option name="modules">
|
||||
<set>
|
||||
<option value="$PROJECT_DIR$/node_modules/@expo/log-box/android" />
|
||||
</set>
|
||||
</option>
|
||||
</GradleProjectSettings>
|
||||
<GradleProjectSettings>
|
||||
<option name="externalProjectPath" value="$PROJECT_DIR$/node_modules/@react-native-async-storage/async-storage/android" />
|
||||
<option name="gradleJvm" value="ms-21" />
|
||||
<option name="modules">
|
||||
<set>
|
||||
<option value="$PROJECT_DIR$/node_modules/@react-native-async-storage/async-storage/android" />
|
||||
</set>
|
||||
</option>
|
||||
</GradleProjectSettings>
|
||||
<GradleProjectSettings>
|
||||
<option name="externalProjectPath" value="$PROJECT_DIR$/node_modules/@react-native/gradle-plugin" />
|
||||
<option name="gradleJvm" value="ms-21" />
|
||||
<option name="modules">
|
||||
<set>
|
||||
<option value="$PROJECT_DIR$/node_modules/@react-native/gradle-plugin" />
|
||||
</set>
|
||||
</option>
|
||||
</GradleProjectSettings>
|
||||
<GradleProjectSettings>
|
||||
<option name="externalProjectPath" value="$PROJECT_DIR$/node_modules/expo-application/android" />
|
||||
<option name="gradleJvm" value="ms-21" />
|
||||
<option name="modules">
|
||||
<set>
|
||||
<option value="$PROJECT_DIR$/node_modules/expo-application/android" />
|
||||
</set>
|
||||
</option>
|
||||
</GradleProjectSettings>
|
||||
<GradleProjectSettings>
|
||||
<option name="externalProjectPath" value="$PROJECT_DIR$/node_modules/expo-background-fetch/android" />
|
||||
<option name="gradleJvm" value="ms-21" />
|
||||
<option name="modules">
|
||||
<set>
|
||||
<option value="$PROJECT_DIR$/node_modules/expo-background-fetch/android" />
|
||||
</set>
|
||||
</option>
|
||||
</GradleProjectSettings>
|
||||
<GradleProjectSettings>
|
||||
<option name="externalProjectPath" value="$PROJECT_DIR$/node_modules/expo-constants/android" />
|
||||
<option name="gradleJvm" value="ms-21" />
|
||||
<option name="modules">
|
||||
<set>
|
||||
<option value="$PROJECT_DIR$/node_modules/expo-constants/android" />
|
||||
</set>
|
||||
</option>
|
||||
</GradleProjectSettings>
|
||||
<GradleProjectSettings>
|
||||
<option name="externalProjectPath" value="$PROJECT_DIR$/node_modules/expo-constants/scripts" />
|
||||
<option name="gradleJvm" value="ms-21" />
|
||||
<option name="modules">
|
||||
<set>
|
||||
<option value="$PROJECT_DIR$/node_modules/expo-constants/scripts" />
|
||||
</set>
|
||||
</option>
|
||||
</GradleProjectSettings>
|
||||
<GradleProjectSettings>
|
||||
<option name="externalProjectPath" value="$PROJECT_DIR$/node_modules/expo-dev-client/android" />
|
||||
<option name="gradleJvm" value="ms-21" />
|
||||
<option name="modules">
|
||||
<set>
|
||||
<option value="$PROJECT_DIR$/node_modules/expo-dev-client/android" />
|
||||
</set>
|
||||
</option>
|
||||
</GradleProjectSettings>
|
||||
<GradleProjectSettings>
|
||||
<option name="externalProjectPath" value="$PROJECT_DIR$/node_modules/expo-dev-launcher/android" />
|
||||
<option name="gradleJvm" value="ms-21" />
|
||||
<option name="modules">
|
||||
<set>
|
||||
<option value="$PROJECT_DIR$/node_modules/expo-dev-launcher/android" />
|
||||
</set>
|
||||
</option>
|
||||
</GradleProjectSettings>
|
||||
<GradleProjectSettings>
|
||||
<option name="externalProjectPath" value="$PROJECT_DIR$/node_modules/expo-dev-launcher/expo-dev-launcher-gradle-plugin" />
|
||||
<option name="gradleJvm" value="ms-21" />
|
||||
<option name="modules">
|
||||
<set>
|
||||
<option value="$PROJECT_DIR$/node_modules/expo-dev-launcher/expo-dev-launcher-gradle-plugin" />
|
||||
</set>
|
||||
</option>
|
||||
</GradleProjectSettings>
|
||||
<GradleProjectSettings>
|
||||
<option name="externalProjectPath" value="$PROJECT_DIR$/node_modules/expo-dev-menu-interface/android" />
|
||||
<option name="gradleJvm" value="ms-21" />
|
||||
<option name="modules">
|
||||
<set>
|
||||
<option value="$PROJECT_DIR$/node_modules/expo-dev-menu-interface/android" />
|
||||
</set>
|
||||
</option>
|
||||
</GradleProjectSettings>
|
||||
<GradleProjectSettings>
|
||||
<option name="externalProjectPath" value="$PROJECT_DIR$/node_modules/expo-dev-menu/android" />
|
||||
<option name="gradleJvm" value="ms-21" />
|
||||
<option name="modules">
|
||||
<set>
|
||||
<option value="$PROJECT_DIR$/node_modules/expo-dev-menu/android" />
|
||||
</set>
|
||||
</option>
|
||||
</GradleProjectSettings>
|
||||
<GradleProjectSettings>
|
||||
<option name="externalProjectPath" value="$PROJECT_DIR$/node_modules/expo-eas-client/android" />
|
||||
<option name="gradleJvm" value="ms-21" />
|
||||
<option name="modules">
|
||||
<set>
|
||||
<option value="$PROJECT_DIR$/node_modules/expo-eas-client/android" />
|
||||
</set>
|
||||
</option>
|
||||
</GradleProjectSettings>
|
||||
<GradleProjectSettings>
|
||||
<option name="externalProjectPath" value="$PROJECT_DIR$/node_modules/expo-file-system/android" />
|
||||
<option name="gradleJvm" value="ms-21" />
|
||||
<option name="modules">
|
||||
<set>
|
||||
<option value="$PROJECT_DIR$/node_modules/expo-file-system/android" />
|
||||
</set>
|
||||
</option>
|
||||
</GradleProjectSettings>
|
||||
<GradleProjectSettings>
|
||||
<option name="externalProjectPath" value="$PROJECT_DIR$/node_modules/expo-font/android" />
|
||||
<option name="gradleJvm" value="ms-21" />
|
||||
<option name="modules">
|
||||
<set>
|
||||
<option value="$PROJECT_DIR$/node_modules/expo-font/android" />
|
||||
</set>
|
||||
</option>
|
||||
</GradleProjectSettings>
|
||||
<GradleProjectSettings>
|
||||
<option name="externalProjectPath" value="$PROJECT_DIR$/node_modules/expo-haptics/android" />
|
||||
<option name="gradleJvm" value="ms-21" />
|
||||
<option name="modules">
|
||||
<set>
|
||||
<option value="$PROJECT_DIR$/node_modules/expo-haptics/android" />
|
||||
</set>
|
||||
</option>
|
||||
</GradleProjectSettings>
|
||||
<GradleProjectSettings>
|
||||
<option name="externalProjectPath" value="$PROJECT_DIR$/node_modules/expo-image-loader/android" />
|
||||
<option name="gradleJvm" value="ms-21" />
|
||||
<option name="modules">
|
||||
<set>
|
||||
<option value="$PROJECT_DIR$/node_modules/expo-image-loader/android" />
|
||||
</set>
|
||||
</option>
|
||||
</GradleProjectSettings>
|
||||
<GradleProjectSettings>
|
||||
<option name="externalProjectPath" value="$PROJECT_DIR$/node_modules/expo-image-picker/android" />
|
||||
<option name="gradleJvm" value="ms-21" />
|
||||
<option name="modules">
|
||||
<set>
|
||||
<option value="$PROJECT_DIR$/node_modules/expo-image-picker/android" />
|
||||
</set>
|
||||
</option>
|
||||
</GradleProjectSettings>
|
||||
<GradleProjectSettings>
|
||||
<option name="externalProjectPath" value="$PROJECT_DIR$/node_modules/expo-image/android" />
|
||||
<option name="gradleJvm" value="ms-21" />
|
||||
<option name="modules">
|
||||
<set>
|
||||
<option value="$PROJECT_DIR$/node_modules/expo-image/android" />
|
||||
</set>
|
||||
</option>
|
||||
</GradleProjectSettings>
|
||||
<GradleProjectSettings>
|
||||
<option name="externalProjectPath" value="$PROJECT_DIR$/node_modules/expo-json-utils/android" />
|
||||
<option name="gradleJvm" value="ms-21" />
|
||||
<option name="modules">
|
||||
<set>
|
||||
<option value="$PROJECT_DIR$/node_modules/expo-json-utils/android" />
|
||||
</set>
|
||||
</option>
|
||||
</GradleProjectSettings>
|
||||
<GradleProjectSettings>
|
||||
<option name="externalProjectPath" value="$PROJECT_DIR$/node_modules/expo-linear-gradient/android" />
|
||||
<option name="gradleJvm" value="ms-21" />
|
||||
<option name="modules">
|
||||
<set>
|
||||
<option value="$PROJECT_DIR$/node_modules/expo-linear-gradient/android" />
|
||||
</set>
|
||||
</option>
|
||||
</GradleProjectSettings>
|
||||
<GradleProjectSettings>
|
||||
<option name="externalProjectPath" value="$PROJECT_DIR$/node_modules/expo-linking/android" />
|
||||
<option name="gradleJvm" value="ms-21" />
|
||||
<option name="modules">
|
||||
<set>
|
||||
<option value="$PROJECT_DIR$/node_modules/expo-linking/android" />
|
||||
</set>
|
||||
</option>
|
||||
</GradleProjectSettings>
|
||||
<GradleProjectSettings>
|
||||
<option name="externalProjectPath" value="$PROJECT_DIR$/node_modules/expo-manifests/android" />
|
||||
<option name="gradleJvm" value="ms-21" />
|
||||
<option name="modules">
|
||||
<set>
|
||||
<option value="$PROJECT_DIR$/node_modules/expo-manifests/android" />
|
||||
</set>
|
||||
</option>
|
||||
</GradleProjectSettings>
|
||||
<GradleProjectSettings>
|
||||
<option name="externalProjectPath" value="$PROJECT_DIR$/node_modules/expo-media-library/android" />
|
||||
<option name="gradleJvm" value="ms-21" />
|
||||
<option name="modules">
|
||||
<set>
|
||||
<option value="$PROJECT_DIR$/node_modules/expo-media-library/android" />
|
||||
</set>
|
||||
</option>
|
||||
</GradleProjectSettings>
|
||||
<GradleProjectSettings>
|
||||
<option name="externalProjectPath" value="$PROJECT_DIR$/node_modules/expo-modules-autolinking/android/expo-gradle-plugin" />
|
||||
<option name="gradleJvm" value="ms-21" />
|
||||
<option name="modules">
|
||||
<set>
|
||||
<option value="$PROJECT_DIR$/node_modules/expo-modules-autolinking/android/expo-gradle-plugin" />
|
||||
</set>
|
||||
</option>
|
||||
</GradleProjectSettings>
|
||||
<GradleProjectSettings>
|
||||
<option name="externalProjectPath" value="$PROJECT_DIR$/node_modules/expo-modules-core/android" />
|
||||
<option name="gradleJvm" value="ms-21" />
|
||||
<option name="modules">
|
||||
<set>
|
||||
<option value="$PROJECT_DIR$/node_modules/expo-modules-core/android" />
|
||||
</set>
|
||||
</option>
|
||||
</GradleProjectSettings>
|
||||
<GradleProjectSettings>
|
||||
<option name="externalProjectPath" value="$PROJECT_DIR$/node_modules/expo-modules-core/expo-module-gradle-plugin" />
|
||||
<option name="gradleJvm" value="ms-21" />
|
||||
<option name="modules">
|
||||
<set>
|
||||
<option value="$PROJECT_DIR$/node_modules/expo-modules-core/expo-module-gradle-plugin" />
|
||||
</set>
|
||||
</option>
|
||||
</GradleProjectSettings>
|
||||
<GradleProjectSettings>
|
||||
<option name="externalProjectPath" value="$PROJECT_DIR$/node_modules/expo-notifications/android" />
|
||||
<option name="gradleJvm" value="ms-21" />
|
||||
<option name="modules">
|
||||
<set>
|
||||
<option value="$PROJECT_DIR$/node_modules/expo-notifications/android" />
|
||||
</set>
|
||||
</option>
|
||||
</GradleProjectSettings>
|
||||
<GradleProjectSettings>
|
||||
<option name="externalProjectPath" value="$PROJECT_DIR$/node_modules/expo-router/android" />
|
||||
<option name="gradleJvm" value="ms-21" />
|
||||
<option name="modules">
|
||||
<set>
|
||||
<option value="$PROJECT_DIR$/node_modules/expo-router/android" />
|
||||
</set>
|
||||
</option>
|
||||
</GradleProjectSettings>
|
||||
<GradleProjectSettings>
|
||||
<option name="externalProjectPath" value="$PROJECT_DIR$/node_modules/expo-sqlite/android" />
|
||||
<option name="gradleJvm" value="ms-21" />
|
||||
<option name="modules">
|
||||
<set>
|
||||
<option value="$PROJECT_DIR$/node_modules/expo-sqlite/android" />
|
||||
</set>
|
||||
</option>
|
||||
</GradleProjectSettings>
|
||||
<GradleProjectSettings>
|
||||
<option name="externalProjectPath" value="$PROJECT_DIR$/node_modules/expo-structured-headers/android" />
|
||||
<option name="gradleJvm" value="ms-21" />
|
||||
<option name="modules">
|
||||
<set>
|
||||
<option value="$PROJECT_DIR$/node_modules/expo-structured-headers/android" />
|
||||
</set>
|
||||
</option>
|
||||
</GradleProjectSettings>
|
||||
<GradleProjectSettings>
|
||||
<option name="externalProjectPath" value="$PROJECT_DIR$/node_modules/expo-system-ui/android" />
|
||||
<option name="gradleJvm" value="ms-21" />
|
||||
<option name="modules">
|
||||
<set>
|
||||
<option value="$PROJECT_DIR$/node_modules/expo-system-ui/android" />
|
||||
</set>
|
||||
</option>
|
||||
</GradleProjectSettings>
|
||||
<GradleProjectSettings>
|
||||
<option name="externalProjectPath" value="$PROJECT_DIR$/node_modules/expo-task-manager/android" />
|
||||
<option name="gradleJvm" value="ms-21" />
|
||||
<option name="modules">
|
||||
<set>
|
||||
<option value="$PROJECT_DIR$/node_modules/expo-task-manager/android" />
|
||||
</set>
|
||||
</option>
|
||||
</GradleProjectSettings>
|
||||
<GradleProjectSettings>
|
||||
<option name="externalProjectPath" value="$PROJECT_DIR$/node_modules/expo-updates-interface/android" />
|
||||
<option name="gradleJvm" value="ms-21" />
|
||||
<option name="modules">
|
||||
<set>
|
||||
<option value="$PROJECT_DIR$/node_modules/expo-updates-interface/android" />
|
||||
</set>
|
||||
</option>
|
||||
</GradleProjectSettings>
|
||||
<GradleProjectSettings>
|
||||
<option name="externalProjectPath" value="$PROJECT_DIR$/node_modules/expo-updates/android" />
|
||||
<option name="gradleJvm" value="ms-21" />
|
||||
<option name="modules">
|
||||
<set>
|
||||
<option value="$PROJECT_DIR$/node_modules/expo-updates/android" />
|
||||
</set>
|
||||
</option>
|
||||
</GradleProjectSettings>
|
||||
<GradleProjectSettings>
|
||||
<option name="externalProjectPath" value="$PROJECT_DIR$/node_modules/expo-updates/expo-updates-gradle-plugin" />
|
||||
<option name="gradleJvm" value="ms-21" />
|
||||
<option name="modules">
|
||||
<set>
|
||||
<option value="$PROJECT_DIR$/node_modules/expo-updates/expo-updates-gradle-plugin" />
|
||||
</set>
|
||||
</option>
|
||||
</GradleProjectSettings>
|
||||
<GradleProjectSettings>
|
||||
<option name="externalProjectPath" value="$PROJECT_DIR$/node_modules/expo-video/android" />
|
||||
<option name="gradleJvm" value="ms-21" />
|
||||
<option name="modules">
|
||||
<set>
|
||||
<option value="$PROJECT_DIR$/node_modules/expo-video/android" />
|
||||
</set>
|
||||
</option>
|
||||
</GradleProjectSettings>
|
||||
<GradleProjectSettings>
|
||||
<option name="externalProjectPath" value="$PROJECT_DIR$/node_modules/expo/android" />
|
||||
<option name="gradleJvm" value="ms-21" />
|
||||
<option name="modules">
|
||||
<set>
|
||||
<option value="$PROJECT_DIR$/node_modules/expo/android" />
|
||||
</set>
|
||||
</option>
|
||||
</GradleProjectSettings>
|
||||
<GradleProjectSettings>
|
||||
<option name="externalProjectPath" value="$PROJECT_DIR$/node_modules/expo/node_modules/expo-asset/android" />
|
||||
<option name="gradleJvm" value="ms-21" />
|
||||
<option name="modules">
|
||||
<set>
|
||||
<option value="$PROJECT_DIR$/node_modules/expo/node_modules/expo-asset/android" />
|
||||
</set>
|
||||
</option>
|
||||
</GradleProjectSettings>
|
||||
<GradleProjectSettings>
|
||||
<option name="externalProjectPath" value="$PROJECT_DIR$/node_modules/expo/node_modules/expo-keep-awake/android" />
|
||||
<option name="gradleJvm" value="ms-21" />
|
||||
<option name="modules">
|
||||
<set>
|
||||
<option value="$PROJECT_DIR$/node_modules/expo/node_modules/expo-keep-awake/android" />
|
||||
</set>
|
||||
</option>
|
||||
</GradleProjectSettings>
|
||||
<GradleProjectSettings>
|
||||
<option name="externalProjectPath" value="$PROJECT_DIR$/node_modules/react-native" />
|
||||
<option name="gradleJvm" value="ms-21" />
|
||||
<option name="modules">
|
||||
<set>
|
||||
<option value="$PROJECT_DIR$/node_modules/react-native" />
|
||||
</set>
|
||||
</option>
|
||||
</GradleProjectSettings>
|
||||
<GradleProjectSettings>
|
||||
<option name="externalProjectPath" value="$PROJECT_DIR$/node_modules/react-native-gesture-handler/android" />
|
||||
<option name="gradleJvm" value="ms-21" />
|
||||
<option name="modules">
|
||||
<set>
|
||||
<option value="$PROJECT_DIR$/node_modules/react-native-gesture-handler/android" />
|
||||
</set>
|
||||
</option>
|
||||
</GradleProjectSettings>
|
||||
<GradleProjectSettings>
|
||||
<option name="externalProjectPath" value="$PROJECT_DIR$/node_modules/react-native-pager-view/android" />
|
||||
<option name="gradleJvm" value="ms-21" />
|
||||
<option name="modules">
|
||||
<set>
|
||||
<option value="$PROJECT_DIR$/node_modules/react-native-pager-view/android" />
|
||||
</set>
|
||||
</option>
|
||||
</GradleProjectSettings>
|
||||
<GradleProjectSettings>
|
||||
<option name="externalProjectPath" value="$PROJECT_DIR$/node_modules/react-native-reanimated/android" />
|
||||
<option name="gradleJvm" value="ms-21" />
|
||||
<option name="modules">
|
||||
<set>
|
||||
<option value="$PROJECT_DIR$/node_modules/react-native-reanimated/android" />
|
||||
</set>
|
||||
</option>
|
||||
</GradleProjectSettings>
|
||||
<GradleProjectSettings>
|
||||
<option name="externalProjectPath" value="$PROJECT_DIR$/node_modules/react-native-safe-area-context/android" />
|
||||
<option name="gradleJvm" value="ms-21" />
|
||||
<option name="modules">
|
||||
<set>
|
||||
<option value="$PROJECT_DIR$/node_modules/react-native-safe-area-context/android" />
|
||||
</set>
|
||||
</option>
|
||||
</GradleProjectSettings>
|
||||
<GradleProjectSettings>
|
||||
<option name="externalProjectPath" value="$PROJECT_DIR$/node_modules/react-native-screens/android" />
|
||||
<option name="gradleJvm" value="ms-21" />
|
||||
<option name="modules">
|
||||
<set>
|
||||
<option value="$PROJECT_DIR$/node_modules/react-native-screens/android" />
|
||||
</set>
|
||||
</option>
|
||||
</GradleProjectSettings>
|
||||
<GradleProjectSettings>
|
||||
<option name="externalProjectPath" value="$PROJECT_DIR$/node_modules/react-native-worklets/android" />
|
||||
<option name="gradleJvm" value="ms-21" />
|
||||
<option name="modules">
|
||||
<set>
|
||||
<option value="$PROJECT_DIR$/node_modules/react-native-worklets/android" />
|
||||
</set>
|
||||
</option>
|
||||
</GradleProjectSettings>
|
||||
<GradleProjectSettings>
|
||||
<option name="externalProjectPath" value="$PROJECT_DIR$/node_modules/unimodules-app-loader/android" />
|
||||
<option name="gradleJvm" value="ms-21" />
|
||||
<option name="modules">
|
||||
<set>
|
||||
<option value="$PROJECT_DIR$/node_modules/unimodules-app-loader/android" />
|
||||
</set>
|
||||
</option>
|
||||
</GradleProjectSettings>
|
||||
</option>
|
||||
</component>
|
||||
</project>
|
||||
7
.idea/misc.xml
generated
Normal file
@@ -0,0 +1,7 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="ExternalStorageConfigurationManager" enabled="true" />
|
||||
<component name="ProjectRootManager">
|
||||
<output url="file://$PROJECT_DIR$/out" />
|
||||
</component>
|
||||
</project>
|
||||
8
.idea/modules.xml
generated
Normal file
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="ProjectModuleManager">
|
||||
<modules>
|
||||
<module fileurl="file://$PROJECT_DIR$/.idea/frontend.iml" filepath="$PROJECT_DIR$/.idea/frontend.iml" />
|
||||
</modules>
|
||||
</component>
|
||||
</project>
|
||||
6
.idea/vcs.xml
generated
Normal file
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="VcsDirectoryMappings">
|
||||
<mapping directory="" vcs="Git" />
|
||||
</component>
|
||||
</project>
|
||||
376
.kilo/package-lock.json
generated
@@ -1,376 +0,0 @@
|
||||
{
|
||||
"name": ".kilo",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"dependencies": {
|
||||
"@kilocode/plugin": "7.2.31"
|
||||
}
|
||||
},
|
||||
"node_modules/@kilocode/plugin": {
|
||||
"version": "7.2.31",
|
||||
"resolved": "https://registry.npmmirror.com/@kilocode/plugin/-/plugin-7.2.31.tgz",
|
||||
"integrity": "sha512-KmKTTIly7hRlJdXKhqZ/j/brvTPh0z0UTjWSjJWq5fqf4pATgYGn7G0g3ZjILnN7MUkkZXuljgqExTEeQJHGkQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@kilocode/sdk": "7.2.31",
|
||||
"effect": "4.0.0-beta.48",
|
||||
"zod": "4.1.8"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@opentui/core": ">=0.1.100",
|
||||
"@opentui/solid": ">=0.1.100"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@opentui/core": {
|
||||
"optional": true
|
||||
},
|
||||
"@opentui/solid": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@kilocode/sdk": {
|
||||
"version": "7.2.31",
|
||||
"resolved": "https://registry.npmmirror.com/@kilocode/sdk/-/sdk-7.2.31.tgz",
|
||||
"integrity": "sha512-Sx05yv+3TIlc6M4Ze+YGgKCLoIg8B0WRE15JXDriVncT+wz7M6+e+4mjNWkwfsuywdeOTYPfeFViqX8iO7ckKA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cross-spawn": "7.0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/@msgpackr-extract/msgpackr-extract-darwin-arm64": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmmirror.com/@msgpackr-extract/msgpackr-extract-darwin-arm64/-/msgpackr-extract-darwin-arm64-3.0.3.tgz",
|
||||
"integrity": "sha512-QZHtlVgbAdy2zAqNA9Gu1UpIuI8Xvsd1v8ic6B2pZmeFnFcMWiPLfWXh7TVw4eGEZ/C9TH281KwhVoeQUKbyjw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
]
|
||||
},
|
||||
"node_modules/@msgpackr-extract/msgpackr-extract-darwin-x64": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmmirror.com/@msgpackr-extract/msgpackr-extract-darwin-x64/-/msgpackr-extract-darwin-x64-3.0.3.tgz",
|
||||
"integrity": "sha512-mdzd3AVzYKuUmiWOQ8GNhl64/IoFGol569zNRdkLReh6LRLHOXxU4U8eq0JwaD8iFHdVGqSy4IjFL4reoWCDFw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
]
|
||||
},
|
||||
"node_modules/@msgpackr-extract/msgpackr-extract-linux-arm": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmmirror.com/@msgpackr-extract/msgpackr-extract-linux-arm/-/msgpackr-extract-linux-arm-3.0.3.tgz",
|
||||
"integrity": "sha512-fg0uy/dG/nZEXfYilKoRe7yALaNmHoYeIoJuJ7KJ+YyU2bvY8vPv27f7UKhGRpY6euFYqEVhxCFZgAUNQBM3nw==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
},
|
||||
"node_modules/@msgpackr-extract/msgpackr-extract-linux-arm64": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmmirror.com/@msgpackr-extract/msgpackr-extract-linux-arm64/-/msgpackr-extract-linux-arm64-3.0.3.tgz",
|
||||
"integrity": "sha512-YxQL+ax0XqBJDZiKimS2XQaf+2wDGVa1enVRGzEvLLVFeqa5kx2bWbtcSXgsxjQB7nRqqIGFIcLteF/sHeVtQg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
},
|
||||
"node_modules/@msgpackr-extract/msgpackr-extract-linux-x64": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmmirror.com/@msgpackr-extract/msgpackr-extract-linux-x64/-/msgpackr-extract-linux-x64-3.0.3.tgz",
|
||||
"integrity": "sha512-cvwNfbP07pKUfq1uH+S6KJ7dT9K8WOE4ZiAcsrSes+UY55E/0jLYc+vq+DO7jlmqRb5zAggExKm0H7O/CBaesg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
},
|
||||
"node_modules/@msgpackr-extract/msgpackr-extract-win32-x64": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmmirror.com/@msgpackr-extract/msgpackr-extract-win32-x64/-/msgpackr-extract-win32-x64-3.0.3.tgz",
|
||||
"integrity": "sha512-x0fWaQtYp4E6sktbsdAqnehxDgEc/VwM7uLsRCYWaiGu0ykYdZPiS8zCWdnjHwyiumousxfBm4SO31eXqwEZhQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
]
|
||||
},
|
||||
"node_modules/@standard-schema/spec": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmmirror.com/@standard-schema/spec/-/spec-1.1.0.tgz",
|
||||
"integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/cross-spawn": {
|
||||
"version": "7.0.6",
|
||||
"resolved": "https://registry.npmmirror.com/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
||||
"integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"path-key": "^3.1.0",
|
||||
"shebang-command": "^2.0.0",
|
||||
"which": "^2.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 8"
|
||||
}
|
||||
},
|
||||
"node_modules/detect-libc": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmmirror.com/detect-libc/-/detect-libc-2.1.2.tgz",
|
||||
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/effect": {
|
||||
"version": "4.0.0-beta.48",
|
||||
"resolved": "https://registry.npmmirror.com/effect/-/effect-4.0.0-beta.48.tgz",
|
||||
"integrity": "sha512-MMAM/ZabuNdNmgXiin+BAanQXK7qM8mlt7nfXDoJ/Gn9V8i89JlCq+2N0AiWmqFLXjGLA0u3FjiOjSOYQk5uMw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@standard-schema/spec": "^1.1.0",
|
||||
"fast-check": "^4.6.0",
|
||||
"find-my-way-ts": "^0.1.6",
|
||||
"ini": "^6.0.0",
|
||||
"kubernetes-types": "^1.30.0",
|
||||
"msgpackr": "^1.11.9",
|
||||
"multipasta": "^0.2.7",
|
||||
"toml": "^4.1.1",
|
||||
"uuid": "^13.0.0",
|
||||
"yaml": "^2.8.3"
|
||||
}
|
||||
},
|
||||
"node_modules/fast-check": {
|
||||
"version": "4.7.0",
|
||||
"resolved": "https://registry.npmmirror.com/fast-check/-/fast-check-4.7.0.tgz",
|
||||
"integrity": "sha512-NsZRtqvSSoCP0HbNjUD+r1JH8zqZalyp6gLY9e7OYs7NK9b6AHOs2baBFeBG7bVNsuoukh89x2Yg3rPsul8ziQ==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "individual",
|
||||
"url": "https://github.com/sponsors/dubzzz"
|
||||
},
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/fast-check"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"pure-rand": "^8.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12.17.0"
|
||||
}
|
||||
},
|
||||
"node_modules/find-my-way-ts": {
|
||||
"version": "0.1.6",
|
||||
"resolved": "https://registry.npmmirror.com/find-my-way-ts/-/find-my-way-ts-0.1.6.tgz",
|
||||
"integrity": "sha512-a85L9ZoXtNAey3Y6Z+eBWW658kO/MwR7zIafkIUPUMf3isZG0NCs2pjW2wtjxAKuJPxMAsHUIP4ZPGv0o5gyTA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/ini": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmmirror.com/ini/-/ini-6.0.0.tgz",
|
||||
"integrity": "sha512-IBTdIkzZNOpqm7q3dRqJvMaldXjDHWkEDfrwGEQTs5eaQMWV+djAhR+wahyNNMAa+qpbDUhBMVt4ZKNwpPm7xQ==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": "^20.17.0 || >=22.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/isexe": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmmirror.com/isexe/-/isexe-2.0.0.tgz",
|
||||
"integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/kubernetes-types": {
|
||||
"version": "1.30.0",
|
||||
"resolved": "https://registry.npmmirror.com/kubernetes-types/-/kubernetes-types-1.30.0.tgz",
|
||||
"integrity": "sha512-Dew1okvhM/SQcIa2rcgujNndZwU8VnSapDgdxlYoB84ZlpAD43U6KLAFqYo17ykSFGHNPrg0qry0bP+GJd9v7Q==",
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/msgpackr": {
|
||||
"version": "1.11.12",
|
||||
"resolved": "https://registry.npmmirror.com/msgpackr/-/msgpackr-1.11.12.tgz",
|
||||
"integrity": "sha512-RBdJ1Un7yGlXWajrkxcSa93nvQ0w4zBf60c0yYv7YtBelP8H2FA7XsfBbMHtXKXUMUxH7zV3Zuozh+kUQWhHvg==",
|
||||
"license": "MIT",
|
||||
"optionalDependencies": {
|
||||
"msgpackr-extract": "^3.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/msgpackr-extract": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmmirror.com/msgpackr-extract/-/msgpackr-extract-3.0.3.tgz",
|
||||
"integrity": "sha512-P0efT1C9jIdVRefqjzOQ9Xml57zpOXnIuS+csaB4MdZbTdmGDLo8XhzBG1N7aO11gKDDkJvBLULeFTo46wwreA==",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"node-gyp-build-optional-packages": "5.2.2"
|
||||
},
|
||||
"bin": {
|
||||
"download-msgpackr-prebuilds": "bin/download-prebuilds.js"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@msgpackr-extract/msgpackr-extract-darwin-arm64": "3.0.3",
|
||||
"@msgpackr-extract/msgpackr-extract-darwin-x64": "3.0.3",
|
||||
"@msgpackr-extract/msgpackr-extract-linux-arm": "3.0.3",
|
||||
"@msgpackr-extract/msgpackr-extract-linux-arm64": "3.0.3",
|
||||
"@msgpackr-extract/msgpackr-extract-linux-x64": "3.0.3",
|
||||
"@msgpackr-extract/msgpackr-extract-win32-x64": "3.0.3"
|
||||
}
|
||||
},
|
||||
"node_modules/multipasta": {
|
||||
"version": "0.2.7",
|
||||
"resolved": "https://registry.npmmirror.com/multipasta/-/multipasta-0.2.7.tgz",
|
||||
"integrity": "sha512-KPA58d68KgGil15oDqXjkUBEBYc00XvbPj5/X+dyzeo/lWm9Nc25pQRlf1D+gv4OpK7NM0J1odrbu9JNNGvynA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/node-gyp-build-optional-packages": {
|
||||
"version": "5.2.2",
|
||||
"resolved": "https://registry.npmmirror.com/node-gyp-build-optional-packages/-/node-gyp-build-optional-packages-5.2.2.tgz",
|
||||
"integrity": "sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"detect-libc": "^2.0.1"
|
||||
},
|
||||
"bin": {
|
||||
"node-gyp-build-optional-packages": "bin.js",
|
||||
"node-gyp-build-optional-packages-optional": "optional.js",
|
||||
"node-gyp-build-optional-packages-test": "build-test.js"
|
||||
}
|
||||
},
|
||||
"node_modules/path-key": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmmirror.com/path-key/-/path-key-3.1.1.tgz",
|
||||
"integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/pure-rand": {
|
||||
"version": "8.4.0",
|
||||
"resolved": "https://registry.npmmirror.com/pure-rand/-/pure-rand-8.4.0.tgz",
|
||||
"integrity": "sha512-IoM8YF/jY0hiugFo/wOWqfmarlE6J0wc6fDK1PhftMk7MGhVZl88sZimmqBBFomLOCSmcCCpsfj7wXASCpvK9A==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "individual",
|
||||
"url": "https://github.com/sponsors/dubzzz"
|
||||
},
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/fast-check"
|
||||
}
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/shebang-command": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmmirror.com/shebang-command/-/shebang-command-2.0.0.tgz",
|
||||
"integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"shebang-regex": "^3.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/shebang-regex": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmmirror.com/shebang-regex/-/shebang-regex-3.0.0.tgz",
|
||||
"integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/toml": {
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmmirror.com/toml/-/toml-4.1.1.tgz",
|
||||
"integrity": "sha512-EBJnVBr3dTXdA89WVFoAIPUqkBjxPMwRqsfuo1r240tKFHXv3zgca4+NJib/h6TyvGF7vOawz0jGuryJCdNHrw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/uuid": {
|
||||
"version": "13.0.1",
|
||||
"resolved": "https://registry.npmmirror.com/uuid/-/uuid-13.0.1.tgz",
|
||||
"integrity": "sha512-9ezox2roIft6ExBVTVqibSd5dc5/47Sw/uY6b4SjQUT2TzQ0tltNquWA46y4xPQmdZYqvnio22SgWd41M86+jw==",
|
||||
"funding": [
|
||||
"https://github.com/sponsors/broofa",
|
||||
"https://github.com/sponsors/ctavan"
|
||||
],
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"uuid": "dist-node/bin/uuid"
|
||||
}
|
||||
},
|
||||
"node_modules/which": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmmirror.com/which/-/which-2.0.2.tgz",
|
||||
"integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"isexe": "^2.0.0"
|
||||
},
|
||||
"bin": {
|
||||
"node-which": "bin/node-which"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 8"
|
||||
}
|
||||
},
|
||||
"node_modules/yaml": {
|
||||
"version": "2.8.4",
|
||||
"resolved": "https://registry.npmmirror.com/yaml/-/yaml-2.8.4.tgz",
|
||||
"integrity": "sha512-ml/JPOj9fOQK8RNnWojA67GbZ0ApXAUlN2UQclwv2eVgTgn7O9gg9o7paZWKMp4g0H3nTLtS9LVzhkpOFIKzog==",
|
||||
"license": "ISC",
|
||||
"bin": {
|
||||
"yaml": "bin.mjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 14.6"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/eemeli"
|
||||
}
|
||||
},
|
||||
"node_modules/zod": {
|
||||
"version": "4.1.8",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/colinhacks"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
231
ARCHITECTURE_REFACTOR_PLAN.md
Normal file
@@ -0,0 +1,231 @@
|
||||
# 架构修复方案文档
|
||||
|
||||
## 1. 重构目标
|
||||
|
||||
### 1.1 核心原则
|
||||
- **单一职责**:每个模块只负责一个明确的功能
|
||||
- **依赖倒置**:高层模块不依赖低层模块,都依赖抽象
|
||||
- **开闭原则**:对扩展开放,对修改关闭
|
||||
|
||||
### 1.2 目标架构
|
||||
```
|
||||
src/
|
||||
├── core/ # 核心业务逻辑(框架无关)
|
||||
│ ├── entities/ # 业务实体
|
||||
│ ├── repositories/ # 仓库接口
|
||||
│ └── usecases/ # 业务用例
|
||||
├── data/ # 数据层
|
||||
│ ├── datasources/ # 数据源实现(API、DB、Cache)
|
||||
│ ├── repositories/ # 仓库实现
|
||||
│ └── mappers/ # 数据映射
|
||||
├── presentation/ # 表现层
|
||||
│ ├── components/ # UI组件
|
||||
│ ├── screens/ # 屏幕组件
|
||||
│ ├── stores/ # 状态管理
|
||||
│ └── hooks/ # 自定义hooks
|
||||
└── infrastructure/ # 基础设施
|
||||
├── navigation/ # 导航
|
||||
├── theme/ # 主题
|
||||
└── services/ # 外部服务
|
||||
```
|
||||
|
||||
## 2. 具体修复方案
|
||||
|
||||
### 2.1 messageManager.ts 重构方案
|
||||
|
||||
#### 现状问题
|
||||
- 1500+行,上帝对象
|
||||
- 混合了 WebSocket、数据库、状态管理、UI通知
|
||||
- 与SQLite深度耦合
|
||||
|
||||
#### 目标结构
|
||||
```
|
||||
src/data/
|
||||
├── datasources/
|
||||
│ ├── WebSocketClient.ts # WebSocket连接管理
|
||||
│ └── MessageDataSource.ts # 消息数据源
|
||||
├── repositories/
|
||||
│ └── MessageRepository.ts # 消息仓库实现
|
||||
└── mappers/
|
||||
└── MessageMapper.ts # 消息数据映射
|
||||
|
||||
src/core/
|
||||
├── entities/
|
||||
│ └── Message.ts # 消息实体
|
||||
├── repositories/
|
||||
│ └── IMessageRepository.ts # 仓库接口
|
||||
└── usecases/
|
||||
├── ProcessMessageUseCase.ts
|
||||
├── SyncMessagesUseCase.ts
|
||||
└── UpdateReadReceiptUseCase.ts
|
||||
|
||||
src/presentation/stores/
|
||||
└── messageStore.ts # 简化的消息状态管理
|
||||
```
|
||||
|
||||
#### 重构步骤
|
||||
1. 提取 WebSocketClient - 纯连接管理
|
||||
2. 创建 MessageRepository - 数据访问抽象
|
||||
3. 创建 UseCases - 业务逻辑
|
||||
4. 重写 messageStore - 仅负责UI状态
|
||||
5. 删除旧的 messageManager.ts
|
||||
|
||||
### 2.2 乐观更新工具函数方案
|
||||
|
||||
#### 现状问题
|
||||
- userStore.ts 中重复6+次相同模式
|
||||
- 代码冗余,难以维护
|
||||
|
||||
#### 目标API
|
||||
```typescript
|
||||
// utils/optimisticUpdate.ts
|
||||
export interface OptimisticUpdateOptions<T, R> {
|
||||
store: StoreApi<any>;
|
||||
getCurrentState: () => T;
|
||||
optimisticUpdate: (current: T) => T;
|
||||
apiCall: () => Promise<R>;
|
||||
onSuccess?: (result: R, current: T) => T;
|
||||
onError?: (error: any, original: T) => void;
|
||||
}
|
||||
|
||||
export async function optimisticUpdate<T, R>(
|
||||
options: OptimisticUpdateOptions<T, R>
|
||||
): Promise<R>
|
||||
```
|
||||
|
||||
#### 使用示例
|
||||
```typescript
|
||||
// 重构前(重复代码)
|
||||
set(state => ({ posts: updatedPosts }));
|
||||
try {
|
||||
const result = await postService.like(postId);
|
||||
// ...
|
||||
} catch (error) {
|
||||
set(state => ({ posts: originalPosts }));
|
||||
}
|
||||
|
||||
// 重构后(简洁)
|
||||
await optimisticUpdate({
|
||||
store,
|
||||
getCurrentState: () => store.getState().posts,
|
||||
optimisticUpdate: (posts) => updatePostLikes(posts, postId, true),
|
||||
apiCall: () => postService.like(postId),
|
||||
onSuccess: (result, posts) => updatePostWithServerData(posts, result),
|
||||
});
|
||||
```
|
||||
|
||||
### 2.3 useResponsive.ts 拆分方案
|
||||
|
||||
#### 现状问题
|
||||
- 485行,43个导出
|
||||
- 功能混杂:断点检测、响应式值计算、平台检测
|
||||
|
||||
#### 目标结构
|
||||
```
|
||||
src/presentation/hooks/responsive/
|
||||
├── useBreakpoint.ts # 断点检测
|
||||
├── useResponsiveValue.ts # 响应式值映射
|
||||
├── useOrientation.ts # 方向检测
|
||||
├── usePlatform.ts # 平台检测
|
||||
├── useScreenSize.ts # 屏幕尺寸
|
||||
└── index.ts # 统一导出
|
||||
```
|
||||
|
||||
#### 各Hook职责
|
||||
- `useBreakpoint`: 返回当前断点 (mobile/tablet/desktop/wide)
|
||||
- `useResponsiveValue`: 根据断点返回不同值
|
||||
- `useOrientation`: 返回 portrait/landscape
|
||||
- `usePlatform`: 返回 ios/android/web
|
||||
- `useScreenSize`: 返回 width/height
|
||||
|
||||
### 2.4 MainNavigator 解耦方案
|
||||
|
||||
#### 现状问题
|
||||
- 1118行,直接依赖store
|
||||
- 混合了导航配置和业务逻辑
|
||||
|
||||
#### 目标结构
|
||||
```
|
||||
src/infrastructure/navigation/
|
||||
├── MainNavigator.tsx # 简化后的导航器
|
||||
├── AuthNavigator.tsx # 认证导航
|
||||
├── TabNavigator.tsx # 标签导航
|
||||
├── navigationService.ts # 导航服务(解耦层)
|
||||
├── navigationTypes.ts # 类型定义
|
||||
└── hooks/
|
||||
└── useNavigationState.ts # 导航状态hook
|
||||
```
|
||||
|
||||
#### 解耦策略
|
||||
1. 创建 NavigationService - 提供导航操作的抽象
|
||||
2. Store通过NavigationService触发导航,而非直接操作
|
||||
3. MainNavigator只负责渲染,不处理业务逻辑
|
||||
|
||||
### 2.5 Repository 层创建方案
|
||||
|
||||
#### 现状问题
|
||||
- 服务层直接操作数据库
|
||||
- 没有明确的数据访问边界
|
||||
|
||||
#### 目标结构
|
||||
```
|
||||
src/data/repositories/
|
||||
├── MessageRepository.ts # 消息数据访问
|
||||
├── PostRepository.ts # 帖子数据访问
|
||||
├── UserRepository.ts # 用户数据访问
|
||||
└── interfaces/
|
||||
├── IMessageRepository.ts
|
||||
├── IPostRepository.ts
|
||||
└── IUserRepository.ts
|
||||
```
|
||||
|
||||
#### 职责划分
|
||||
- **Repository**: 数据访问抽象,定义接口
|
||||
- **DataSource**: 具体数据源实现(API、SQLite、Cache)
|
||||
- **Service**: 业务逻辑编排,使用Repository
|
||||
|
||||
## 3. 实施计划
|
||||
|
||||
### 阶段1:基础设施(第1-2天)
|
||||
- [ ] 创建目录结构
|
||||
- [ ] 实现乐观更新工具函数
|
||||
- [ ] 拆分 useResponsive hooks
|
||||
|
||||
### 阶段2:数据层重构(第3-5天)
|
||||
- [ ] 创建 Repository 接口和实现
|
||||
- [ ] 创建 DataSource 层
|
||||
- [ ] 迁移数据库操作到 Repository
|
||||
|
||||
### 阶段3:核心业务重构(第6-10天)
|
||||
- [ ] 重构 messageManager → UseCases + Store
|
||||
- [ ] 创建 WebSocketClient
|
||||
- [ ] 实现消息同步逻辑
|
||||
|
||||
### 阶段4:表现层重构(第11-12天)
|
||||
- [ ] 解耦 MainNavigator
|
||||
- [ ] 重构 userStore 使用乐观更新工具
|
||||
- [ ] 更新所有屏幕组件
|
||||
|
||||
### 阶段5:测试与优化(第13-14天)
|
||||
- [ ] 单元测试
|
||||
- [ ] 集成测试
|
||||
- [ ] 性能优化
|
||||
|
||||
## 4. 风险与应对
|
||||
|
||||
| 风险 | 影响 | 应对措施 |
|
||||
|-----|------|---------|
|
||||
| 重构引入bug | 高 | 每个模块重构后必须测试,保持API兼容 |
|
||||
| 开发时间超期 | 中 | 分阶段交付,优先核心功能 |
|
||||
| 团队成员不熟悉新架构 | 中 | 编写详细文档,代码审查 |
|
||||
| 性能下降 | 低 | 添加性能监控,及时优化 |
|
||||
|
||||
## 5. 验收标准
|
||||
|
||||
- [ ] messageManager.ts 被删除,功能拆分到多个小模块
|
||||
- [ ] userStore.ts 中无重复乐观更新代码
|
||||
- [ ] useResponsive.ts 拆分为5+个专注hook
|
||||
- [ ] MainNavigator.tsx 行数 < 300
|
||||
- [ ] 所有单元测试通过
|
||||
- [ ] 无TypeScript错误
|
||||
- [ ] 性能不劣化(启动时间、内存占用)
|
||||
138
App.tsx
@@ -1,7 +1,137 @@
|
||||
/**
|
||||
* 应用入口已由 Expo Router 接管(package.json main: expo-router/entry)。
|
||||
* 全局 Provider 与根布局见 app/_layout.tsx。
|
||||
* 萝卜BBS - 主应用入口
|
||||
* 配置导航、主题、状态管理等
|
||||
*/
|
||||
export default function App() {
|
||||
return null;
|
||||
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import { AppState, AppStateStatus, Platform, StyleSheet } from 'react-native';
|
||||
import { StatusBar } from 'expo-status-bar';
|
||||
import { GestureHandlerRootView } from 'react-native-gesture-handler';
|
||||
import { SafeAreaProvider } from 'react-native-safe-area-context';
|
||||
import { PaperProvider } from 'react-native-paper';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import * as Notifications from 'expo-notifications';
|
||||
|
||||
// 配置通知处理器 - 必须在应用启动时设置
|
||||
Notifications.setNotificationHandler({
|
||||
handleNotification: async () => ({
|
||||
shouldShowAlert: true, // 即使在后台也要显示通知
|
||||
shouldPlaySound: true, // 播放声音
|
||||
shouldSetBadge: true, // 设置角标
|
||||
shouldShowBanner: true, // 显示横幅
|
||||
shouldShowList: true, // 显示通知列表
|
||||
}),
|
||||
});
|
||||
|
||||
import MainNavigator from './src/navigation/MainNavigator';
|
||||
import { paperTheme } from './src/theme';
|
||||
import AppPromptBar from './src/components/common/AppPromptBar';
|
||||
import AppDialogHost from './src/components/common/AppDialogHost';
|
||||
import { installAlertOverride } from './src/services/alertOverride';
|
||||
// 数据库初始化移到 authStore 中,根据用户ID创建用户专属数据库
|
||||
|
||||
// 创建 QueryClient 实例
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: 2,
|
||||
staleTime: 5 * 60 * 1000, // 5分钟
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
installAlertOverride();
|
||||
|
||||
// 注入全局CSS来移除React Native Web在浏览器中的默认focus outline
|
||||
if (Platform.OS === 'web') {
|
||||
const style = document.createElement('style');
|
||||
style.textContent = `
|
||||
/* 移除TextInput focus时的浏览器默认outline */
|
||||
input:focus, textarea:focus, select:focus {
|
||||
outline: none !important;
|
||||
outline-width: 0 !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
/* 移除focus-visible时的默认outline */
|
||||
input:focus-visible, textarea:focus-visible {
|
||||
outline: none !important;
|
||||
}
|
||||
|
||||
/* 移除所有:focus相关的默认样式 */
|
||||
*:focus {
|
||||
outline: none !important;
|
||||
}
|
||||
|
||||
*:focus-visible {
|
||||
outline: none !important;
|
||||
}
|
||||
`;
|
||||
document.head.appendChild(style);
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
const appState = useRef<AppStateStatus>(AppState.currentState);
|
||||
const notificationResponseListener = useRef<Notifications.EventSubscription | null>(null);
|
||||
|
||||
// 系统通知功能初始化
|
||||
useEffect(() => {
|
||||
const initNotifications = async () => {
|
||||
const { systemNotificationService } = await import('./src/services/systemNotificationService');
|
||||
await systemNotificationService.initialize();
|
||||
|
||||
// 初始化后台保活服务
|
||||
const { initBackgroundService } = await import('./src/services/backgroundService');
|
||||
await initBackgroundService();
|
||||
|
||||
// 监听 App 状态变化
|
||||
const subscription = AppState.addEventListener('change', (nextAppState) => {
|
||||
if (
|
||||
appState.current.match(/inactive|background/) &&
|
||||
nextAppState === 'active'
|
||||
) {
|
||||
systemNotificationService.clearBadge();
|
||||
}
|
||||
appState.current = nextAppState;
|
||||
});
|
||||
|
||||
// 监听通知点击响应
|
||||
notificationResponseListener.current = Notifications.addNotificationResponseReceivedListener(
|
||||
(response) => {
|
||||
const data = response.notification.request.content.data;
|
||||
void data;
|
||||
}
|
||||
);
|
||||
|
||||
// 监听通知到达(用于前台显示时额外处理)
|
||||
const notificationReceivedSubscription = Notifications.addNotificationReceivedListener(
|
||||
(notification) => {
|
||||
void notification;
|
||||
}
|
||||
);
|
||||
|
||||
return () => {
|
||||
subscription.remove();
|
||||
notificationResponseListener.current?.remove();
|
||||
notificationReceivedSubscription.remove();
|
||||
};
|
||||
};
|
||||
|
||||
initNotifications();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<GestureHandlerRootView style={{ flex: 1 }}>
|
||||
<SafeAreaProvider>
|
||||
<PaperProvider theme={paperTheme}>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<StatusBar style="light" />
|
||||
<MainNavigator />
|
||||
<AppPromptBar />
|
||||
<AppDialogHost />
|
||||
</QueryClientProvider>
|
||||
</PaperProvider>
|
||||
</SafeAreaProvider>
|
||||
</GestureHandlerRootView>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
FROM node:25-alpine AS builder
|
||||
FROM node:20-alpine AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
@@ -6,11 +6,10 @@ COPY package.json package-lock.json ./
|
||||
RUN npm ci
|
||||
|
||||
COPY . .
|
||||
RUN node /app/node_modules/.bin/expo export --platform web --output-dir dist-web
|
||||
RUN npx expo export --platform web --output-dir dist-web
|
||||
|
||||
FROM nginx:1.27-alpine
|
||||
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
COPY --from=builder /app/dist-web /usr/share/nginx/html
|
||||
|
||||
EXPOSE 80
|
||||
|
||||
@@ -1,27 +1,10 @@
|
||||
const appJson = require('./app.json');
|
||||
const { execSync } = require('child_process');
|
||||
|
||||
const isDevVariant = process.env.APP_VARIANT === 'dev';
|
||||
const releaseApiBaseUrl = 'https://withyou.littlelan.cn/api/v1';
|
||||
const releaseApiBaseUrl = 'https://bbs.littlelan.cn/api/v1';
|
||||
const releaseUpdatesBaseUrl = 'https://updates.littlelan.cn';
|
||||
const devApiBaseUrl = process.env.EXPO_PUBLIC_API_BASE_URL || 'http://192.168.31.238:8080/api/v1';
|
||||
|
||||
function getCommitCount() {
|
||||
try {
|
||||
return execSync('git rev-list --count HEAD', { encoding: 'utf-8' }).trim();
|
||||
} catch {
|
||||
return '1';
|
||||
}
|
||||
}
|
||||
|
||||
// 在 commit count 上加偏移,保证 versionCode / buildNumber / runtimeVersion
|
||||
// 始终大于历史最大值(之前的最后一个版本是 5547),避免被系统/Play Store
|
||||
// 误判为降级。后续即使 commit count 重置或换仓库,偏移也能保证单调递增。
|
||||
const BUILD_NUMBER_OFFSET = 100000;
|
||||
|
||||
const commitCount = getCommitCount();
|
||||
const buildNumber = String(parseInt(commitCount, 10) + BUILD_NUMBER_OFFSET);
|
||||
|
||||
function toManifestUrl(baseUrl, portOverride) {
|
||||
const parsed = new URL(baseUrl);
|
||||
if (portOverride != null) {
|
||||
@@ -40,72 +23,27 @@ const devUpdatesUrl =
|
||||
|
||||
const expo = appJson.expo;
|
||||
|
||||
// 检测是否为 Web 平台
|
||||
const isWeb = process.env.EXPO_TARGET === 'web' || process.env.PLATFORM === 'web';
|
||||
|
||||
// 原生平台特有的插件(在 Web 端会报错)
|
||||
const nativeOnlyPlugins = [
|
||||
'expo-camera',
|
||||
'expo-notifications',
|
||||
'expo-background-fetch',
|
||||
'expo-media-library',
|
||||
'expo-image-picker',
|
||||
'expo-video',
|
||||
'expo-sqlite',
|
||||
'./plugins/withMainActivityConfigChange',
|
||||
];
|
||||
|
||||
// 过滤插件:Web 端排除原生插件
|
||||
const filteredPlugins = isWeb
|
||||
? expo.plugins.filter((plugin) => {
|
||||
const pluginName = Array.isArray(plugin) ? plugin[0] : plugin;
|
||||
return !nativeOnlyPlugins.includes(pluginName);
|
||||
})
|
||||
: expo.plugins;
|
||||
|
||||
module.exports = {
|
||||
...expo,
|
||||
name: isDevVariant ? `${expo.name} Dev` : expo.name,
|
||||
// runtimeVersion 用 build number(commit count + 偏移):单调递增、纯数字、与 version (语义版本) 解耦
|
||||
// 字符串形式,等价于 policy: 'custom'
|
||||
runtimeVersion: buildNumber,
|
||||
runtimeVersion: {
|
||||
policy: 'appVersion',
|
||||
},
|
||||
updates: {
|
||||
...(expo.updates || {}),
|
||||
url: isDevVariant ? devUpdatesUrl : releaseUpdatesUrl,
|
||||
checkAutomatically: 'ON_LOAD',
|
||||
fallbackToCacheTimeout: 0,
|
||||
},
|
||||
ios: {
|
||||
...expo.ios,
|
||||
buildNumber: buildNumber,
|
||||
},
|
||||
ios: expo.ios,
|
||||
android: {
|
||||
...expo.android,
|
||||
package: 'cn.qczlit.withyou',
|
||||
versionCode: parseInt(buildNumber, 10),
|
||||
package: 'skin.carrot.bbs',
|
||||
},
|
||||
// Web 端使用过滤后的插件
|
||||
plugins: filteredPlugins,
|
||||
extra: {
|
||||
...(expo.extra || {}),
|
||||
appVariant: isDevVariant ? 'dev' : 'release',
|
||||
apiBaseUrl: isDevVariant ? devApiBaseUrl : releaseApiBaseUrl,
|
||||
updatesUrl: isDevVariant ? devUpdatesUrl : releaseUpdatesUrl,
|
||||
},
|
||||
// Web 端配置
|
||||
...(isWeb && {
|
||||
web: {
|
||||
...expo.web,
|
||||
build: {
|
||||
...expo.web?.build,
|
||||
// 配置 Web 端别名,避免加载原生模块
|
||||
babel: {
|
||||
dangerouslyAddModulePathsToTranspile: [
|
||||
'livekit-client',
|
||||
'@livekit',
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
};
|
||||
|
||||
100
app.json
@@ -1,12 +1,11 @@
|
||||
{
|
||||
"expo": {
|
||||
"name": "威友",
|
||||
"name": "萝卜社区",
|
||||
"slug": "qojo",
|
||||
"version": "1.0.1",
|
||||
"version": "1.0.10",
|
||||
"orientation": "default",
|
||||
"icon": "./assets/icon.png",
|
||||
"userInterfaceStyle": "automatic",
|
||||
"scheme": "withyou",
|
||||
"userInterfaceStyle": "light",
|
||||
"splash": {
|
||||
"image": "./assets/splash-icon.png",
|
||||
"resizeMode": "contain",
|
||||
@@ -15,31 +14,28 @@
|
||||
"ios": {
|
||||
"supportsTablet": true,
|
||||
"infoPlist": {
|
||||
"NSMicrophoneUsageDescription": "允许威友访问您的麦克风以进行语音通话",
|
||||
"NSCameraUsageDescription": "允许威友访问您的相机以进行视频通话",
|
||||
"UIBackgroundModes": [
|
||||
"fetch",
|
||||
"remote-notification",
|
||||
"audio"
|
||||
"fetch",
|
||||
"remote-notification"
|
||||
],
|
||||
"ITSAppUsesNonExemptEncryption": false
|
||||
},
|
||||
"bundleIdentifier": "cn.qczlit.weiyou"
|
||||
"bundleIdentifier": "skin.carrot.bbs"
|
||||
},
|
||||
"android": {
|
||||
"googleServicesFile": "./google-services.json",
|
||||
"adaptiveIcon": {
|
||||
"backgroundColor": "#0570F9",
|
||||
"backgroundColor": "#E6F4FE",
|
||||
"foregroundImage": "./assets/android-icon-foreground.png",
|
||||
"backgroundImage": "./assets/android-icon-background.png",
|
||||
"monochromeImage": "./assets/android-icon-monochrome.png"
|
||||
},
|
||||
"predictiveBackGestureEnabled": false,
|
||||
"package": "cn.qczlit.withyou",
|
||||
"package": "skin.carrot.bbs",
|
||||
"versionCode": 5,
|
||||
"permissions": [
|
||||
"VIBRATE",
|
||||
"RECORD_AUDIO",
|
||||
"CAMERA",
|
||||
"RECEIVE_BOOT_COMPLETED",
|
||||
"WAKE_LOCK",
|
||||
"READ_EXTERNAL_STORAGE",
|
||||
@@ -52,32 +48,16 @@
|
||||
"android.permission.READ_EXTERNAL_STORAGE",
|
||||
"android.permission.WRITE_EXTERNAL_STORAGE",
|
||||
"android.permission.READ_MEDIA_VISUAL_USER_SELECTED",
|
||||
"android.permission.ACCESS_MEDIA_LOCATION",
|
||||
"android.permission.READ_MEDIA_IMAGES",
|
||||
"android.permission.READ_MEDIA_VIDEO",
|
||||
"android.permission.READ_MEDIA_AUDIO",
|
||||
"android.permission.ACCESS_NETWORK_STATE",
|
||||
"android.permission.ACCESS_WIFI_STATE",
|
||||
"android.permission.POST_NOTIFICATIONS"
|
||||
"android.permission.READ_MEDIA_AUDIO"
|
||||
]
|
||||
},
|
||||
"web": {
|
||||
"favicon": "./assets/favicon.png"
|
||||
},
|
||||
"plugins": [
|
||||
"./plugins/withCmakeJobLimit",
|
||||
"./plugins/withJcorePatch",
|
||||
"./plugins/withSigning",
|
||||
"./plugins/withMainActivityConfigChange",
|
||||
[
|
||||
"./plugins/withForegroundService",
|
||||
{
|
||||
"notificationTitle": "威友",
|
||||
"notificationBody": "正在后台同步消息",
|
||||
"notificationIcon": "ic_notification",
|
||||
"channelId": "withyou_sync_foreground",
|
||||
"channelName": "消息同步"
|
||||
}
|
||||
],
|
||||
[
|
||||
"expo-router",
|
||||
{
|
||||
@@ -89,31 +69,30 @@
|
||||
],
|
||||
"expo-sqlite",
|
||||
[
|
||||
"expo-camera",
|
||||
"expo-notifications",
|
||||
{
|
||||
"cameraPermission": "允许威友访问您的相机以扫描二维码"
|
||||
"sounds": []
|
||||
}
|
||||
],
|
||||
[
|
||||
"expo-background-task",
|
||||
"expo-background-fetch",
|
||||
{
|
||||
"minimumInterval": 15
|
||||
"minimumInterval": 900
|
||||
}
|
||||
],
|
||||
"./plugins/withRemoveAutoStart",
|
||||
[
|
||||
"expo-media-library",
|
||||
{
|
||||
"photosPermission": "允许威友访问您的照片以发布内容",
|
||||
"savePhotosPermission": "允许威友保存照片到您的相册",
|
||||
"isAccessMediaLocationEnabled": false
|
||||
"photosPermission": "允许萝卜社区访问您的照片以发布内容",
|
||||
"savePhotosPermission": "允许萝卜社区保存照片到您的相册",
|
||||
"isAccessMediaLocationEnabled": true
|
||||
}
|
||||
],
|
||||
[
|
||||
[
|
||||
"expo-image-picker",
|
||||
{
|
||||
"photosPermission": "允许威友访问您的照片以选择图片",
|
||||
"cameraPermission": "允许威友访问您的相机以拍摄图片",
|
||||
"photosPermission": "允许萝卜社区访问您的照片以选择图片",
|
||||
"cameraPermission": "允许萝卜社区访问您的相机以拍摄图片",
|
||||
"colors": {
|
||||
"cropToolbarColor": "#111827",
|
||||
"cropToolbarIconColor": "#ffffff",
|
||||
@@ -138,44 +117,13 @@
|
||||
"proguardRules": "-keep class com.google.android.exoplayer2.** { *; }"
|
||||
}
|
||||
],
|
||||
"expo-font",
|
||||
[
|
||||
"expo-notifications",
|
||||
{
|
||||
"color": "#E6F4FE",
|
||||
"defaultChannel": "default",
|
||||
"enableBackgroundRemoteNotifications": true
|
||||
}
|
||||
],
|
||||
[
|
||||
"./plugins/withJPush",
|
||||
{
|
||||
"appKey": "2cbe4da12e2c24aa3dca4610",
|
||||
"channel": "developer-default"
|
||||
}
|
||||
],
|
||||
"./plugins/withHuaweiPush",
|
||||
"./plugins/withXiaomiPush",
|
||||
"./plugins/withHonorPush",
|
||||
"./plugins/withVivoPush",
|
||||
"./plugins/withOppoPush",
|
||||
"expo-callkit-telecom",
|
||||
[
|
||||
"expo-splash-screen",
|
||||
{
|
||||
"image": "./assets/splash-icon.png",
|
||||
"resizeMode": "contain",
|
||||
"backgroundColor": "#ffffff"
|
||||
}
|
||||
],
|
||||
"expo-status-bar"
|
||||
"expo-font"
|
||||
],
|
||||
"jsEngine": "hermes",
|
||||
"extra": {
|
||||
"eas": {
|
||||
"projectId": "65540196-d37d-437b-8496-227df0317069"
|
||||
},
|
||||
"jpushAppKey": "2cbe4da12e2c24aa3dca4610",
|
||||
"jpushChannel": "developer-default"
|
||||
}
|
||||
},
|
||||
"owner": "qojo"
|
||||
}
|
||||
|
||||
@@ -1,176 +0,0 @@
|
||||
import { useMemo, useCallback } from 'react';
|
||||
import { Platform, Pressable, useWindowDimensions } from 'react-native';
|
||||
import { Tabs, usePathname, useRouter } from 'expo-router';
|
||||
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
import type { PressableProps } from 'react-native';
|
||||
|
||||
import { useAppColors, shadows } from '../../../src/theme';
|
||||
import { BREAKPOINTS } from '../../../src/hooks';
|
||||
import { useHomeTabBarVisibilityStore, useTotalUnreadCount, useHomeTabPressStore } from '../../../src/stores';
|
||||
import { hrefHome } from '../../../src/navigation/hrefs';
|
||||
|
||||
const TAB_BAR_HEIGHT = 56;
|
||||
const TAB_BAR_FLOATING_MARGIN = 12;
|
||||
const TAB_BAR_MARGIN = 20;
|
||||
|
||||
const TabBarButton = ({ children, style, ...rest }: PressableProps) => (
|
||||
<Pressable
|
||||
{...rest}
|
||||
style={style}
|
||||
android_ripple={null}
|
||||
>
|
||||
{children}
|
||||
</Pressable>
|
||||
);
|
||||
|
||||
export default function TabsLayout() {
|
||||
const colors = useAppColors();
|
||||
const { width } = useWindowDimensions();
|
||||
const insets = useSafeAreaInsets();
|
||||
const pathname = usePathname();
|
||||
const router = useRouter();
|
||||
const unreadCount = useTotalUnreadCount();
|
||||
const scrollHideTabBar = useHomeTabBarVisibilityStore((s) => s.bottomTabBarHiddenByScroll);
|
||||
const triggerHomeTabPress = useHomeTabPressStore((s) => s.triggerPress);
|
||||
const hideTabBar = Platform.OS === 'web' && width >= BREAKPOINTS.desktop;
|
||||
|
||||
const isHomeStackRoute = pathname === '/home' || pathname.startsWith('/home/');
|
||||
|
||||
const handleHomeTabPress = useCallback(
|
||||
(e: { preventDefault: () => void }) => {
|
||||
if (!isHomeStackRoute) return;
|
||||
e.preventDefault();
|
||||
if (pathname === '/home') {
|
||||
triggerHomeTabPress();
|
||||
} else {
|
||||
router.navigate(hrefHome());
|
||||
}
|
||||
},
|
||||
[isHomeStackRoute, pathname, triggerHomeTabPress, router]
|
||||
);
|
||||
|
||||
const handleAppsTabPress = useCallback(() => {
|
||||
if (pathname.startsWith('/apps/')) {
|
||||
router.replace('/apps');
|
||||
}
|
||||
}, [pathname, router]);
|
||||
|
||||
const handleProfileTabPress = useCallback(() => {
|
||||
if (pathname.startsWith('/profile/')) {
|
||||
router.replace('/profile');
|
||||
}
|
||||
}, [pathname, router]);
|
||||
|
||||
const tabBarStyle = useMemo(() => {
|
||||
if (hideTabBar) {
|
||||
return { display: 'none' as const, height: 0, overflow: 'hidden' as const };
|
||||
}
|
||||
const visibleStyle = {
|
||||
position: 'absolute' as const,
|
||||
left: 0,
|
||||
right: 0,
|
||||
marginHorizontal: TAB_BAR_MARGIN,
|
||||
bottom: TAB_BAR_FLOATING_MARGIN + insets.bottom,
|
||||
height: TAB_BAR_HEIGHT,
|
||||
borderRadius: 22,
|
||||
backgroundColor: colors.background.paper,
|
||||
...shadows.lg,
|
||||
borderTopWidth: 0,
|
||||
elevation: 100,
|
||||
zIndex: 100,
|
||||
};
|
||||
if (isHomeStackRoute && scrollHideTabBar) {
|
||||
return {
|
||||
...visibleStyle,
|
||||
display: 'none' as const,
|
||||
height: 0,
|
||||
opacity: 0,
|
||||
overflow: 'hidden' as const,
|
||||
};
|
||||
}
|
||||
return visibleStyle;
|
||||
}, [hideTabBar, insets.bottom, isHomeStackRoute, scrollHideTabBar, colors]);
|
||||
|
||||
return (
|
||||
<Tabs
|
||||
screenOptions={{
|
||||
headerShown: false,
|
||||
tabBarActiveTintColor: colors.primary.main,
|
||||
tabBarInactiveTintColor: colors.text.secondary,
|
||||
tabBarStyle,
|
||||
tabBarItemStyle: {
|
||||
borderRadius: 16,
|
||||
marginHorizontal: 2,
|
||||
paddingVertical: 1,
|
||||
},
|
||||
tabBarButton: (props) => <TabBarButton {...props} />,
|
||||
tabBarShowLabel: true,
|
||||
tabBarLabelStyle: { fontSize: 11, fontWeight: '600', marginTop: -1 },
|
||||
}}
|
||||
>
|
||||
<Tabs.Screen
|
||||
name="home"
|
||||
options={{
|
||||
title: '首页',
|
||||
tabBarIcon: ({ color, focused }) => (
|
||||
<MaterialCommunityIcons
|
||||
name={focused ? 'home' : 'home-outline'}
|
||||
size={focused ? 24 : 22}
|
||||
color={color}
|
||||
/>
|
||||
),
|
||||
}}
|
||||
listeners={{
|
||||
tabPress: handleHomeTabPress,
|
||||
}}
|
||||
/>
|
||||
<Tabs.Screen
|
||||
name="apps"
|
||||
options={{
|
||||
title: '应用',
|
||||
tabBarIcon: ({ color, focused }) => (
|
||||
<MaterialCommunityIcons
|
||||
name={focused ? 'view-grid' : 'view-grid-outline'}
|
||||
size={focused ? 24 : 22}
|
||||
color={color}
|
||||
/>
|
||||
),
|
||||
}}
|
||||
listeners={{
|
||||
tabPress: handleAppsTabPress,
|
||||
}}
|
||||
/>
|
||||
<Tabs.Screen
|
||||
name="messages"
|
||||
options={{
|
||||
title: '消息',
|
||||
tabBarBadge: unreadCount > 0 ? (unreadCount > 99 ? 99 : unreadCount) : undefined,
|
||||
tabBarIcon: ({ color, focused }) => (
|
||||
<MaterialCommunityIcons
|
||||
name={focused ? 'message-text' : 'message-text-outline'}
|
||||
size={focused ? 24 : 22}
|
||||
color={color}
|
||||
/>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
<Tabs.Screen
|
||||
name="profile"
|
||||
options={{
|
||||
title: '我的',
|
||||
tabBarIcon: ({ color, focused }) => (
|
||||
<MaterialCommunityIcons
|
||||
name={focused ? 'account' : 'account-outline'}
|
||||
size={focused ? 24 : 22}
|
||||
color={color}
|
||||
/>
|
||||
),
|
||||
}}
|
||||
listeners={{
|
||||
tabPress: handleProfileTabPress,
|
||||
}}
|
||||
/>
|
||||
</Tabs>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
import { Stack } from 'expo-router';
|
||||
|
||||
export default function AppsStackLayout() {
|
||||
return <Stack screenOptions={{ headerShown: false }} />;
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
import { AppsScreen } from '../../../../src/screens/apps';
|
||||
|
||||
export default function AppsRoute() {
|
||||
return <AppsScreen />;
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
import { Stack } from 'expo-router';
|
||||
|
||||
export default function MaterialsStackLayout() {
|
||||
return (
|
||||
<Stack screenOptions={{ headerShown: false }}>
|
||||
<Stack.Screen name="index" />
|
||||
<Stack.Screen name="subject" />
|
||||
<Stack.Screen name="detail" />
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
import { MaterialDetailScreen } from '../../../../../src/screens/material';
|
||||
|
||||
export default function MaterialDetailRoute() {
|
||||
return <MaterialDetailScreen />;
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
import { MaterialsScreen } from '../../../../../src/screens/material';
|
||||
|
||||
export default function MaterialsRoute() {
|
||||
return <MaterialsScreen />;
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
import { SubjectMaterialsScreen } from '../../../../../src/screens/material';
|
||||
|
||||
export default function SubjectMaterialsRoute() {
|
||||
return <SubjectMaterialsScreen />;
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
import { Stack } from 'expo-router';
|
||||
|
||||
export default function ScheduleStackLayout() {
|
||||
return (
|
||||
<Stack screenOptions={{ headerShown: false }}>
|
||||
<Stack.Screen name="index" />
|
||||
<Stack.Screen
|
||||
name="course"
|
||||
options={{
|
||||
headerShown: false,
|
||||
presentation: 'transparentModal',
|
||||
animation: 'fade',
|
||||
}}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
import { CourseDetailScreen } from '../../../../../src/screens/schedule';
|
||||
|
||||
export default function CourseDetailRoute() {
|
||||
return <CourseDetailScreen />;
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
import { ScheduleScreen } from '../../../../../src/screens/schedule';
|
||||
|
||||
export default function ScheduleRoute() {
|
||||
return <ScheduleScreen />;
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
import { Stack } from 'expo-router';
|
||||
|
||||
export default function HomeStackLayout() {
|
||||
return <Stack screenOptions={{ headerShown: false }} />;
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
import { HomeScreen } from '../../../../src/screens/home';
|
||||
|
||||
export default function HomeRoute() {
|
||||
return <HomeScreen />;
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
import { SearchScreen } from '../../../../src/screens/home';
|
||||
|
||||
export default function HomeSearchRoute() {
|
||||
return <SearchScreen />;
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
import { Stack } from 'expo-router';
|
||||
|
||||
export default function MessagesStackLayout() {
|
||||
return <Stack screenOptions={{ headerShown: false }} />;
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
import { MessageListScreen } from '../../../../src/screens/message';
|
||||
|
||||
export default function MessagesRoute() {
|
||||
return <MessageListScreen />;
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
import { NotificationsScreen } from '../../../../src/screens/message';
|
||||
|
||||
export default function NotificationsRoute() {
|
||||
return <NotificationsScreen />;
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
import { Stack } from 'expo-router';
|
||||
|
||||
export default function ProfileStackLayout() {
|
||||
return (
|
||||
<Stack
|
||||
screenOptions={{
|
||||
headerShown: false,
|
||||
}}
|
||||
>
|
||||
<Stack.Screen name="index" />
|
||||
<Stack.Screen name="settings" />
|
||||
<Stack.Screen name="edit-profile" />
|
||||
<Stack.Screen name="account-security" />
|
||||
<Stack.Screen name="my-posts" />
|
||||
<Stack.Screen name="bookmarks" />
|
||||
<Stack.Screen name="notification-settings" />
|
||||
<Stack.Screen name="blocked-users" />
|
||||
<Stack.Screen name="chat-settings" />
|
||||
<Stack.Screen name="about" />
|
||||
<Stack.Screen name="verification" />
|
||||
<Stack.Screen name="data-storage" />
|
||||
<Stack.Screen name="privacy-settings" />
|
||||
<Stack.Screen name="account-deletion" />
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
import { AboutScreen } from '../../../../src/screens/profile/AboutScreen';
|
||||
|
||||
export default function AboutRoute() {
|
||||
return <AboutScreen />;
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
import { AccountDeletionScreen } from '../../../../src/screens/profile';
|
||||
|
||||
export default function AccountDeletionRoute() {
|
||||
return <AccountDeletionScreen />;
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
import { AccountSecurityScreen } from '../../../../src/screens/profile';
|
||||
|
||||
export default function AccountSecurityRoute() {
|
||||
return <AccountSecurityScreen />;
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
import { BlockedUsersScreen } from '../../../../src/screens/profile';
|
||||
|
||||
export default function BlockedUsersRoute() {
|
||||
return <BlockedUsersScreen />;
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
import { ProfileScreen } from '../../../../src/screens/profile';
|
||||
|
||||
export default function BookmarksRoute() {
|
||||
return <ProfileScreen />;
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
import { ChatSettingsScreen } from '../../../../src/screens/profile/ChatSettingsScreen';
|
||||
|
||||
export default function ChatSettingsRoute() {
|
||||
return <ChatSettingsScreen />;
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
import { DataStorageScreen } from '../../../../src/screens/profile';
|
||||
|
||||
export default function DataStorageRoute() {
|
||||
return <DataStorageScreen />;
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
import { EditProfileScreen } from '../../../../src/screens/profile';
|
||||
|
||||
export default function EditProfileRoute() {
|
||||
return <EditProfileScreen />;
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
import { ProfileScreen } from '../../../../src/screens/profile';
|
||||
|
||||
export default function ProfileRoute() {
|
||||
return <ProfileScreen />;
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
import { ProfileScreen } from '../../../../src/screens/profile';
|
||||
|
||||
export default function MyPostsRoute() {
|
||||
return <ProfileScreen />;
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
import { NotificationSettingsScreen } from '../../../../src/screens/profile';
|
||||
|
||||
export default function NotificationSettingsRoute() {
|
||||
return <NotificationSettingsScreen />;
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
import { PrivacySettingsScreen } from '../../../../src/screens/profile';
|
||||
|
||||
export default function PrivacySettingsRoute() {
|
||||
return <PrivacySettingsScreen />;
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
import { SettingsScreen } from '../../../../src/screens/profile';
|
||||
|
||||
export default function SettingsRoute() {
|
||||
return <SettingsScreen />;
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
import { VerificationSettingsScreen } from '@/screens/profile/VerificationSettingsScreen';
|
||||
|
||||
export default function VerificationSettingsPage() {
|
||||
return <VerificationSettingsScreen />;
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { ActivityIndicator, View } from 'react-native';
|
||||
import { Redirect } from 'expo-router';
|
||||
|
||||
import { AppRouteStack } from '../../src/app-navigation/AppRouteStack';
|
||||
import { messageManager, useAuthStore } from '../../src/stores';
|
||||
import { useRegisterPushDevice } from '../../src/hooks';
|
||||
import { hrefAuthLogin } from '../../src/navigation/hrefs';
|
||||
import { useAppColors } from '../../src/theme';
|
||||
|
||||
export default function AppLayout() {
|
||||
const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
|
||||
const fetchCurrentUser = useAuthStore((s) => s.fetchCurrentUser);
|
||||
const userID = useAuthStore((s) => s.currentUser?.id);
|
||||
const colors = useAppColors();
|
||||
const [verified, setVerified] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
// 冷启动 token 校验:仅鉴权分组 (app) 需要。
|
||||
// 公开页(/privacy、/terms)和 (auth) 分组在根布局直接渲染,不受此校验影响,
|
||||
// 因此冷启动 401 不会把公开页顶到 /login。
|
||||
fetchCurrentUser().finally(() => setVerified(true));
|
||||
}, [fetchCurrentUser]);
|
||||
|
||||
useRegisterPushDevice(isAuthenticated, userID);
|
||||
|
||||
useEffect(() => {
|
||||
messageManager.initialize();
|
||||
}, []);
|
||||
|
||||
// 持久化状态显示已登录则立即渲染(后台静默校验)
|
||||
if (isAuthenticated) {
|
||||
return <AppRouteStack />;
|
||||
}
|
||||
|
||||
// 未登录且校验未完成,显示 loading
|
||||
if (!verified) {
|
||||
return (
|
||||
<View
|
||||
style={{
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
backgroundColor: colors.background.default,
|
||||
}}
|
||||
>
|
||||
<ActivityIndicator size="large" color={colors.primary.main} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
// 校验完成仍未登录,跳转登录页
|
||||
return <Redirect href={hrefAuthLogin()} />;
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { ActivityIndicator, View, useWindowDimensions } from 'react-native';
|
||||
import { Redirect } from 'expo-router';
|
||||
|
||||
import { AppDesktopShell } from '../../src/app-navigation/AppDesktopShell';
|
||||
import { AppRouteStack } from '../../src/app-navigation/AppRouteStack';
|
||||
import { BREAKPOINTS } from '../../src/hooks';
|
||||
import { messageManager, useAuthStore } from '../../src/stores';
|
||||
import { hrefAuthLogin } from '../../src/navigation/hrefs';
|
||||
import { useAppColors } from '../../src/theme';
|
||||
|
||||
export default function AppLayoutWeb() {
|
||||
const { width } = useWindowDimensions();
|
||||
const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
|
||||
const fetchCurrentUser = useAuthStore((s) => s.fetchCurrentUser);
|
||||
const useDesktopShell = width >= BREAKPOINTS.desktop;
|
||||
const colors = useAppColors();
|
||||
const [verified, setVerified] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
// 冷启动 token 校验:仅鉴权分组 (app) 需要。
|
||||
// 公开页(/privacy、/terms)和 (auth) 分组在根布局直接渲染,不受此校验影响。
|
||||
fetchCurrentUser().finally(() => setVerified(true));
|
||||
}, [fetchCurrentUser]);
|
||||
|
||||
useEffect(() => {
|
||||
messageManager.initialize();
|
||||
}, []);
|
||||
|
||||
// 持久化状态显示已登录则立即渲染(后台静默校验)
|
||||
if (isAuthenticated) {
|
||||
if (useDesktopShell) {
|
||||
return <AppDesktopShell />;
|
||||
}
|
||||
return <AppRouteStack />;
|
||||
}
|
||||
|
||||
// 未登录且校验未完成,显示 loading
|
||||
if (!verified) {
|
||||
return (
|
||||
<View
|
||||
style={{
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
backgroundColor: colors.background.default,
|
||||
}}
|
||||
>
|
||||
<ActivityIndicator size="large" color={colors.primary.main} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
// 校验完成仍未登录,跳转登录页
|
||||
return <Redirect href={hrefAuthLogin()} />;
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
import { ChatScreen } from '../../../src/screens/message';
|
||||
|
||||
export default function ChatRoute() {
|
||||
return <ChatScreen />;
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
import { MessageSearchScreen } from '../../../src/screens/message';
|
||||
|
||||
export default function MessageSearchRoute() {
|
||||
return <MessageSearchScreen />;
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
import { PrivateChatInfoScreen } from '../../../src/screens/message';
|
||||
|
||||
export default function PrivateChatInfoRoute() {
|
||||
return <PrivateChatInfoScreen />;
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
import { GroupInfoScreen } from '../../../../src/screens/message';
|
||||
|
||||
export default function GroupInfoRoute() {
|
||||
return <GroupInfoScreen />;
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
import { GroupMembersScreen } from '../../../../src/screens/message';
|
||||
|
||||
export default function GroupMembersRoute() {
|
||||
return <GroupMembersScreen />;
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
import { CreateGroupScreen } from '../../../src/screens/message';
|
||||
|
||||
export default function CreateGroupRoute() {
|
||||
return <CreateGroupScreen />;
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
import GroupInviteDetailScreen from '../../../src/screens/message/GroupInviteDetailScreen';
|
||||
|
||||
export default function GroupInviteRoute() {
|
||||
return <GroupInviteDetailScreen />;
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
import { JoinGroupScreen } from '../../../src/screens/message';
|
||||
|
||||
export default function JoinGroupRoute() {
|
||||
return <JoinGroupScreen />;
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
import GroupRequestDetailScreen from '../../../src/screens/message/GroupRequestDetailScreen';
|
||||
|
||||
export default function GroupRequestRoute() {
|
||||
return <GroupRequestDetailScreen />;
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
import { CreatePostScreen } from '../../../src/screens/create';
|
||||
|
||||
export default function CreatePostRoute() {
|
||||
return <CreatePostScreen />;
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
import { QRCodeConfirmScreen } from '../../../../src/screens/auth/QRCodeConfirmScreen';
|
||||
|
||||
export default function QrLoginConfirmRoute() {
|
||||
return <QRCodeConfirmScreen />;
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
import FollowListScreen from '../../../../src/screens/profile/FollowListScreen';
|
||||
|
||||
export default function FollowListRoute() {
|
||||
return <FollowListScreen />;
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
import { Stack } from 'expo-router';
|
||||
|
||||
export default function AuthLayout() {
|
||||
return <Stack screenOptions={{ headerShown: false }} />;
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
import { ForgotPasswordScreen } from '../../src/screens/auth';
|
||||
|
||||
export default function ForgotPasswordRoute() {
|
||||
return <ForgotPasswordScreen />;
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
import { LoginScreen } from '../../src/screens/auth';
|
||||
|
||||
export default function LoginRoute() {
|
||||
return <LoginScreen />;
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
import { RegisterScreen } from '../../src/screens/auth';
|
||||
|
||||
export default function RegisterRoute() {
|
||||
return <RegisterScreen />;
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
import { VerificationFormScreen } from '@/screens/auth/VerificationFormScreen';
|
||||
|
||||
export default function VerificationFormPage() {
|
||||
return <VerificationFormScreen />;
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
import { VerificationGuideScreen } from '@/screens/auth/VerificationGuideScreen';
|
||||
|
||||
export default function VerificationGuidePage() {
|
||||
return <VerificationGuideScreen />;
|
||||
}
|
||||
297
app/_layout.tsx
@@ -1,297 +0,0 @@
|
||||
import '../src/polyfills';
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import { AppState, AppStateStatus, Platform, View, ActivityIndicator } from 'react-native';
|
||||
import { Stack, useRouter } from 'expo-router';
|
||||
import { StatusBar } from 'expo-status-bar';
|
||||
import { GestureHandlerRootView } from 'react-native-gesture-handler';
|
||||
import { SafeAreaProvider } from 'react-native-safe-area-context';
|
||||
import { PaperProvider } from 'react-native-paper';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import * as SystemUI from 'expo-system-ui';
|
||||
import { useFonts } from 'expo-font';
|
||||
|
||||
import { registerNotificationPresentationHandler } from '@/services/notification';
|
||||
import { EventSubscriber } from '../src/infrastructure/EventSubscriber';
|
||||
import {
|
||||
ThemeBootstrap,
|
||||
useAppColors,
|
||||
usePaperThemeFromStore,
|
||||
useResolvedColorScheme,
|
||||
} from '../src/theme';
|
||||
|
||||
import AppPromptBar from '../src/components/common/AppPromptBar';
|
||||
import AppDialogHost from '../src/components/common/AppDialogHost';
|
||||
import { installAlertOverride } from '@/services/ui';
|
||||
import { checkForAPKUpdate } from '@/services/platform';
|
||||
import { CallScreen, IncomingCallModal, FloatingCallWindow } from '../src/components/call';
|
||||
import { jpushService } from '@/services/notification/jpushService';
|
||||
import { callKeepService } from '@/services/callkeep';
|
||||
import { callStore } from '@/stores/call';
|
||||
import * as hrefs from '../src/navigation/hrefs';
|
||||
|
||||
registerNotificationPresentationHandler();
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: 2,
|
||||
staleTime: 5 * 60 * 1000,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
installAlertOverride();
|
||||
|
||||
if (Platform.OS === 'web' && typeof document !== 'undefined') {
|
||||
const style = document.createElement('style');
|
||||
style.textContent = `
|
||||
input:focus, textarea:focus, select:focus {
|
||||
outline: none !important;
|
||||
outline-width: 0 !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
input:focus-visible, textarea:focus-visible {
|
||||
outline: none !important;
|
||||
}
|
||||
*:focus { outline: none !important; }
|
||||
*:focus-visible { outline: none !important; }
|
||||
/* 修复移动端滑动问题 - 仅对根容器限制 */
|
||||
html, body {
|
||||
overscroll-behavior: none;
|
||||
touch-action: manipulation;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
/* React Native Web 生成的滚动容器 - 允许双向滑动 */
|
||||
[class*="css-view"] {
|
||||
touch-action: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
/* 水平滚动容器 */
|
||||
[data-horizontal-scroll="true"],
|
||||
div[style*="overflow-x"],
|
||||
div[style*="overflow: scroll"],
|
||||
div[style*="overflow: auto"] {
|
||||
touch-action: pan-x pan-y;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
/* 手势区域 - 允许滑动手势 */
|
||||
[data-gesture-area="true"] {
|
||||
touch-action: pan-x pan-y;
|
||||
}
|
||||
/* 图片查看器手势区域 */
|
||||
.react-native-image-viewer,
|
||||
[data-image-viewer="true"] {
|
||||
touch-action: pinch-zoom pan-x pan-y;
|
||||
}
|
||||
`;
|
||||
document.head.appendChild(style);
|
||||
}
|
||||
|
||||
function SystemChrome() {
|
||||
const colors = useAppColors();
|
||||
useEffect(() => {
|
||||
void SystemUI.setBackgroundColorAsync(colors.background.default);
|
||||
if (Platform.OS === 'web' && typeof document !== 'undefined') {
|
||||
const meta = document.querySelector('meta[name="theme-color"]');
|
||||
if (meta) {
|
||||
meta.setAttribute('content', colors.background.default);
|
||||
}
|
||||
}
|
||||
}, [colors.background.default]);
|
||||
return null;
|
||||
}
|
||||
|
||||
function NotificationBootstrap() {
|
||||
const appState = useRef<AppStateStatus>(AppState.currentState);
|
||||
const permissionRequested = useRef(false);
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
const initNotifications = async () => {
|
||||
const { loadNotificationPreferences } = await import('@/services/notification');
|
||||
await loadNotificationPreferences();
|
||||
const { systemNotificationService } = await import('@/services/notification');
|
||||
await systemNotificationService.initialize();
|
||||
const { initBackgroundService } = await import('@/services/background');
|
||||
// 默认静默模式,不会注册后台任务,不会触发自启动
|
||||
await initBackgroundService();
|
||||
|
||||
const subscription = AppState.addEventListener('change', (nextAppState) => {
|
||||
if (appState.current.match(/inactive|background/) && nextAppState === 'active') {
|
||||
systemNotificationService.clearBadge();
|
||||
}
|
||||
appState.current = nextAppState;
|
||||
});
|
||||
|
||||
// Listen for JPush notification taps — navigate to the relevant screen
|
||||
jpushService.onNotification((message) => {
|
||||
console.log('[NotificationBootstrap] JPush notification:', message.notificationEventType, message);
|
||||
if (message.notificationEventType !== 'notificationOpened') return;
|
||||
|
||||
const extras = message.extras || {};
|
||||
const notifType = extras.notification_type || message.notificationType;
|
||||
|
||||
if (notifType === 'chat_message' || notifType === 'chat') {
|
||||
const conversationId = extras.conversation_id || extras.conversationId;
|
||||
const senderId = extras.sender_id || extras.senderId;
|
||||
const conversationType = extras.conversation_type || extras.conversationType;
|
||||
const groupId = extras.group_id || extras.groupId;
|
||||
const groupName = extras.group_name || extras.groupName;
|
||||
const groupAvatar = extras.group_avatar || extras.groupAvatar;
|
||||
|
||||
if (conversationId) {
|
||||
const isGroup = conversationType === 'group';
|
||||
router.push(
|
||||
hrefs.hrefChat({
|
||||
conversationId,
|
||||
userId: isGroup ? undefined : senderId,
|
||||
isGroupChat: isGroup,
|
||||
groupId: isGroup ? groupId : undefined,
|
||||
groupName: isGroup ? groupName : undefined,
|
||||
groupAvatar: isGroup ? groupAvatar : undefined,
|
||||
})
|
||||
);
|
||||
} else {
|
||||
router.push(hrefs.hrefNotifications());
|
||||
}
|
||||
} else {
|
||||
router.push(hrefs.hrefNotifications());
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
subscription.remove();
|
||||
};
|
||||
};
|
||||
|
||||
const requestPermissionOnLaunch = async () => {
|
||||
if (permissionRequested.current) return;
|
||||
permissionRequested.current = true;
|
||||
|
||||
if (Platform.OS === 'web') return;
|
||||
|
||||
try {
|
||||
const hasPermission = await jpushService.checkNotificationPermission();
|
||||
if (!hasPermission) {
|
||||
await jpushService.requestNotificationPermission();
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('[NotificationBootstrap] request permission on launch failed:', error);
|
||||
}
|
||||
};
|
||||
|
||||
initNotifications();
|
||||
requestPermissionOnLaunch();
|
||||
}, []);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function APKUpdateBootstrap() {
|
||||
const hasChecked = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (hasChecked.current) return;
|
||||
hasChecked.current = true;
|
||||
|
||||
// 延迟执行,避免与启动流程冲突
|
||||
const timer = setTimeout(() => {
|
||||
checkForAPKUpdate().catch((error) => {
|
||||
console.error('APK update check failed:', error);
|
||||
});
|
||||
}, 3000);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}, []);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function CallKeepBootstrap() {
|
||||
useEffect(() => {
|
||||
if (Platform.OS === 'web') return;
|
||||
|
||||
callKeepService.initialize({
|
||||
onSystemAnswered: () => {
|
||||
callStore.getState().handleSystemAnswer();
|
||||
},
|
||||
onSystemEnded: () => {
|
||||
callStore.getState().endCall('ended');
|
||||
},
|
||||
onSystemMuted: (muted: boolean) => {
|
||||
callStore.getState().handleSystemMuted(muted);
|
||||
},
|
||||
onIncomingCallFromPush: (session) => {
|
||||
callStore.getState().handleIncomingFromPush(session);
|
||||
},
|
||||
onOutgoingStarted: () => {
|
||||
// Outgoing call accepted by system — no action needed,
|
||||
// the WS call_invited / call_accepted flow handles everything
|
||||
},
|
||||
});
|
||||
|
||||
return () => {
|
||||
callKeepService.dispose();
|
||||
};
|
||||
}, []);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function ThemedStack() {
|
||||
const router = useRouter();
|
||||
const resolved = useResolvedColorScheme();
|
||||
|
||||
return (
|
||||
<>
|
||||
<StatusBar style={resolved === 'dark' ? 'light' : 'dark'} />
|
||||
<SystemChrome />
|
||||
<EventSubscriber />
|
||||
<NotificationBootstrap />
|
||||
<APKUpdateBootstrap />
|
||||
<CallKeepBootstrap />
|
||||
<Stack screenOptions={{ headerShown: false }}>
|
||||
<Stack.Screen name="index" options={{ headerShown: false }} />
|
||||
<Stack.Screen name="(auth)" options={{ headerShown: false }} />
|
||||
<Stack.Screen name="(app)" options={{ headerShown: false }} />
|
||||
<Stack.Screen name="privacy" options={{ headerShown: false }} />
|
||||
<Stack.Screen name="terms" options={{ headerShown: false }} />
|
||||
</Stack>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function ThemedProviders({ children }: { children: React.ReactNode }) {
|
||||
const theme = usePaperThemeFromStore();
|
||||
return <PaperProvider theme={theme}>{children}</PaperProvider>;
|
||||
}
|
||||
|
||||
export default function RootLayout() {
|
||||
const [fontsLoaded] = useFonts({});
|
||||
|
||||
if (!fontsLoaded) {
|
||||
return (
|
||||
<View style={{ flex: 1, justifyContent: 'center', alignItems: 'center' }}>
|
||||
<ActivityIndicator />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<GestureHandlerRootView style={{ flex: 1 }}>
|
||||
<SafeAreaProvider>
|
||||
<ThemedProviders>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ThemeBootstrap />
|
||||
<ThemedStack />
|
||||
<AppPromptBar />
|
||||
<AppDialogHost />
|
||||
<IncomingCallModal />
|
||||
<CallScreen />
|
||||
<FloatingCallWindow />
|
||||
</QueryClientProvider>
|
||||
</ThemedProviders>
|
||||
</SafeAreaProvider>
|
||||
</GestureHandlerRootView>
|
||||
);
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
import { Redirect } from 'expo-router';
|
||||
|
||||
import { useAuthStore } from '../src/stores';
|
||||
import { hrefAuthWelcome, hrefHome } from '../src/navigation/hrefs';
|
||||
|
||||
export default function Index() {
|
||||
const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
|
||||
if (isAuthenticated) {
|
||||
return <Redirect href={hrefHome()} />;
|
||||
}
|
||||
return <Redirect href={hrefAuthWelcome()} />;
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
import { PostDetailScreen } from '../../src/screens/home';
|
||||
|
||||
export default function PostDetailRoute() {
|
||||
return <PostDetailScreen />;
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
import { PrivacyPolicyScreen } from '../src/screens/profile/PrivacyPolicyScreen';
|
||||
|
||||
export default function PrivacyRoute() {
|
||||
return <PrivacyPolicyScreen />;
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
import { TermsOfServiceScreen } from '../src/screens/profile/TermsOfServiceScreen';
|
||||
|
||||
export default function TermsRoute() {
|
||||
return <TermsOfServiceScreen />;
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
import { TradeDetailScreen } from '../../src/screens/trade/TradeDetailScreen';
|
||||
import { useLocalSearchParams } from 'expo-router';
|
||||
|
||||
export default function TradeDetailRoute() {
|
||||
const { tradeId } = useLocalSearchParams<{ tradeId: string }>();
|
||||
return <TradeDetailScreen tradeId={tradeId} />;
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useLocalSearchParams, useRouter } from 'expo-router';
|
||||
import { CreateTradeScreen } from '../../../src/screens/trade/CreateTradeScreen';
|
||||
import { tradeService } from '../../../src/services/trade/tradeService';
|
||||
import { Loading } from '../../../src/components/common';
|
||||
import { View, Alert } from 'react-native';
|
||||
import type { TradeItemDetailDTO } from '../../../src/types/trade';
|
||||
|
||||
export default function EditTradeRoute() {
|
||||
const { tradeId } = useLocalSearchParams<{ tradeId: string }>();
|
||||
const router = useRouter();
|
||||
const [item, setItem] = useState<TradeItemDetailDTO | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
if (!tradeId) return;
|
||||
tradeService.getTradeItem(tradeId)
|
||||
.then(data => setItem(data))
|
||||
.catch(() => Alert.alert('错误', '加载商品信息失败'))
|
||||
.finally(() => setLoading(false));
|
||||
}, [tradeId]);
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
router.back();
|
||||
}, [router]);
|
||||
|
||||
const handleSuccess = useCallback(() => {
|
||||
router.back();
|
||||
}, [router]);
|
||||
|
||||
if (loading) {
|
||||
return <Loading fullScreen />;
|
||||
}
|
||||
|
||||
if (!item) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<CreateTradeScreen
|
||||
onClose={handleClose}
|
||||
onSuccess={handleSuccess}
|
||||
editItem={item}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
import { UserScreen } from '../../src/screens/profile';
|
||||
|
||||
export default function UserProfileRoute() {
|
||||
return <UserScreen />;
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
import { WelcomeScreen } from '../src/screens/auth';
|
||||
|
||||
export default function WelcomeRoute() {
|
||||
return <WelcomeScreen />;
|
||||
}
|
||||
|
Before Width: | Height: | Size: 7.0 KiB After Width: | Height: | Size: 7.0 KiB |
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 29 KiB |
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 66 KiB |
|
Before Width: | Height: | Size: 4.7 KiB After Width: | Height: | Size: 4.5 KiB |
BIN
assets/icon.png
|
Before Width: | Height: | Size: 385 KiB After Width: | Height: | Size: 895 KiB |
|
Before Width: | Height: | Size: 385 KiB After Width: | Height: | Size: 895 KiB |
1725
docs/OPTIMIZATION_DESIGN.md
Normal file
3
eas.json
@@ -27,9 +27,6 @@
|
||||
},
|
||||
"production": {
|
||||
"autoIncrement": true,
|
||||
"android": {
|
||||
"buildType": "apk"
|
||||
},
|
||||
"ios": {
|
||||
"resourceClass": "m-medium"
|
||||
}
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
{
|
||||
"project_info": {
|
||||
"project_number": "000000000000",
|
||||
"project_id": "withyou-dummy",
|
||||
"storage_bucket": "withyou-dummy.appspot.com"
|
||||
},
|
||||
"client": [
|
||||
{
|
||||
"client_info": {
|
||||
"mobilesdk_app_id": "1:000000000000:android:0000000000000000",
|
||||
"android_client_info": {
|
||||
"package_name": "cn.qczlit.withyou"
|
||||
}
|
||||
},
|
||||
"api_key": [
|
||||
{
|
||||
"current_key": "AIzaSyDummyKeyForFirebaseInitOnly"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"configuration_version": "1"
|
||||
}
|
||||
9
index.ts
@@ -1 +1,8 @@
|
||||
import 'expo-router/entry';
|
||||
import { registerRootComponent } from 'expo';
|
||||
|
||||
import App from './App';
|
||||
|
||||
// registerRootComponent calls AppRegistry.registerComponent('main', () => App);
|
||||
// It also ensures that whether you load the app in Expo Go or in a native build,
|
||||
// the environment is set up appropriately
|
||||
registerRootComponent(App);
|
||||
|
||||
@@ -1,72 +1,11 @@
|
||||
/** @type {import('expo/metro-config').MetroConfig} */
|
||||
const { getDefaultConfig } = require('expo/metro-config');
|
||||
const path = require('path');
|
||||
|
||||
const config = getDefaultConfig(__dirname);
|
||||
|
||||
// Add wasm asset support
|
||||
config.resolver.assetExts.push('wasm');
|
||||
|
||||
// Support TypeScript path aliases (@/ -> ./src/)
|
||||
config.resolver.alias = {
|
||||
'@': path.resolve(__dirname, 'src'),
|
||||
};
|
||||
|
||||
// Ensure platform-specific files are resolved correctly
|
||||
config.resolver.platforms = ['ios', 'android', 'native', 'web'];
|
||||
|
||||
// Web shims for native-only React Native APIs that don't exist in react-native-web.
|
||||
// Packages like react-native-pager-view import codegenNativeComponent from
|
||||
// react-native/Libraries/Utilities/codegenNativeComponent, which calls
|
||||
// requireNativeComponent internally. On web (react-native-web), these APIs
|
||||
// don't exist, causing "(0 , _reactNativeWebDistIndex.requireNativeComponent)
|
||||
// is not a function".
|
||||
const webShimPaths = [
|
||||
'react-native/Libraries/Utilities/codegenNativeComponent',
|
||||
'react-native/Libraries/Utilities/codegenNativeCommands',
|
||||
'react-native/Libraries/Types/CodegenTypes',
|
||||
'react-native/Libraries/ReactNative/requireNativeComponent',
|
||||
];
|
||||
|
||||
// Packages that should be entirely replaced with web shims on web platform.
|
||||
// These packages import requireNativeComponent directly from 'react-native'
|
||||
// (which resolves to react-native-web on web) and don't have web implementations.
|
||||
const webPackageShims = {
|
||||
};
|
||||
|
||||
const originalResolveRequest = config.resolver.resolveRequest;
|
||||
const shimDir = path.resolve(__dirname, 'web-shims');
|
||||
|
||||
config.resolver.resolveRequest = (context, moduleName, platform) => {
|
||||
// Redirect entire native-only packages to web shims
|
||||
if (platform === 'web' && webPackageShims[moduleName]) {
|
||||
return {
|
||||
type: 'sourceFile',
|
||||
filePath: webPackageShims[moduleName],
|
||||
};
|
||||
}
|
||||
|
||||
// Redirect specific React Native internal modules to web shims
|
||||
if (platform === 'web' && webShimPaths.some((p) => moduleName.endsWith(p))) {
|
||||
// For requireNativeComponent, the path doesn't follow the Libraries/* pattern
|
||||
if (moduleName.endsWith('react-native/Libraries/ReactNative/requireNativeComponent')) {
|
||||
return {
|
||||
type: 'sourceFile',
|
||||
filePath: path.join(shimDir, 'react-native', 'Libraries', 'ReactNative', 'requireNativeComponent.js'),
|
||||
};
|
||||
}
|
||||
const shimName = moduleName.replace('react-native/Libraries/', '');
|
||||
return {
|
||||
type: 'sourceFile',
|
||||
filePath: path.join(shimDir, 'react-native', 'Libraries', shimName + '.js'),
|
||||
};
|
||||
}
|
||||
if (originalResolveRequest) {
|
||||
return originalResolveRequest(context, moduleName, platform);
|
||||
}
|
||||
return context.resolveRequest(context, moduleName, platform);
|
||||
};
|
||||
|
||||
// NOTE: enhanceMiddleware is marked as deprecated in Metro's types,
|
||||
// but there's currently no official alternative for custom dev server headers.
|
||||
// For production, configure COEP/COOP headers on your hosting platform.
|
||||
|
||||
19
nginx.conf
@@ -1,19 +0,0 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable";
|
||||
}
|
||||
|
||||
gzip on;
|
||||
gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript image/svg+xml;
|
||||
gzip_min_length 256;
|
||||
}
|
||||
8513
package-lock.json
generated
117
package.json
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "with_you",
|
||||
"version": "1.0.1",
|
||||
"main": "expo-router/entry",
|
||||
"name": "carrot_bbs",
|
||||
"version": "1.0.0",
|
||||
"main": "index.ts",
|
||||
"scripts": {
|
||||
"start": "expo start",
|
||||
"android": "expo run:android",
|
||||
@@ -14,78 +14,55 @@
|
||||
"ios:submit": "eas submit --platform ios --profile production"
|
||||
},
|
||||
"dependencies": {
|
||||
"@expo/ui": "~56.0.17",
|
||||
"@expo/vector-icons": "^15.1.1",
|
||||
"@livekit/react-native": "^2.11.0",
|
||||
"@livekit/react-native-webrtc": "^144.1.0",
|
||||
"@react-native-async-storage/async-storage": "^3.1.1",
|
||||
"@shopify/flash-list": "^2.3.1",
|
||||
"@tanstack/react-query": "^5.100.14",
|
||||
"@types/react": "~19.2.14",
|
||||
"axios": "^1.16.1",
|
||||
"date-fns": "^4.4.0",
|
||||
"expo": "~56.0.11",
|
||||
"expo-background-task": "~56.0.18",
|
||||
"expo-callkit-telecom": "^0.3.9",
|
||||
"expo-camera": "~56.0.8",
|
||||
"expo-constants": "~56.0.18",
|
||||
"expo-dev-client": "~56.0.20",
|
||||
"expo-document-picker": "~56.0.4",
|
||||
"expo-file-system": "~56.0.8",
|
||||
"expo-font": "~56.0.6",
|
||||
"expo-haptics": "~56.0.3",
|
||||
"expo-image": "~56.0.6",
|
||||
"expo-image-picker": "~56.0.17",
|
||||
"expo-intent-launcher": "~56.0.4",
|
||||
"expo-linear-gradient": "~56.0.4",
|
||||
"expo-linking": "~56.0.14",
|
||||
"expo-media-library": "~56.0.7",
|
||||
"expo-notifications": "~56.0.17",
|
||||
"expo-router": "~56.2.10",
|
||||
"expo-splash-screen": "~56.0.10",
|
||||
"expo-sqlite": "~56.0.5",
|
||||
"expo-status-bar": "~56.0.4",
|
||||
"expo-system-ui": "~56.0.5",
|
||||
"expo-task-manager": "~56.0.18",
|
||||
"expo-updates": "~56.0.19",
|
||||
"expo-video": "~56.1.3",
|
||||
"jcore-react-native": "^2.3.6",
|
||||
"jpush-react-native": "^3.2.7",
|
||||
"katex": "^0.17.0",
|
||||
"livekit-client": "^2.19.2",
|
||||
"markdown-it": "^14.2.0",
|
||||
"pako": "^2.1.0",
|
||||
"prismjs": "^1.30.0",
|
||||
"react": "19.2.3",
|
||||
"react-dom": "19.2.3",
|
||||
"react-native": "0.85.3",
|
||||
"react-native-gesture-handler": "^3.0.0",
|
||||
"react-native-pager-view": "^8.0.2",
|
||||
"react-native-paper": "^5.15.3",
|
||||
"react-native-reanimated": "~4.3.1",
|
||||
"react-native-safe-area-context": "~5.8.0",
|
||||
"react-native-screens": "4.25.2",
|
||||
"react-native-share": "^12.3.1",
|
||||
"@react-native-async-storage/async-storage": "^2.2.0",
|
||||
"@react-navigation/bottom-tabs": "^7.15.2",
|
||||
"@react-navigation/material-top-tabs": "^7.4.16",
|
||||
"@react-navigation/native": "^7.1.31",
|
||||
"@react-navigation/native-stack": "^7.14.2",
|
||||
"@shopify/flash-list": "2.0.2",
|
||||
"@tanstack/react-query": "^5.90.21",
|
||||
"axios": "^1.13.6",
|
||||
"date-fns": "^4.1.0",
|
||||
"expo": "~55.0.4",
|
||||
"expo-background-fetch": "~55.0.9",
|
||||
"expo-constants": "~55.0.7",
|
||||
"expo-dev-client": "~55.0.10",
|
||||
"expo-file-system": "~55.0.10",
|
||||
"expo-font": "~55.0.4",
|
||||
"expo-haptics": "~55.0.8",
|
||||
"expo-image": "^55.0.5",
|
||||
"expo-image-picker": "^55.0.10",
|
||||
"expo-linear-gradient": "^55.0.8",
|
||||
"expo-media-library": "~55.0.9",
|
||||
"expo-notifications": "^55.0.10",
|
||||
"expo-router": "^55.0.4",
|
||||
"expo-sqlite": "~55.0.10",
|
||||
"expo-status-bar": "~55.0.4",
|
||||
"expo-system-ui": "^55.0.9",
|
||||
"expo-task-manager": "~55.0.9",
|
||||
"expo-updates": "^55.0.12",
|
||||
"expo-video": "^55.0.10",
|
||||
"react": "19.2.0",
|
||||
"react-dom": "19.2.0",
|
||||
"react-native": "0.83.2",
|
||||
"react-native-gesture-handler": "^2.30.0",
|
||||
"react-native-pager-view": "^8.0.0",
|
||||
"react-native-paper": "^5.15.0",
|
||||
"react-native-reanimated": "^4.2.1",
|
||||
"react-native-safe-area-context": "~5.6.2",
|
||||
"react-native-screens": "~4.23.0",
|
||||
"react-native-sse": "^1.2.1",
|
||||
"react-native-web": "^0.21.0",
|
||||
"react-native-webview": "13.16.1",
|
||||
"react-native-worklets": "^0.8.3",
|
||||
"zod": "^4.4.3",
|
||||
"zustand": "^5.0.14"
|
||||
"react-native-worklets": "0.7.2",
|
||||
"zod": "^4.3.6",
|
||||
"zustand": "^5.0.11"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@react-native-community/cli": "^20.1.3",
|
||||
"@types/pako": "^2.0.4",
|
||||
"@types/react": "~19.2.16",
|
||||
"@react-native-community/cli": "^20.1.2",
|
||||
"@types/react": "~19.2.2",
|
||||
"@types/react-native-vector-icons": "^6.4.18",
|
||||
"typescript": "~6.0.3"
|
||||
"typescript": "~5.9.2"
|
||||
},
|
||||
"private": true,
|
||||
"expo": {
|
||||
"install": {
|
||||
"exclude": [
|
||||
"expo-image"
|
||||
]
|
||||
}
|
||||
}
|
||||
"private": true
|
||||
}
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
plugins {
|
||||
kotlin("jvm") version "2.1.20" apply false
|
||||
id("java-gradle-plugin")
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
[versions]
|
||||
agp = "8.12.0"
|
||||
gson = "2.8.9"
|
||||
guava = "31.0.1-jre"
|
||||
javapoet = "1.13.0"
|
||||
junit = "4.13.2"
|
||||
kotlin = "2.1.20"
|
||||
assertj = "3.25.1"
|
||||
ktfmt = "0.22.0"
|
||||
|
||||
[libraries]
|
||||
kotlin-gradle-plugin = { module = "org.jetbrains.kotlin:kotlin-gradle-plugin", version.ref = "kotlin" }
|
||||
android-gradle-plugin = { module = "com.android.tools.build:gradle", version.ref = "agp" }
|
||||
gson = { module = "com.google.code.gson:gson", version.ref = "gson" }
|
||||
guava = { module = "com.google.guava:guava", version.ref = "guava" }
|
||||
javapoet = { module = "com.squareup:javapoet", version.ref = "javapoet" }
|
||||
junit = {module = "junit:junit", version.ref = "junit" }
|
||||
assertj = { module = "org.assertj:assertj-core", version.ref = "assertj" }
|
||||
|
||||
[plugins]
|
||||
kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" }
|
||||
ktfmt = { id = "com.ncorti.ktfmt.gradle", version.ref = "ktfmt" }
|
||||
@@ -1,7 +0,0 @@
|
||||
distributionBase=GRADLE_USER_HOME
|
||||
distributionPath=wrapper/dists
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-9.3.1-bin.zip
|
||||
networkTimeout=10000
|
||||
validateDistributionUrl=true
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
zipStorePath=wrapper/dists
|
||||
@@ -1,83 +0,0 @@
|
||||
/*
|
||||
* Copyright (c) Meta Platforms, Inc. and affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*/
|
||||
|
||||
import org.gradle.api.tasks.testing.logging.TestExceptionFormat
|
||||
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
|
||||
import org.jetbrains.kotlin.gradle.dsl.KotlinVersion
|
||||
import org.jetbrains.kotlin.gradle.tasks.KotlinCompile
|
||||
|
||||
plugins {
|
||||
alias(libs.plugins.kotlin.jvm)
|
||||
alias(libs.plugins.ktfmt)
|
||||
id("java-gradle-plugin")
|
||||
}
|
||||
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
}
|
||||
|
||||
gradlePlugin {
|
||||
plugins {
|
||||
create("react") {
|
||||
id = "com.facebook.react"
|
||||
implementationClass = "com.facebook.react.ReactPlugin"
|
||||
}
|
||||
create("reactrootproject") {
|
||||
id = "com.facebook.react.rootproject"
|
||||
implementationClass = "com.facebook.react.ReactRootProjectPlugin"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
group = "com.facebook.react"
|
||||
|
||||
dependencies {
|
||||
implementation(project(":shared"))
|
||||
|
||||
implementation(gradleApi())
|
||||
|
||||
// The KGP/AGP version is defined by React Native Gradle plugin.
|
||||
// Therefore we specify an implementation dep rather than a compileOnly.
|
||||
implementation(libs.kotlin.gradle.plugin)
|
||||
implementation(libs.android.gradle.plugin)
|
||||
|
||||
implementation(libs.gson)
|
||||
implementation(libs.guava)
|
||||
implementation(libs.javapoet)
|
||||
|
||||
testImplementation(libs.junit)
|
||||
testImplementation(libs.assertj)
|
||||
testImplementation(project(":shared-testutil"))
|
||||
}
|
||||
|
||||
// We intentionally don't build for Java 17 as users will see a cryptic bytecode version
|
||||
// error first. Instead we produce a Java 11-compatible Gradle Plugin, so that AGP can print their
|
||||
// nice message showing that JDK 11 (or 17) is required first
|
||||
java { targetCompatibility = JavaVersion.VERSION_11 }
|
||||
|
||||
kotlin { jvmToolchain(17) }
|
||||
|
||||
tasks.withType<KotlinCompile>().configureEach {
|
||||
compilerOptions {
|
||||
apiVersion.set(KotlinVersion.KOTLIN_1_8)
|
||||
// See comment above on JDK 11 support
|
||||
jvmTarget.set(JvmTarget.JVM_11)
|
||||
allWarningsAsErrors.set(
|
||||
project.properties["enableWarningsAsErrors"]?.toString()?.toBoolean() ?: false
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
tasks.withType<Test>().configureEach {
|
||||
testLogging {
|
||||
exceptionFormat = TestExceptionFormat.FULL
|
||||
showExceptions = true
|
||||
showCauses = true
|
||||
showStackTraces = true
|
||||
}
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
{"agcgw":{"url":"connect-drcn.dbankcloud.cn","backurl":"connect-drcn.hispace.hicloud.com","websocketurl":"connect-ws-drcn.hispace.dbankcloud.cn","websocketbackurl":"connect-ws-drcn.hispace.dbankcloud.com"},"agcgw_all":{"SG":"connect-dra.dbankcloud.cn","SG_back":"connect-dra.hispace.hicloud.com","CN":"connect-drcn.dbankcloud.cn","CN_back":"connect-drcn.hispace.hicloud.com","RU":"connect-drru.hispace.dbankcloud.ru","RU_back":"connect-drru.hispace.dbankcloud.cn","DE":"connect-dre.dbankcloud.cn","DE_back":"connect-dre.hispace.hicloud.com"},"websocketgw_all":{"SG":"connect-ws-dra.hispace.dbankcloud.cn","SG_back":"connect-ws-dra.hispace.dbankcloud.com","CN":"connect-ws-drcn.hispace.dbankcloud.cn","CN_back":"connect-ws-drcn.hispace.dbankcloud.com","RU":"connect-ws-drru.hispace.dbankcloud.ru","RU_back":"connect-ws-drru.hispace.dbankcloud.cn","DE":"connect-ws-dre.hispace.dbankcloud.cn","DE_back":"connect-ws-dre.hispace.dbankcloud.com"},"client":{"cp_id":"30086000618737115","product_id":"101653523864112478","client_id":"1949443370896080512","client_secret":"CA50EF8C69075FAEFECF2D9F0F3ED101DC3264E0BFE1CC5E5B0272E7FF8215A1","project_id":"101653523864112478","app_id":"117704941","api_key":"DgEDAIkxmMyXBT6vYYW8ZWWXBMJweDbdmmKLYo5MvgH2lV8jPSIZt5WVpjd0/nZ0dk9gAwHf+dWIySILr3QQd7W80H8NNvuCUAQPig==","package_name":"cn.qczlit.withyou"},"oauth_client":{"client_id":"117704941","client_type":1},"app_info":{"app_id":"117704941","package_name":"cn.qczlit.withyou"},"service":{"analytics":{"collector_url":"datacollector-drcn.dt.hicloud.com,datacollector-drcn.dt.dbankcloud.cn","collector_url_cn":"datacollector-drcn.dt.hicloud.com,datacollector-drcn.dt.dbankcloud.cn","collector_url_de":"datacollector-dre.dt.hicloud.com,datacollector-dre.dt.dbankcloud.cn","collector_url_ru":"datacollector-drru.dt.dbankcloud.ru,datacollector-drru.dt.hicloud.com","collector_url_sg":"datacollector-dra.dt.hicloud.com,datacollector-dra.dt.dbankcloud.cn","resource_id":"p1","channel_id":""},"ml":{"mlservice_url":"ml-api-drcn.ai.dbankcloud.com,ml-api-drcn.ai.dbankcloud.cn"},"cloudstorage":{"storage_url":"https://agc-storage-drcn.platform.dbankcloud.cn","storage_url_ru":"https://agc-storage-drru.cloud.huawei.ru","storage_url_sg":"https://ops-dra.agcstorage.link","storage_url_de":"https://ops-dre.agcstorage.link","storage_url_cn":"https://agc-storage-drcn.platform.dbankcloud.cn","storage_url_ru_back":"https://agc-storage-drru.cloud.huawei.ru","storage_url_sg_back":"https://agc-storage-dra.cloud.huawei.asia","storage_url_de_back":"https://agc-storage-dre.cloud.huawei.eu","storage_url_cn_back":"https://agc-storage-drcn.cloud.huawei.com.cn"},"search":{"url":"https://search-drcn.cloud.huawei.com"},"edukit":{"edu_url":"edukit.cloud.huawei.com.cn","dh_url":"edukit.cloud.huawei.com.cn"}},"region":"CN","configuration_version":"3.0","appInfos":[{"package_name":"cn.qczlit.withyou","client":{"app_id":"117704941"},"oauth_client":{"client_id":"117704941","client_type":1},"app_info":{"app_id":"117704941","package_name":"cn.qczlit.withyou"}}]}
|
||||
@@ -1,40 +0,0 @@
|
||||
const { withDangerousMod } = require('@expo/config-plugins');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const MAX_JOBS = '3';
|
||||
|
||||
// React Native's hermes-engine build hardcodes
|
||||
// Runtime.getRuntime().availableProcessors()
|
||||
// for `cmake --build -j`, ignoring the CMAKE_BUILD_PARALLEL_LEVEL env var.
|
||||
// On high-core CI runners this exhausts memory.
|
||||
// This plugin caps it during prebuild.
|
||||
const withCmakeJobLimit = (config) =>
|
||||
withDangerousMod(config, [
|
||||
'android',
|
||||
async (config) => {
|
||||
const root = config.modRequest.projectRoot;
|
||||
|
||||
const hermesPath = path.join(
|
||||
root,
|
||||
'node_modules',
|
||||
'react-native',
|
||||
'ReactAndroid',
|
||||
'hermes-engine',
|
||||
'build.gradle.kts',
|
||||
);
|
||||
if (fs.existsSync(hermesPath)) {
|
||||
let content = fs.readFileSync(hermesPath, 'utf-8');
|
||||
content = content.replace(
|
||||
/Runtime\.getRuntime\(\)\.availableProcessors\(\)\.toString\(\)/,
|
||||
`"${MAX_JOBS}"`,
|
||||
);
|
||||
fs.writeFileSync(hermesPath, content);
|
||||
console.log(`[withCmakeJobLimit] capped hermes-engine jobs to ${MAX_JOBS}`);
|
||||
}
|
||||
|
||||
return config;
|
||||
},
|
||||
]);
|
||||
|
||||
module.exports = withCmakeJobLimit;
|
||||
@@ -1,467 +0,0 @@
|
||||
const { withAndroidManifest, withDangerousMod } = require('@expo/config-plugins');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const FOREGROUND_SERVICE_PERMISSION = 'android.permission.FOREGROUND_SERVICE';
|
||||
const FOREGROUND_SERVICE_DATA_SYNC = 'android.permission.FOREGROUND_SERVICE_DATA_SYNC';
|
||||
|
||||
/**
|
||||
* Expo Config Plugin:为 Android 添加前台服务保活
|
||||
*
|
||||
* 功能:
|
||||
* 1. 添加 FOREGROUND_SERVICE 相关权限
|
||||
* 2. 在 AndroidManifest 中声明 Service
|
||||
* 3. 生成 Kotlin 原生服务代码
|
||||
* 4. 生成 React Native Bridge 模块
|
||||
*
|
||||
* 参考:Element Android 的 GuardAndroidService
|
||||
*/
|
||||
const withForegroundService = (config, options = {}) => {
|
||||
const {
|
||||
notificationTitle = '威友',
|
||||
notificationBody = '正在后台同步消息',
|
||||
notificationIcon = 'ic_notification',
|
||||
channelId = 'withyou_sync_foreground',
|
||||
channelName = '消息同步',
|
||||
} = options;
|
||||
|
||||
// 1. 添加权限和 Service 声明到 AndroidManifest
|
||||
config = withAndroidManifest(config, (config) => {
|
||||
const manifest = config.modResults;
|
||||
|
||||
// 添加权限声明
|
||||
const permissions = [
|
||||
FOREGROUND_SERVICE_PERMISSION,
|
||||
FOREGROUND_SERVICE_DATA_SYNC,
|
||||
];
|
||||
|
||||
permissions.forEach((permission) => {
|
||||
const existingPermission = manifest.manifest['uses-permission']?.find(
|
||||
(p) => p.$['android:name'] === permission
|
||||
);
|
||||
if (!existingPermission) {
|
||||
if (!manifest.manifest['uses-permission']) {
|
||||
manifest.manifest['uses-permission'] = [];
|
||||
}
|
||||
manifest.manifest['uses-permission'].push({
|
||||
$: { 'android:name': permission },
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// 添加 Service 声明
|
||||
if (!manifest.manifest.application[0].service) {
|
||||
manifest.manifest.application[0].service = [];
|
||||
}
|
||||
|
||||
const existingService = manifest.manifest.application[0].service.find(
|
||||
(s) => s.$['android:name'] === '.SyncForegroundService'
|
||||
);
|
||||
|
||||
if (!existingService) {
|
||||
manifest.manifest.application[0].service.push({
|
||||
$: {
|
||||
'android:name': '.SyncForegroundService',
|
||||
'android:enabled': 'true',
|
||||
'android:exported': 'false',
|
||||
'android:foregroundServiceType': 'dataSync',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return config;
|
||||
});
|
||||
|
||||
// 2. 生成 Kotlin 原生代码
|
||||
config = withDangerousMod(config, [
|
||||
'android',
|
||||
async (config) => {
|
||||
const projectRoot = config.modRequest.projectRoot;
|
||||
const packageName = config.android.package;
|
||||
const kotlinPath = path.join(
|
||||
projectRoot,
|
||||
'android/app/src/main/java',
|
||||
packageName.replace(/\./g, '/')
|
||||
);
|
||||
|
||||
// 确保目录存在
|
||||
if (!fs.existsSync(kotlinPath)) {
|
||||
fs.mkdirSync(kotlinPath, { recursive: true });
|
||||
}
|
||||
|
||||
// 生成 SyncForegroundService.kt
|
||||
const serviceCode = generateServiceCode(packageName, {
|
||||
notificationTitle,
|
||||
notificationBody,
|
||||
notificationIcon,
|
||||
channelId,
|
||||
channelName,
|
||||
});
|
||||
|
||||
fs.writeFileSync(
|
||||
path.join(kotlinPath, 'SyncForegroundService.kt'),
|
||||
serviceCode
|
||||
);
|
||||
|
||||
// 生成 ForegroundServiceModule.kt (React Native Bridge)
|
||||
const moduleCode = generateModuleCode(packageName);
|
||||
fs.writeFileSync(
|
||||
path.join(kotlinPath, 'ForegroundServiceModule.kt'),
|
||||
moduleCode
|
||||
);
|
||||
|
||||
// 生成 ForegroundServicePackage.kt
|
||||
const packageCode = generatePackageCode(packageName);
|
||||
fs.writeFileSync(
|
||||
path.join(kotlinPath, 'ForegroundServicePackage.kt'),
|
||||
packageCode
|
||||
);
|
||||
|
||||
// 修改 MainApplication.kt 注册模块
|
||||
const mainAppPath = path.join(kotlinPath, 'MainApplication.kt');
|
||||
if (fs.existsSync(mainAppPath)) {
|
||||
let content = fs.readFileSync(mainAppPath, 'utf-8');
|
||||
|
||||
// 添加 import
|
||||
if (!content.includes('ForegroundServicePackage')) {
|
||||
content = content.replace(
|
||||
/package .*\n/,
|
||||
`$&\nimport ${packageName}.ForegroundServicePackage\n`
|
||||
);
|
||||
}
|
||||
|
||||
// 在 packages 列表中添加
|
||||
if (!content.includes('ForegroundServicePackage()')) {
|
||||
// 查找 PackageList(this).packages.apply 块
|
||||
content = content.replace(
|
||||
/(PackageList\(this\)\.packages\.apply\s*\{)/,
|
||||
`$1\n // 前台服务模块\n add(ForegroundServicePackage())`
|
||||
);
|
||||
}
|
||||
|
||||
fs.writeFileSync(mainAppPath, content);
|
||||
}
|
||||
|
||||
// 创建通知图标目录和文件
|
||||
const drawablePath = path.join(projectRoot, 'android/app/src/main/res/drawable');
|
||||
if (!fs.existsSync(drawablePath)) {
|
||||
fs.mkdirSync(drawablePath, { recursive: true });
|
||||
}
|
||||
|
||||
const iconPath = path.join(drawablePath, 'ic_notification.xml');
|
||||
if (!fs.existsSync(iconPath)) {
|
||||
fs.writeFileSync(iconPath, generateNotificationIcon());
|
||||
}
|
||||
|
||||
return config;
|
||||
},
|
||||
]);
|
||||
|
||||
return config;
|
||||
};
|
||||
|
||||
/**
|
||||
* 生成前台服务 Kotlin 代码
|
||||
*/
|
||||
function generateServiceCode(packageName, options) {
|
||||
const { notificationTitle, notificationBody, notificationIcon, channelId, channelName } = options;
|
||||
|
||||
return `package ${packageName}
|
||||
|
||||
import android.app.Notification
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.app.PendingIntent
|
||||
import android.app.Service
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.ServiceInfo
|
||||
import android.os.Build
|
||||
import android.os.IBinder
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.content.ContextCompat
|
||||
|
||||
/**
|
||||
* 前台同步服务
|
||||
*
|
||||
* 在通知栏显示常驻通知,防止应用进程被系统杀死
|
||||
* 类似 Element Android 的 GuardAndroidService 和 V2Ray 的保活机制
|
||||
*/
|
||||
class SyncForegroundService : Service() {
|
||||
|
||||
companion object {
|
||||
const val NOTIFICATION_ID = 1001
|
||||
const val CHANNEL_ID = "${channelId}"
|
||||
const val CHANNEL_NAME = "${channelName}"
|
||||
|
||||
const val ACTION_START = "${packageName}.foreground.START"
|
||||
const val ACTION_STOP = "${packageName}.foreground.STOP"
|
||||
const val ACTION_UPDATE = "${packageName}.foreground.UPDATE"
|
||||
|
||||
const val EXTRA_TITLE = "title"
|
||||
const val EXTRA_BODY = "body"
|
||||
|
||||
/**
|
||||
* 启动前台服务
|
||||
*/
|
||||
fun start(context: Context, title: String = "${notificationTitle}", body: String = "${notificationBody}") {
|
||||
val intent = Intent(context, SyncForegroundService::class.java).apply {
|
||||
action = ACTION_START
|
||||
putExtra(EXTRA_TITLE, title)
|
||||
putExtra(EXTRA_BODY, body)
|
||||
}
|
||||
try {
|
||||
ContextCompat.startForegroundService(context, intent)
|
||||
} catch (e: Exception) {
|
||||
android.util.Log.e("SyncForegroundService", "启动前台服务失败", e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 停止前台服务
|
||||
*/
|
||||
fun stop(context: Context) {
|
||||
val intent = Intent(context, SyncForegroundService::class.java).apply {
|
||||
action = ACTION_STOP
|
||||
}
|
||||
context.startService(intent)
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新通知内容
|
||||
*/
|
||||
fun update(context: Context, title: String, body: String) {
|
||||
val intent = Intent(context, SyncForegroundService::class.java).apply {
|
||||
action = ACTION_UPDATE
|
||||
putExtra(EXTRA_TITLE, title)
|
||||
putExtra(EXTRA_BODY, body)
|
||||
}
|
||||
context.startService(intent)
|
||||
}
|
||||
}
|
||||
|
||||
private var notificationTitle: String = "${notificationTitle}"
|
||||
private var notificationBody: String = "${notificationBody}"
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
createNotificationChannel()
|
||||
}
|
||||
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
when (intent?.action) {
|
||||
ACTION_START -> {
|
||||
notificationTitle = intent.getStringExtra(EXTRA_TITLE) ?: "${notificationTitle}"
|
||||
notificationBody = intent.getStringExtra(EXTRA_BODY) ?: "${notificationBody}"
|
||||
startForegroundCompat()
|
||||
}
|
||||
ACTION_STOP -> {
|
||||
stopForegroundCompat()
|
||||
stopSelf()
|
||||
}
|
||||
ACTION_UPDATE -> {
|
||||
notificationTitle = intent.getStringExtra(EXTRA_TITLE) ?: notificationTitle
|
||||
notificationBody = intent.getStringExtra(EXTRA_BODY) ?: notificationBody
|
||||
updateNotification()
|
||||
}
|
||||
}
|
||||
// START_STICKY: 服务被杀后自动重启
|
||||
return START_STICKY
|
||||
}
|
||||
|
||||
override fun onBind(intent: Intent?): IBinder? = null
|
||||
|
||||
/**
|
||||
* 启动前台服务(兼容 Android Q+)
|
||||
*/
|
||||
private fun startForegroundCompat() {
|
||||
val notification = buildNotification()
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
startForeground(
|
||||
NOTIFICATION_ID,
|
||||
notification,
|
||||
ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC
|
||||
)
|
||||
} else {
|
||||
startForeground(NOTIFICATION_ID, notification)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 停止前台服务(兼容 Android N+)
|
||||
*/
|
||||
private fun stopForegroundCompat() {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
|
||||
stopForeground(STOP_FOREGROUND_REMOVE)
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
stopForeground(true)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建通知渠道
|
||||
*/
|
||||
private fun createNotificationChannel() {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
val channel = NotificationChannel(
|
||||
CHANNEL_ID,
|
||||
CHANNEL_NAME,
|
||||
NotificationManager.IMPORTANCE_MIN // 最低重要性,不打扰用户
|
||||
).apply {
|
||||
description = "后台消息同步服务"
|
||||
setSound(null, null)
|
||||
setShowBadge(false)
|
||||
enableLights(false)
|
||||
enableVibration(false)
|
||||
}
|
||||
|
||||
val manager = getSystemService(NotificationManager::class.java)
|
||||
manager.createNotificationChannel(channel)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建通知
|
||||
*/
|
||||
private fun buildNotification(): Notification {
|
||||
// 点击通知打开应用
|
||||
val launchIntent = packageManager.getLaunchIntentForPackage(packageName)
|
||||
val pendingIntent = PendingIntent.getActivity(
|
||||
this,
|
||||
0,
|
||||
launchIntent,
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
||||
)
|
||||
|
||||
return NotificationCompat.Builder(this, CHANNEL_ID)
|
||||
.setContentTitle(notificationTitle)
|
||||
.setContentText(notificationBody)
|
||||
.setSmallIcon(R.drawable.${notificationIcon})
|
||||
.setCategory(NotificationCompat.CATEGORY_SERVICE)
|
||||
.setPriority(NotificationCompat.PRIORITY_MIN)
|
||||
.setOngoing(true) // 不可滑动清除
|
||||
.setShowWhen(false)
|
||||
.setContentIntent(pendingIntent)
|
||||
.build()
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新通知内容
|
||||
*/
|
||||
private fun updateNotification() {
|
||||
val manager = getSystemService(NotificationManager::class.java)
|
||||
manager.notify(NOTIFICATION_ID, buildNotification())
|
||||
}
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成 React Native Bridge 模块代码
|
||||
*/
|
||||
function generateModuleCode(packageName) {
|
||||
return `package ${packageName}
|
||||
|
||||
import android.content.Context
|
||||
import com.facebook.react.bridge.ReactApplicationContext
|
||||
import com.facebook.react.bridge.ReactContextBaseJavaModule
|
||||
import com.facebook.react.bridge.ReactMethod
|
||||
import com.facebook.react.bridge.Promise
|
||||
|
||||
/**
|
||||
* React Native Bridge: 前台服务控制模块
|
||||
*/
|
||||
class ForegroundServiceModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaModule(reactContext) {
|
||||
|
||||
override fun getName(): String = "ForegroundService"
|
||||
|
||||
/**
|
||||
* 启动前台服务
|
||||
*/
|
||||
@ReactMethod
|
||||
fun start(title: String, body: String, promise: Promise) {
|
||||
try {
|
||||
SyncForegroundService.start(reactApplicationContext, title, body)
|
||||
promise.resolve(true)
|
||||
} catch (e: Exception) {
|
||||
promise.reject("FOREGROUND_SERVICE_ERROR", e.message)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 停止前台服务
|
||||
*/
|
||||
@ReactMethod
|
||||
fun stop(promise: Promise) {
|
||||
try {
|
||||
SyncForegroundService.stop(reactApplicationContext)
|
||||
promise.resolve(true)
|
||||
} catch (e: Exception) {
|
||||
promise.reject("FOREGROUND_SERVICE_ERROR", e.message)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新通知内容
|
||||
*/
|
||||
@ReactMethod
|
||||
fun update(title: String, body: String, promise: Promise) {
|
||||
try {
|
||||
SyncForegroundService.update(reactApplicationContext, title, body)
|
||||
promise.resolve(true)
|
||||
} catch (e: Exception) {
|
||||
promise.reject("FOREGROUND_SERVICE_ERROR", e.message)
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成 React Native Package 代码
|
||||
*/
|
||||
function generatePackageCode(packageName) {
|
||||
return `package ${packageName}
|
||||
|
||||
import com.facebook.react.ReactPackage
|
||||
import com.facebook.react.bridge.NativeModule
|
||||
import com.facebook.react.bridge.ReactApplicationContext
|
||||
import com.facebook.react.uimanager.ViewManager
|
||||
|
||||
class ForegroundServicePackage : ReactPackage {
|
||||
override fun createNativeModules(reactContext: ReactApplicationContext): List<NativeModule> {
|
||||
return listOf(ForegroundServiceModule(reactContext))
|
||||
}
|
||||
|
||||
override fun createViewManagers(reactContext: ReactApplicationContext): List<ViewManager<*, *>> {
|
||||
return emptyList()
|
||||
}
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成通知图标 XML
|
||||
*/
|
||||
function generateNotificationIcon() {
|
||||
return `<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<!-- 背景圆 -->
|
||||
<path
|
||||
android:fillColor="#4CAF50"
|
||||
android:pathData="M12,2C6.48,2 2,6.48 2,12s4.48,10 10,10 10,-4.48 10,-10S17.52,2 12,2z"/>
|
||||
<!-- 对勾符号 -->
|
||||
<path
|
||||
android:fillColor="#FFFFFF"
|
||||
android:pathData="M10,17l-5,-5 1.41,-1.41L10,14.17l7.59,-7.59L19,8l-9,9z"/>
|
||||
</vector>
|
||||
`;
|
||||
}
|
||||
|
||||
module.exports = withForegroundService;
|
||||
@@ -1,205 +0,0 @@
|
||||
const {
|
||||
withProjectBuildGradle,
|
||||
withSettingsGradle,
|
||||
withDangerousMod,
|
||||
} = require('@expo/config-plugins');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
// 荣耀厂商推送通道的 Expo config plugin.
|
||||
//
|
||||
// 客户端职责(依据极光官方文档 https://docs.jiguang.cn/jpush/client/Android/android_3rd_guide):
|
||||
// 1) 添加 cn.jiguang.sdk.plugin:honor 依赖
|
||||
// 2) 注入 manifestPlaceholders: HONOR_APPID
|
||||
// 3) 添加荣耀 Maven 仓库 https://developer.hihonor.com/repo (v5.9.0+ 必需)
|
||||
// 4) 添加 Proguard 规则 (com.hihonor.push.** 保留 + 5 条 -keepattributes)
|
||||
//
|
||||
// 实现说明:app/build.gradle 改动用 withDangerousMod 直接读写磁盘文件。
|
||||
// 原因:Expo SDK 56+ 的 withAppBuildGradle hook 链中,第二个以后的 plugin 写回
|
||||
// modResults.contents 会被静默忽略,导致多个 vendor plugin 只能写入第一个的修改。
|
||||
// 仓库(settings.gradle / build.gradle)改动用 withProjectBuildGradle / withSettingsGradle
|
||||
// 没有这个问题。
|
||||
const withHonorPush = (config, options = {}) => {
|
||||
const {
|
||||
jpushVersion = '6.1.0',
|
||||
appId = '104562917',
|
||||
} = options;
|
||||
|
||||
const honorRepoUrl = 'https://developer.hihonor.com/repo';
|
||||
|
||||
// 1. settings.gradle: 添加荣耀 Maven 仓库
|
||||
config = withSettingsGradle(config, (config) => {
|
||||
let contents = config.modResults.contents;
|
||||
|
||||
if (!contents.includes('developer.hihonor.com/repo')) {
|
||||
const repoLine = ` maven { url '${honorRepoUrl}' }`;
|
||||
const hasRepositoriesInPM = /pluginManagement\s*\{[\s\S]*?repositories\s*\{/.test(contents);
|
||||
|
||||
if (hasRepositoriesInPM) {
|
||||
config.modResults.contents = contents.replace(
|
||||
/(pluginManagement\s*\{[\s\S]*?repositories\s*\{)/,
|
||||
`$1\n ${repoLine}`
|
||||
);
|
||||
} else if (contents.includes('pluginManagement')) {
|
||||
config.modResults.contents = contents.replace(
|
||||
/(pluginManagement\s*\{)/,
|
||||
`$1\n repositories {\n gradlePluginPortal()\n google()\n mavenCentral()\n ${repoLine}\n }`
|
||||
);
|
||||
} else {
|
||||
config.modResults.contents =
|
||||
`pluginManagement {\n repositories {\n ${repoLine}\n }\n}\n\n` +
|
||||
config.modResults.contents;
|
||||
}
|
||||
}
|
||||
|
||||
// Gradle 9+ layout
|
||||
if (
|
||||
!config.modResults.contents.match(
|
||||
/dependencyResolutionManagement[\s\S]*developer\.hihonor\.com\/repo/
|
||||
)
|
||||
) {
|
||||
const depMgmtPattern = /dependencyResolutionManagement\s*\{[\s\S]*?repositories\s*\{/;
|
||||
if (depMgmtPattern.test(config.modResults.contents)) {
|
||||
config.modResults.contents = config.modResults.contents.replace(
|
||||
depMgmtPattern,
|
||||
`$&\n maven { url '${honorRepoUrl}' }`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return config;
|
||||
});
|
||||
|
||||
// 2. 根 build.gradle: 添加荣耀 Maven 仓库到 buildscript.repositories
|
||||
config = withProjectBuildGradle(config, (config) => {
|
||||
let contents = config.modResults.contents;
|
||||
|
||||
if (!contents.includes('developer.hihonor.com/repo')) {
|
||||
const bsRepoPattern = /(buildscript\s*\{[\s\S]*?repositories\s*\{)/;
|
||||
if (bsRepoPattern.test(contents)) {
|
||||
contents = contents.replace(
|
||||
bsRepoPattern,
|
||||
`$1\n maven { url '${honorRepoUrl}' }`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
config.modResults.contents = contents;
|
||||
return config;
|
||||
});
|
||||
|
||||
// 3. app/build.gradle: 注入 AAR 依赖 + manifestPlaceholders (用 withDangerousMod 直接读文件)
|
||||
config = withDangerousMod(config, [
|
||||
'android',
|
||||
async (config) => {
|
||||
const appBuildGradlePath = path.join(
|
||||
config.modRequest.platformProjectRoot,
|
||||
'app',
|
||||
'build.gradle'
|
||||
);
|
||||
const proguardFilePath = path.join(
|
||||
config.modRequest.platformProjectRoot,
|
||||
'app',
|
||||
'proguard-rules.pro'
|
||||
);
|
||||
|
||||
// --- 3a. 处理 build.gradle ---
|
||||
if (fs.existsSync(appBuildGradlePath)) {
|
||||
let gradle = fs.readFileSync(appBuildGradlePath, 'utf-8');
|
||||
let changed = false;
|
||||
|
||||
// 注入 cn.jiguang.sdk.plugin:honor 依赖
|
||||
if (!gradle.includes('cn.jiguang.sdk.plugin:honor')) {
|
||||
const depsPattern = /dependencies\s*\{/;
|
||||
if (depsPattern.test(gradle)) {
|
||||
gradle = gradle.replace(
|
||||
depsPattern,
|
||||
`dependencies {\n implementation 'cn.jiguang.sdk.plugin:honor:${jpushVersion}'`
|
||||
);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
// 注入 manifestPlaceholders: HONOR_APPID
|
||||
const manifestPlaceholderRegex = /manifestPlaceholders\s*=\s*\[([\s\S]*?)\]/;
|
||||
const match = gradle.match(manifestPlaceholderRegex);
|
||||
|
||||
const requiredPlaceholders = {
|
||||
HONOR_APPID: appId,
|
||||
};
|
||||
|
||||
if (match) {
|
||||
const existingBlock = match[1];
|
||||
const existingEntries = {};
|
||||
const entryRegex = /(\w+)\s*:\s*"([^"]*)"/g;
|
||||
let entryMatch;
|
||||
while ((entryMatch = entryRegex.exec(existingBlock)) !== null) {
|
||||
existingEntries[entryMatch[1]] = entryMatch[2];
|
||||
}
|
||||
|
||||
let placeholdersChanged = false;
|
||||
for (const [key, value] of Object.entries(requiredPlaceholders)) {
|
||||
if (existingEntries[key] !== value) {
|
||||
existingEntries[key] = value;
|
||||
placeholdersChanged = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (placeholdersChanged) {
|
||||
const entriesStr = Object.entries(existingEntries)
|
||||
.map(([k, v]) => `${k}: "${v}"`)
|
||||
.join(',\n ');
|
||||
const newBlock = `\n manifestPlaceholders = [\n ${entriesStr}\n ]`;
|
||||
gradle = gradle.replace(manifestPlaceholderRegex, newBlock.trim());
|
||||
changed = true;
|
||||
}
|
||||
} else if (gradle.includes('REACT_NATIVE_RELEASE_LEVEL')) {
|
||||
// 没有 manifestPlaceholders block 时新建
|
||||
const entriesStr = Object.entries(requiredPlaceholders)
|
||||
.map(([k, v]) => `${k}: "${v}"`)
|
||||
.join(',\n ');
|
||||
const block = `\n manifestPlaceholders = [\n ${entriesStr}\n ]\n`;
|
||||
const lines = gradle.split('\n');
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
if (lines[i].includes('REACT_NATIVE_RELEASE_LEVEL')) {
|
||||
lines.splice(i + 1, 0, block);
|
||||
break;
|
||||
}
|
||||
}
|
||||
gradle = lines.join('\n');
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if (changed) {
|
||||
fs.writeFileSync(appBuildGradlePath, gradle);
|
||||
console.log('[withHonorPush] updated app/build.gradle (AAR + manifestPlaceholders)');
|
||||
}
|
||||
}
|
||||
|
||||
// --- 3b. 处理 proguard-rules.pro ---
|
||||
if (fs.existsSync(proguardFilePath)) {
|
||||
let proguard = fs.readFileSync(proguardFilePath, 'utf-8');
|
||||
if (!proguard.includes('-keep class com.hihonor.push.**')) {
|
||||
const honorRules = `
|
||||
# JPush Honor vendor channel — keep rules (https://docs.jiguang.cn/jpush/client/Android/android_3rd_guide)
|
||||
-ignorewarnings
|
||||
-keepattributes *Annotation*
|
||||
-keepattributes Exceptions
|
||||
-keepattributes InnerClasses
|
||||
-keepattributes Signature
|
||||
-keepattributes SourceFile,LineNumberTable
|
||||
-keep class com.hihonor.push.**{*;}
|
||||
`;
|
||||
fs.writeFileSync(proguardFilePath, proguard + honorRules);
|
||||
console.log('[withHonorPush] appended Honor Proguard rules to proguard-rules.pro');
|
||||
}
|
||||
}
|
||||
|
||||
return config;
|
||||
},
|
||||
]);
|
||||
|
||||
return config;
|
||||
};
|
||||
|
||||
module.exports = withHonorPush;
|
||||
@@ -1,239 +0,0 @@
|
||||
const {
|
||||
withAppBuildGradle,
|
||||
withProjectBuildGradle,
|
||||
withSettingsGradle,
|
||||
withDangerousMod,
|
||||
withAndroidManifest,
|
||||
} = require('@expo/config-plugins');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const withHuaweiPush = (config, options = {}) => {
|
||||
const { jpushVersion = '6.1.0' } = options;
|
||||
|
||||
config = withSettingsGradle(config, (config) => {
|
||||
const contents = config.modResults.contents;
|
||||
|
||||
// Add Huawei Maven repo to pluginManagement.repositories for buildscript resolution.
|
||||
// NOTE: We intentionally do NOT add `plugins { id 'com.huawei.agconnect' ... }` here.
|
||||
// The Huawei agcp plugin transitively depends on agconnect-apms-plugin:1.6.2.300,
|
||||
// which depends on AGP 4.0.1. That old AGP references BuildCompletionListener (removed
|
||||
// from Gradle 8.x+), causing NoClassDefFoundError at build time. Instead, the plugin
|
||||
// is applied via buildscript classpath in build.gradle with the apms-plugin excluded.
|
||||
if (!contents.includes('developer.huawei.com/repo')) {
|
||||
const repoLine = ` maven { url 'https://developer.huawei.com/repo/' }`;
|
||||
const hasPluginManagement = contents.includes('pluginManagement');
|
||||
const hasRepositoriesInPM = /pluginManagement\s*\{[\s\S]*?repositories\s*\{/.test(contents);
|
||||
|
||||
if (hasRepositoriesInPM) {
|
||||
config.modResults.contents = contents.replace(
|
||||
/(pluginManagement\s*\{[\s\S]*?repositories\s*\{)/,
|
||||
`$1\n ${repoLine}`
|
||||
);
|
||||
} else if (hasPluginManagement) {
|
||||
config.modResults.contents = contents.replace(
|
||||
/(pluginManagement\s*\{)/,
|
||||
`$1\n repositories {\n gradlePluginPortal()\n google()\n mavenCentral()\n ${repoLine}\n }`
|
||||
);
|
||||
} else {
|
||||
config.modResults.contents =
|
||||
`pluginManagement {\n repositories {\n ${repoLine}\n }\n}\n\n` +
|
||||
config.modResults.contents;
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure Huawei Maven repo in dependencyResolutionManagement (Gradle 9+)
|
||||
if (!config.modResults.contents.match(/dependencyResolutionManagement[\s\S]*developer\.huawei\.com\/repo/)) {
|
||||
const depMgmtPattern = /dependencyResolutionManagement\s*\{[\s\S]*?repositories\s*\{/;
|
||||
if (depMgmtPattern.test(config.modResults.contents)) {
|
||||
config.modResults.contents = config.modResults.contents.replace(
|
||||
depMgmtPattern,
|
||||
`$&\n maven { url 'https://developer.huawei.com/repo/' }`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return config;
|
||||
});
|
||||
|
||||
config = withProjectBuildGradle(config, (config) => {
|
||||
const contents = config.modResults.contents;
|
||||
|
||||
if (!contents.includes('developer.huawei.com/repo')) {
|
||||
config.modResults.contents = contents.replace(
|
||||
/(repositories\s*\{)/g,
|
||||
(match) => {
|
||||
return match + `\n maven { url 'https://developer.huawei.com/repo/' }`;
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
return config;
|
||||
});
|
||||
|
||||
config = withProjectBuildGradle(config, (config) => {
|
||||
const contents = config.modResults.contents;
|
||||
|
||||
// Add Huawei AGConnect classpath with apms-plugin excluded.
|
||||
// The apms-plugin transitively depends on AGP 4.0.1 which is incompatible
|
||||
// with Gradle 8.14+ (references removed BuildCompletionListener class).
|
||||
if (!contents.includes('com.huawei.agconnect:agcp')) {
|
||||
if (contents.includes('buildscript')) {
|
||||
const depsPattern = /(buildscript\s*\{[\s\S]*?dependencies\s*\{)/;
|
||||
if (depsPattern.test(contents)) {
|
||||
config.modResults.contents = contents.replace(
|
||||
depsPattern,
|
||||
`$1\n classpath('com.huawei.agconnect:agcp:1.9.1.301') {\n exclude group: 'com.huawei.agconnect', module: 'agconnect-apms-plugin'\n }`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Set explicit AGP version (needed by Huawei plugin validation)
|
||||
if (!contents.includes('com.android.tools.build:gradle:8.12.0')) {
|
||||
config.modResults.contents = config.modResults.contents.replace(
|
||||
/classpath\(['"]com\.android\.tools\.build:gradle['"]\)/,
|
||||
"classpath('com.android.tools.build:gradle:8.12.0')"
|
||||
);
|
||||
}
|
||||
|
||||
// Google Services plugin classpath — required for Firebase init via google-services.json.
|
||||
if (!contents.includes('com.google.gms:google-services')) {
|
||||
const depsPattern = /(buildscript\s*\{[\s\S]*?dependencies\s*\{[\s\S]*?classpath\(['"]org\.jetbrains\.kotlin:kotlin-gradle-plugin['"]\))/;
|
||||
if (depsPattern.test(config.modResults.contents)) {
|
||||
config.modResults.contents = config.modResults.contents.replace(
|
||||
depsPattern,
|
||||
`$1\n classpath('com.google.gms:google-services:4.4.4')`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (!config.modResults.contents.includes('developer.huawei.com/repo')) {
|
||||
const bsRepoPattern = /(buildscript\s*\{[\s\S]*?repositories\s*\{)/;
|
||||
if (bsRepoPattern.test(config.modResults.contents)) {
|
||||
config.modResults.contents = config.modResults.contents.replace(
|
||||
bsRepoPattern,
|
||||
`$1\n maven { url 'https://developer.huawei.com/repo/' }`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return config;
|
||||
});
|
||||
|
||||
config = withAppBuildGradle(config, (config) => {
|
||||
const contents = config.modResults.contents;
|
||||
|
||||
// Apply Google Services plugin (Firebase) before the Huawei plugin.
|
||||
// The googleServicesFile in app.json only sets up the file copy + plugin
|
||||
// when run through `expo prebuild`, but it does not coexist with this
|
||||
// Huawei plugin reliably, so we apply it explicitly here.
|
||||
if (!contents.includes('com.google.gms.google-services')) {
|
||||
const reactApply = /apply plugin:\s*['"]com\.facebook\.react['"]\s*\n/;
|
||||
if (reactApply.test(contents)) {
|
||||
config.modResults.contents = contents.replace(
|
||||
reactApply,
|
||||
`apply plugin: "com.facebook.react"\napply plugin: 'com.google.gms.google-services'\n`
|
||||
);
|
||||
} else {
|
||||
config.modResults.contents = `apply plugin: 'com.google.gms.google-services'\n` + contents;
|
||||
}
|
||||
}
|
||||
|
||||
if (!contents.includes('com.huawei.agconnect')) {
|
||||
const applyPluginPattern = /apply plugin:\s*['"]com\.google\.gms\.google-services['"]/;
|
||||
if (applyPluginPattern.test(contents)) {
|
||||
config.modResults.contents = contents.replace(
|
||||
applyPluginPattern,
|
||||
`apply plugin: 'com.google.gms.google-services'\napply plugin: 'com.huawei.agconnect'`
|
||||
);
|
||||
} else {
|
||||
const lastApplyPlugin = /apply plugin:[^\n]*(?:\n)/g;
|
||||
const matches = contents.match(lastApplyPlugin);
|
||||
if (matches && matches.length > 0) {
|
||||
const lastMatch = matches[matches.length - 1];
|
||||
const lastIndex = contents.lastIndexOf(lastMatch);
|
||||
config.modResults.contents =
|
||||
contents.slice(0, lastIndex + lastMatch.length) +
|
||||
"apply plugin: 'com.huawei.agconnect'\n" +
|
||||
contents.slice(lastIndex + lastMatch.length);
|
||||
} else {
|
||||
config.modResults.contents = contents + "\napply plugin: 'com.huawei.agconnect'\n";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!contents.includes('cn.jiguang.sdk.plugin:huawei')) {
|
||||
const depsPattern = /dependencies\s*\{/;
|
||||
if (depsPattern.test(config.modResults.contents)) {
|
||||
config.modResults.contents = config.modResults.contents.replace(
|
||||
depsPattern,
|
||||
`dependencies {\n implementation 'cn.jiguang.sdk.plugin:huawei:${jpushVersion}'`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return config;
|
||||
});
|
||||
|
||||
config = withDangerousMod(config, [
|
||||
'android',
|
||||
async (config) => {
|
||||
const appDir = path.join(config.modRequest.platformProjectRoot, 'app');
|
||||
const srcFile = path.join(config.modRequest.projectRoot, 'plugins', 'agconnect-services.json');
|
||||
const destFile = path.join(appDir, 'agconnect-services.json');
|
||||
|
||||
if (fs.existsSync(srcFile)) {
|
||||
fs.copyFileSync(srcFile, destFile);
|
||||
console.log('[withHuaweiPush] copied agconnect-services.json to android/app/');
|
||||
} else {
|
||||
console.warn('[withHuaweiPush] agconnect-services.json not found at project root');
|
||||
}
|
||||
|
||||
// Copy google-services.json to android/app/ so the Google Services
|
||||
// Gradle plugin can find it. Without this, FCM / Firebase init fails
|
||||
// at runtime with "default FirebaseApp is not initialized".
|
||||
const gmsSrc = path.join(config.modRequest.projectRoot, 'google-services.json');
|
||||
const gmsDest = path.join(appDir, 'google-services.json');
|
||||
if (fs.existsSync(gmsSrc)) {
|
||||
fs.copyFileSync(gmsSrc, gmsDest);
|
||||
console.log('[withHuaweiPush] copied google-services.json to android/app/');
|
||||
} else {
|
||||
console.warn('[withHuaweiPush] google-services.json not found at project root');
|
||||
}
|
||||
|
||||
// RN 0.85.3 ships Gradle 9.3.1 in its template, but Kotlin 2.1.20 is incompatible
|
||||
// with Gradle 9.x (CompilerEnvironment ClassNotFoundException). Downgrade to 8.14.5.
|
||||
const wrapperFile = path.join(config.modRequest.platformProjectRoot, 'gradle', 'wrapper', 'gradle-wrapper.properties');
|
||||
if (fs.existsSync(wrapperFile)) {
|
||||
let wrapper = fs.readFileSync(wrapperFile, 'utf8');
|
||||
if (wrapper.includes('gradle-9.')) {
|
||||
wrapper = wrapper.replace(/gradle-[\d.]+-bin\.zip/, 'gradle-8.14.5-bin.zip');
|
||||
fs.writeFileSync(wrapperFile, wrapper);
|
||||
console.log('[withHuaweiPush] downgraded Gradle to 8.14.5 for Kotlin 2.1.20 compatibility');
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure local.properties with SDK path exists (gets cleared on prebuild --clean)
|
||||
const localPropsFile = path.join(config.modRequest.platformProjectRoot, 'local.properties');
|
||||
if (!fs.existsSync(localPropsFile)) {
|
||||
const homeDir = process.env.USERPROFILE || process.env.HOME || '';
|
||||
const sdkPath = path.join(homeDir, 'AppData', 'Local', 'Android', 'Sdk');
|
||||
if (fs.existsSync(sdkPath)) {
|
||||
fs.writeFileSync(localPropsFile, `sdk.dir=${sdkPath.replace(/\\/g, '\\\\')}\n`);
|
||||
console.log('[withHuaweiPush] created local.properties with SDK path');
|
||||
}
|
||||
}
|
||||
|
||||
return config;
|
||||
},
|
||||
]);
|
||||
|
||||
config = withAndroidManifest(config, (config) => {
|
||||
return config;
|
||||
});
|
||||
|
||||
return config;
|
||||
};
|
||||
|
||||
module.exports = withHuaweiPush;
|
||||
@@ -1,321 +0,0 @@
|
||||
const {
|
||||
withAndroidManifest,
|
||||
withDangerousMod,
|
||||
withAppDelegate,
|
||||
withInfoPlist,
|
||||
withAppBuildGradle,
|
||||
} = require('@expo/config-plugins');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const withJPush = (config, options = {}) => {
|
||||
const { appKey, channel = 'developer-default' } = options;
|
||||
|
||||
if (!appKey) {
|
||||
throw new Error('JPush appKey is required in plugin options');
|
||||
}
|
||||
|
||||
// 1. Android: Inject manifestPlaceholders into app/build.gradle
|
||||
config = withAppBuildGradle(config, (config) => {
|
||||
const contents = config.modResults.contents;
|
||||
|
||||
const requiredPlaceholders = {
|
||||
JPUSH_APPKEY: appKey,
|
||||
JPUSH_CHANNEL: channel,
|
||||
};
|
||||
|
||||
const manifestPlaceholderRegex = /manifestPlaceholders\s*=\s*\[([\s\S]*?)\]/;
|
||||
const match = contents.match(manifestPlaceholderRegex);
|
||||
|
||||
if (match) {
|
||||
const existingBlock = match[1];
|
||||
const existingEntries = {};
|
||||
const entryRegex = /(\w+)\s*:\s*"([^"]*)"/g;
|
||||
let entryMatch;
|
||||
while ((entryMatch = entryRegex.exec(existingBlock)) !== null) {
|
||||
existingEntries[entryMatch[1]] = entryMatch[2];
|
||||
}
|
||||
|
||||
for (const [key, value] of Object.entries(requiredPlaceholders)) {
|
||||
existingEntries[key] = value;
|
||||
}
|
||||
|
||||
const entriesStr = Object.entries(existingEntries)
|
||||
.map(([k, v]) => `${k}: "${v}"`)
|
||||
.join(',\n ');
|
||||
const newBlock = `\n manifestPlaceholders = [\n ${entriesStr}\n ]`;
|
||||
|
||||
config.modResults.contents = contents.replace(manifestPlaceholderRegex, newBlock.trim());
|
||||
} else if (contents.includes('REACT_NATIVE_RELEASE_LEVEL')) {
|
||||
const entriesStr = Object.entries(requiredPlaceholders)
|
||||
.map(([k, v]) => `${k}: "${v}"`)
|
||||
.join(',\n ');
|
||||
const block = '\n manifestPlaceholders = [\n ' + entriesStr + '\n ]';
|
||||
const lines = contents.split('\n');
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
if (lines[i].includes('REACT_NATIVE_RELEASE_LEVEL')) {
|
||||
lines.splice(i + 1, 0, block);
|
||||
break;
|
||||
}
|
||||
}
|
||||
config.modResults.contents = lines.join('\n');
|
||||
}
|
||||
|
||||
return config;
|
||||
});
|
||||
|
||||
// 2. Android: Add JPush metadata and permissions to AndroidManifest
|
||||
config = withAndroidManifest(config, (config) => {
|
||||
const manifest = config.modResults;
|
||||
|
||||
if (!manifest.manifest['uses-permission']) {
|
||||
manifest.manifest['uses-permission'] = [];
|
||||
}
|
||||
|
||||
const requiredPermissions = [
|
||||
'android.permission.RECEIVE_USER_PRESENT',
|
||||
'android.permission.POST_NOTIFICATIONS',
|
||||
];
|
||||
|
||||
requiredPermissions.forEach((permission) => {
|
||||
const exists = manifest.manifest['uses-permission'].find(
|
||||
(p) => p.$['android:name'] === permission
|
||||
);
|
||||
if (!exists) {
|
||||
manifest.manifest['uses-permission'].push({
|
||||
$: { 'android:name': permission },
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return config;
|
||||
});
|
||||
|
||||
// 3. iOS: Add JPush initialization to AppDelegate
|
||||
config = withAppDelegate(config, (config) => {
|
||||
const { contents } = config.modResults;
|
||||
|
||||
if (contents.includes('JPush initialization') && contents.includes('JPUSHService.registerDeviceToken')) {
|
||||
return config;
|
||||
}
|
||||
|
||||
const isSwift = contents.includes('class AppDelegate') || contents.includes('@main');
|
||||
|
||||
if (isSwift) {
|
||||
let newContents = contents;
|
||||
|
||||
// Add UNUserNotifications import if needed
|
||||
if (!newContents.includes('import UserNotifications')) {
|
||||
if (newContents.includes('import UIKit')) {
|
||||
newContents = newContents.replace(
|
||||
/(import UIKit\n)/,
|
||||
`$1import UserNotifications\n`
|
||||
);
|
||||
} else {
|
||||
newContents = newContents.replace(
|
||||
/(internal import Expo\n)/,
|
||||
`$1import UserNotifications\n`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Add UNUserNotificationCenterDelegate conformance if needed
|
||||
if (!newContents.includes('UNUserNotificationCenterDelegate')) {
|
||||
newContents = newContents.replace(
|
||||
/class AppDelegate: ExpoAppDelegate \{/,
|
||||
'class AppDelegate: ExpoAppDelegate, UNUserNotificationCenterDelegate {'
|
||||
);
|
||||
}
|
||||
|
||||
// Swift: Add JPush init in application(_:didFinishLaunchingWithOptions:)
|
||||
// Expo SDK 56+ uses JPUSHService.setup (not JCoreModule.setup)
|
||||
if (!newContents.includes('JPUSHService.setup') && !newContents.includes('JCoreModule.setup')) {
|
||||
const jpushInitBlock = `
|
||||
// JPush initialization
|
||||
#if DEBUG
|
||||
JPUSHService.setup(withOption: launchOptions, appKey: "${appKey}", channel: "${channel}", apsForProduction: false)
|
||||
#else
|
||||
JPUSHService.setup(withOption: launchOptions, appKey: "${appKey}", channel: "${channel}", apsForProduction: true)
|
||||
#endif
|
||||
UNUserNotificationCenter.current().delegate = self`;
|
||||
|
||||
// Insert after the opening brace of didFinishLaunchingWithOptions
|
||||
// The { may be on the same line as didFinishLaunchingWithOptions or on a subsequent line
|
||||
if (newContents.includes('didFinishLaunchingWithOptions')) {
|
||||
const lines = newContents.split('\n');
|
||||
let foundDidFinish = false;
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
if (lines[i].includes('didFinishLaunchingWithOptions')) {
|
||||
foundDidFinish = true;
|
||||
}
|
||||
// Insert after the line that contains the opening brace of this method
|
||||
if (foundDidFinish && lines[i].includes('{')) {
|
||||
const indent = lines[i].match(/^(\s*)/)[1];
|
||||
const initLines = jpushInitBlock.split('\n').map(l => l ? indent + l.trimStart() : l);
|
||||
lines.splice(i + 1, 0, ...initLines);
|
||||
newContents = lines.join('\n');
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (newContents.includes('JCoreModule.setup') && !newContents.includes('JPUSHService.setup')) {
|
||||
// Migrate from JCoreModule.setup to JPUSHService.setup
|
||||
newContents = newContents.replace(/JCoreModule\.setup/g, 'JPUSHService.setup');
|
||||
}
|
||||
|
||||
// Add delegate = self after init insertion if not present
|
||||
if (!newContents.includes('UNUserNotificationCenter.current().delegate')) {
|
||||
newContents = newContents.replace(
|
||||
/(#endif\n)(\s+let delegate)/,
|
||||
`$1 UNUserNotificationCenter.current().delegate = self\n$2`
|
||||
);
|
||||
}
|
||||
|
||||
// Swift: Add JPush notification delegate methods to AppDelegate class (before closing brace)
|
||||
// These go in AppDelegate, NOT ReactNativeDelegate
|
||||
const jpushSwiftMethods = `
|
||||
|
||||
// JPush: Register for remote notifications
|
||||
override func application(_ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) {
|
||||
JPUSHService.registerDeviceToken(deviceToken)
|
||||
}
|
||||
|
||||
override func application(_ application: UIApplication, didFailToRegisterForRemoteNotificationsWithError error: Error) {
|
||||
NSLog("did fail to register for remote notifications with error: %@", error.localizedDescription)
|
||||
}
|
||||
|
||||
// JPush: Handle remote notification
|
||||
override func application(_ application: UIApplication, didReceiveRemoteNotification userInfo: [AnyHashable: Any], fetchCompletionHandler completionHandler: @escaping (UIBackgroundFetchResult) -> Void) {
|
||||
JPUSHService.handleRemoteNotification(userInfo)
|
||||
completionHandler(.newData)
|
||||
}
|
||||
|
||||
// UNUserNotificationCenter delegate: foreground notification display
|
||||
func userNotificationCenter(_ center: UNUserNotificationCenter, willPresent notification: UNNotification, withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void) {
|
||||
completionHandler([.alert, .badge, .sound])
|
||||
}
|
||||
|
||||
func userNotificationCenter(_ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse, withCompletionHandler completionHandler: @escaping () -> Void) {
|
||||
JPUSHService.handleRemoteNotification(response.notification.request.content.userInfo)
|
||||
completionHandler()
|
||||
}
|
||||
`;
|
||||
|
||||
if (!newContents.includes('JPUSHService.registerDeviceToken')) {
|
||||
// Insert before the closing brace of the AppDelegate class
|
||||
// Find the last '}' that closes the AppDelegate class (before ReactNativeDelegate)
|
||||
const classEndPattern = /\n\}\n\nclass ReactNativeDelegate/;
|
||||
if (classEndPattern.test(newContents)) {
|
||||
newContents = newContents.replace(
|
||||
classEndPattern,
|
||||
`${jpushSwiftMethods}}\n\nclass ReactNativeDelegate`
|
||||
);
|
||||
} else {
|
||||
// Fallback: insert before the last closing brace
|
||||
const lastBraceIndex = newContents.lastIndexOf('}');
|
||||
if (lastBraceIndex !== -1) {
|
||||
newContents = newContents.slice(0, lastBraceIndex) + jpushSwiftMethods + '\n' + newContents.slice(lastBraceIndex);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
config.modResults.contents = newContents;
|
||||
} else {
|
||||
// ObjC AppDelegate
|
||||
let newContents = contents;
|
||||
|
||||
if (!newContents.includes('#import <UserNotifications/UserNotifications.h>')) {
|
||||
newContents = newContents.replace(
|
||||
/(#import "AppDelegate.h")/,
|
||||
`$1\n#import <UserNotifications/UserNotifications.h>`
|
||||
);
|
||||
}
|
||||
|
||||
if (!newContents.includes('#import <jcore-react-native/JCoreModule.h>')) {
|
||||
newContents = newContents.replace(
|
||||
/(#import "AppDelegate.h")/,
|
||||
`$1\n#import <jcore-react-native/JCoreModule.h>`
|
||||
);
|
||||
}
|
||||
|
||||
const didFinishLaunchPattern = /(- \(BOOL\)application:\(UIApplication \*\)application didFinishLaunchingWithOptions:\(NSDictionary \*\)launchOptions\s*\{)/;
|
||||
if (didFinishLaunchPattern.test(newContents)) {
|
||||
newContents = newContents.replace(
|
||||
didFinishLaunchPattern,
|
||||
`$1\n // JPush initialization\n #if DEBUG\n [JCoreModule setupWithOption:launchOptions appKey:@"${appKey}" channel:@"${channel}" apsForProduction:NO];\n #else\n [JCoreModule setupWithOption:launchOptions appKey:@"${appKey}" channel:@"${channel}" apsForProduction:YES];\n #endif\n`
|
||||
);
|
||||
}
|
||||
|
||||
const jpushMethods = `
|
||||
// JPush: Register for remote notifications
|
||||
- (void)application:(UIApplication *)application didRegisterForRemoteNotificationsWithDeviceToken:(NSData *)deviceToken {
|
||||
[JCoreModule registerDeviceToken:deviceToken];
|
||||
}
|
||||
|
||||
- (void)application:(UIApplication *)application didFailToRegisterForRemoteNotificationsWithError:(NSError *)error {
|
||||
NSLog(@"did fail to register for remote notifications with error: %@", error);
|
||||
}
|
||||
|
||||
// JPush: Handle remote notification
|
||||
- (void)application:(UIApplication *)application didReceiveRemoteNotification:(NSDictionary *)userInfo fetchCompletionHandler:(void (^)(UIBackgroundFetchResult))completionHandler {
|
||||
[JCoreModule didReceiveRemoteNotification:userInfo];
|
||||
completionHandler(UIBackgroundFetchResultNewData);
|
||||
}
|
||||
|
||||
// UNUserNotificationCenter delegate
|
||||
- (void)userNotificationCenter:(UNUserNotificationCenter *)center willPresentNotification:(UNNotification *)notification withCompletionHandler:(void (^)(UNNotificationPresentationOptions))completionHandler {
|
||||
completionHandler(UNNotificationPresentationOptionAlert | UNNotificationPresentationOptionBadge | UNNotificationPresentationOptionSound);
|
||||
}
|
||||
|
||||
- (void)userNotificationCenter:(UNUserNotificationCenter *)center didReceiveNotificationResponse:(UNNotificationResponse *)response withCompletionHandler:(void (^)(void))completionHandler {
|
||||
[JCoreModule didReceiveRemoteNotification:response.notification.request.content.userInfo];
|
||||
completionHandler();
|
||||
}
|
||||
`;
|
||||
|
||||
if (!newContents.includes('JCoreModule registerDeviceToken')) {
|
||||
newContents = newContents.replace(
|
||||
/@end\s*$/,
|
||||
`${jpushMethods}\n@end\n`
|
||||
);
|
||||
}
|
||||
|
||||
config.modResults.contents = newContents;
|
||||
}
|
||||
|
||||
return config;
|
||||
});
|
||||
|
||||
// 4. iOS: Add bridging header for Swift if needed
|
||||
config = withDangerousMod(config, [
|
||||
'ios',
|
||||
async (config) => {
|
||||
const appDelegatePath = path.join(config.modRequest.platformProjectRoot, 'app', 'AppDelegate.swift');
|
||||
if (fs.existsSync(appDelegatePath)) {
|
||||
const bridgingHeaderSearch = path.join(config.modRequest.platformProjectRoot, 'app', 'app-Bridging-Header.h');
|
||||
if (fs.existsSync(bridgingHeaderSearch)) {
|
||||
let bridgingContent = fs.readFileSync(bridgingHeaderSearch, 'utf-8');
|
||||
if (!bridgingContent.includes('JCoreModule')) {
|
||||
bridgingContent += '\n#import "JPUSHService.h"\n#import <UserNotifications/UserNotifications.h>\n';
|
||||
fs.writeFileSync(bridgingHeaderSearch, bridgingContent);
|
||||
}
|
||||
}
|
||||
}
|
||||
return config;
|
||||
},
|
||||
]);
|
||||
|
||||
// 5. iOS: Add required capabilities and permissions to Info.plist
|
||||
config = withInfoPlist(config, (config) => {
|
||||
config.modResults.UIBackgroundModes = config.modResults.UIBackgroundModes || [];
|
||||
if (!config.modResults.UIBackgroundModes.includes('remote-notification')) {
|
||||
config.modResults.UIBackgroundModes.push('remote-notification');
|
||||
}
|
||||
|
||||
return config;
|
||||
});
|
||||
|
||||
return config;
|
||||
};
|
||||
|
||||
module.exports = withJPush;
|
||||