Compare commits

1 Commits

Author SHA1 Message Date
cf466a3959 Merge pull request 'dev' (#3) from dev into master
All checks were successful
Frontend CI / ota-android (push) Successful in 13m19s
Frontend CI / build-android-apk (push) Successful in 56m46s
Frontend CI / build-and-push-web (push) Successful in 5m46s
Reviewed-on: #3
2026-03-19 01:00:14 +08:00
515 changed files with 34942 additions and 67618 deletions

View File

@@ -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]')"
]
}
}

View File

@@ -1,40 +1,10 @@
# Dependencies
node_modules node_modules
# Source control
.git .git
.gitignore
# Expo caches
.expo .expo
.expo-shared
# Build artifacts
dist dist
dist-web dist-web
dist-android dist-android
dist-ios
dist-android-update.zip dist-android-update.zip
dist-ios-update.zip
android android
ios
web-build
# Native credentials (never ship into image)
*.jks
*.p8
*.p12
*.key
*.mobileprovision
*.orig.*
# Misc
screenshots screenshots
*.log *.log
.vscode
.idea
.DS_Store
*.swp
*.swo
coverage
__tests__

View File

@@ -21,18 +21,16 @@ on:
env: env:
REGISTRY: code.littlelan.cn REGISTRY: code.littlelan.cn
IMAGE_NAME: carrot_bbs/frontend-web IMAGE_NAME: carrot_bbs/frontend-web
OTA_PLATFORM: android
OTA_PUBLISH_URL: https://updates.littlelan.cn/admin/publish OTA_PUBLISH_URL: https://updates.littlelan.cn/admin/publish
OTA_MANIFEST_URL: https://updates.littlelan.cn/api/manifest OTA_MANIFEST_URL: https://updates.littlelan.cn/api/manifest
jobs: jobs:
ota: ota-android:
runs-on: ubuntu-latest runs-on: ubuntu-latest
if: github.event_name != 'pull_request' && (github.event_name != 'workflow_dispatch' || github.event.inputs.publish_ota == 'true') if: github.event_name != 'pull_request' && (github.event_name != 'workflow_dispatch' || github.event.inputs.publish_ota == 'true')
permissions: permissions:
contents: read contents: read
strategy:
matrix:
platform: [android, ios]
steps: steps:
- name: Checkout code - name: Checkout code
uses: actions/checkout@v4 uses: actions/checkout@v4
@@ -41,6 +39,7 @@ jobs:
uses: actions/setup-node@v4 uses: actions/setup-node@v4
with: with:
node-version: '25.6.1' node-version: '25.6.1'
cache: 'npm'
- name: Install dependencies - name: Install dependencies
run: npm ci run: npm ci
@@ -48,61 +47,63 @@ jobs:
- name: Resolve runtime version - name: Resolve runtime version
id: runtime id: runtime
run: | 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 "runtime_version=${RUNTIME_VERSION}" >> "$GITHUB_OUTPUT"
echo "Resolved runtimeVersion: ${RUNTIME_VERSION}" echo "Resolved runtimeVersion: ${RUNTIME_VERSION}"
- name: Export update bundle - name: Export Android update bundle
run: | run: |
rm -rf dist-${{ matrix.platform }} dist-${{ matrix.platform }}-update.zip rm -rf dist-android dist-android-update.zip
npx expo export --platform ${{ matrix.platform }} --output-dir dist-${{ matrix.platform }} npx expo export --platform android --output-dir dist-android
npx expo config --type public --json > dist-${{ matrix.platform }}/expoConfig.json npx expo config --type public --json > dist-android/expoConfig.json
- name: Archive update bundle - name: Archive Android update bundle
run: | run: |
python -c " python - <<'PY'
import os, zipfile import os
p = '${{ matrix.platform }}' import zipfile
dist = f'dist-{p}'
with zipfile.ZipFile(f'dist-{p}-update.zip', 'w', zipfile.ZIP_DEFLATED) as zf: with zipfile.ZipFile('dist-android-update.zip', 'w', zipfile.ZIP_DEFLATED) as zf:
for root, _, files in os.walk(dist): for root, _, files in os.walk('dist-android'):
for name in files: for name in files:
src = os.path.join(root, name) src = os.path.join(root, name)
arc = os.path.relpath(src, dist) arc = os.path.relpath(src, 'dist-android')
zf.write(src, arc) zf.write(src, arc)
" PY
- name: Publish OTA - name: Publish OTA
env: env:
OTA_AUTH_TOKEN: ${{ secrets.OTA_AUTH_TOKEN }} OTA_AUTH_TOKEN: ${{ secrets.OTA_AUTH_TOKEN }}
run: | run: |
test -n "${OTA_AUTH_TOKEN}" || (echo "Missing secret OTA_AUTH_TOKEN" && exit 1) 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 "Authorization: Bearer ${OTA_AUTH_TOKEN}" \
-H "Content-Type: application/zip" \ -H "Content-Type: application/zip" \
--data-binary @"dist-${{ matrix.platform }}-update.zip" --data-binary @"dist-android-update.zip"
- name: Verify OTA manifest - name: Verify OTA manifest
run: | run: |
REMOTE="$(curl -sS "${OTA_MANIFEST_URL}" \ 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-runtime-version: ${{ steps.runtime.outputs.runtime_version }}" \
-H "expo-protocol-version: 1" \ -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])")" | 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])")" 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 "Remote bundle: ${REMOTE}"
echo "Local bundle: ${LOCAL}" echo "Local bundle: ${LOCAL}"
test "${REMOTE}" = "${LOCAL}" test "${REMOTE}" = "${LOCAL}"
build-android-apk: build-android-apk:
runs-on: android-builder runs-on: ubuntu-latest
container: container:
image: reactnativecommunity/react-native-android:latest image: reactnativecommunity/react-native-android:latest
env: env:
GRADLE_OPTS: "-Dorg.gradle.daemon=false -Dorg.gradle.parallel=false -Dorg.gradle.workers.max=1" 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"
NODE_OPTIONS: "--max-old-space-size=2048" _JAVA_OPTIONS: "-Xmx8g -XX:MaxMetaspaceSize=1g -XX:+UseG1GC -XX:SoftRefLRUPolicyMSPerMB=0 -XX:ReservedCodeCacheSize=512m"
NODE_OPTIONS: "--max-old-space-size=8192"
NODE_ENV: "production" NODE_ENV: "production"
GRADLE_USER_HOME: /root/.gradle NDK_NUM_JOBS: "4"
CMAKE_BUILD_PARALLEL_LEVEL: "4"
permissions: permissions:
contents: read contents: read
steps: steps:
@@ -119,6 +120,7 @@ jobs:
uses: actions/setup-node@v4 uses: actions/setup-node@v4
with: with:
node-version: '25.6.1' node-version: '25.6.1'
cache: 'npm'
- name: Install dependencies - name: Install dependencies
run: npm ci run: npm ci
@@ -126,21 +128,21 @@ jobs:
- name: Generate Android native project - name: Generate Android native project
run: npx expo prebuild --platform android run: npx expo prebuild --platform android
- name: Configure Gradle with signing - name: Configure Gradle with Aliyun Maven mirror
run: | run: |
cd android cd android
# Decode Android signing keystore # Update settings.gradle with Aliyun Maven mirror
echo "${{ secrets.ANDROID_KEYSTORE_BASE64 }}" | base64 -d > app/withyou-release-key.keystore
# Update settings.gradle
cat > settings.gradle << 'SETTINGS_EOF' cat > settings.gradle << 'SETTINGS_EOF'
pluginManagement { pluginManagement {
repositories { repositories {
maven { url 'https://developer.huawei.com/repo/' }
google() google()
mavenCentral() mavenCentral()
gradlePluginPortal() 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( def reactNativeGradlePlugin = new File(
providers.exec { providers.exec {
@@ -163,16 +165,6 @@ jobs:
plugins { plugins {
id("com.facebook.react.settings") id("com.facebook.react.settings")
id("expo-autolinking-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 -> extensions.configure(com.facebook.react.ReactSettingsExtension) { ex ->
@@ -184,7 +176,7 @@ jobs:
} }
expoAutolinking.useExpoModules() expoAutolinking.useExpoModules()
rootProject.name = '威友' rootProject.name = '萝卜社区'
expoAutolinking.useExpoVersionCatalog() expoAutolinking.useExpoVersionCatalog()
@@ -192,22 +184,21 @@ jobs:
includeBuild(expoAutolinking.reactNativeGradlePlugin) includeBuild(expoAutolinking.reactNativeGradlePlugin)
SETTINGS_EOF SETTINGS_EOF
# Update build.gradle # Update build.gradle with Aliyun Maven mirror
cat > build.gradle << 'BUILD_EOF' cat > build.gradle << 'BUILD_EOF'
// Top-level build file where you can add configuration options common to all sub-projects/modules. // Top-level build file where you can add configuration options common to all sub-projects/modules.
buildscript { buildscript {
repositories { repositories {
maven { url 'https://developer.huawei.com/repo/' }
google() google()
mavenCentral() 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 { dependencies {
classpath('com.huawei.agconnect:agcp:1.9.1.301') { classpath('com.android.tools.build:gradle')
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.facebook.react:react-native-gradle-plugin') classpath('com.facebook.react:react-native-gradle-plugin')
classpath('org.jetbrains.kotlin:kotlin-gradle-plugin') classpath('org.jetbrains.kotlin:kotlin-gradle-plugin')
} }
@@ -215,10 +206,12 @@ jobs:
allprojects { allprojects {
repositories { repositories {
maven { url 'https://developer.huawei.com/repo/' }
google() google()
mavenCentral() mavenCentral()
maven { url 'https://www.jitpack.io' } 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" apply plugin: "com.facebook.react.rootproject"
BUILD_EOF BUILD_EOF
# Update gradle.properties (without secrets for better cache hit rate) # Update gradle.properties
cat > gradle.properties << 'PROPS_EOF' cat > gradle.properties << 'PROPS_EOF'
org.gradle.daemon=false org.gradle.daemon=false
org.gradle.parallel=false org.gradle.parallel=true
org.gradle.configureondemand=false org.gradle.configureondemand=true
org.gradle.workers.max=1 org.gradle.workers.max=4
org.gradle.jvmargs=-Xmx4g -XX:MaxMetaspaceSize=1g -XX:+UseG1GC -XX:ReservedCodeCacheSize=256m -XX:+HeapDumpOnOutOfMemoryError org.gradle.jvmargs=-Xmx8g -XX:MaxMetaspaceSize=1g -XX:+UseG1GC -XX:SoftRefLRUPolicyMSPerMB=0 -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError
kotlin.daemon.jvmargs=-Xmx4g -XX:MaxMetaspaceSize=1g -XX:+UseG1GC -XX:ReservedCodeCacheSize=256m
android.enableJetifier=false android.enableJetifier=false
android.useAndroidX=true android.useAndroidX=true
hermesEnabled=true hermesEnabled=true
@@ -243,72 +235,21 @@ jobs:
expo.gif.enabled=true expo.gif.enabled=true
expo.webp.enabled=true expo.webp.enabled=true
expo.webp.animated=false expo.webp.animated=false
ndkVersion=27.1.12297006
expo.useLegacyPackaging=false expo.useLegacyPackaging=false
systemProp.org.gradle.internal.http.connectionTimeout=30000
systemProp.org.gradle.internal.http.socketTimeout=30000
PROPS_EOF 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) - name: Build Android release APK (arm64 only)
run: | run: |
cd android cd android
chmod +x gradlew 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 - name: Upload APK artifact
uses: actions/upload-artifact@v3 uses: actions/upload-artifact@v3
with: with:
name: withyou-android-release-apk name: carrot-bbs-android-release-apk
path: android/app/build/outputs/apk/release/app-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: build-and-push-web:
runs-on: ubuntu-latest runs-on: ubuntu-latest
permissions: permissions:

3
.gitignore vendored
View File

@@ -51,6 +51,3 @@ dist-android-update.zip
# Backend # Backend
backend/data/ backend/data/
backend/logs/ backend/logs/
doc/
docs/

10
.idea/.gitignore generated vendored Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View File

@@ -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"
}
}
}
}

View 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
View File

@@ -1,7 +1,137 @@
/** /**
* 应用入口已由 Expo Router 接管package.json main: expo-router/entry * 萝卜BBS - 主应用入口
* 全局 Provider 与根布局见 app/_layout.tsx。 * 配置导航、主题、状态管理等
*/ */
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>
);
} }

View File

@@ -1,4 +1,4 @@
FROM node:25-alpine AS builder FROM node:20-alpine AS builder
WORKDIR /app WORKDIR /app
@@ -6,11 +6,10 @@ COPY package.json package-lock.json ./
RUN npm ci RUN npm ci
COPY . . 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 FROM nginx:1.27-alpine
COPY nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=builder /app/dist-web /usr/share/nginx/html COPY --from=builder /app/dist-web /usr/share/nginx/html
EXPOSE 80 EXPOSE 80

View File

@@ -1,27 +1,10 @@
const appJson = require('./app.json'); const appJson = require('./app.json');
const { execSync } = require('child_process');
const isDevVariant = process.env.APP_VARIANT === 'dev'; 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 releaseUpdatesBaseUrl = 'https://updates.littlelan.cn';
const devApiBaseUrl = process.env.EXPO_PUBLIC_API_BASE_URL || 'http://192.168.31.238:8080/api/v1'; 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) { function toManifestUrl(baseUrl, portOverride) {
const parsed = new URL(baseUrl); const parsed = new URL(baseUrl);
if (portOverride != null) { if (portOverride != null) {
@@ -40,72 +23,27 @@ const devUpdatesUrl =
const expo = appJson.expo; 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 = { module.exports = {
...expo, ...expo,
name: isDevVariant ? `${expo.name} Dev` : expo.name, name: isDevVariant ? `${expo.name} Dev` : expo.name,
// runtimeVersion 用 build numbercommit count + 偏移):单调递增、纯数字、与 version (语义版本) 解耦 runtimeVersion: {
// 字符串形式,等价于 policy: 'custom' policy: 'appVersion',
runtimeVersion: buildNumber, },
updates: { updates: {
...(expo.updates || {}), ...(expo.updates || {}),
url: isDevVariant ? devUpdatesUrl : releaseUpdatesUrl, url: isDevVariant ? devUpdatesUrl : releaseUpdatesUrl,
checkAutomatically: 'ON_LOAD', checkAutomatically: 'ON_LOAD',
fallbackToCacheTimeout: 0, fallbackToCacheTimeout: 0,
}, },
ios: { ios: expo.ios,
...expo.ios,
buildNumber: buildNumber,
},
android: { android: {
...expo.android, ...expo.android,
package: 'cn.qczlit.withyou', package: 'skin.carrot.bbs',
versionCode: parseInt(buildNumber, 10),
}, },
// Web 端使用过滤后的插件
plugins: filteredPlugins,
extra: { extra: {
...(expo.extra || {}), ...(expo.extra || {}),
appVariant: isDevVariant ? 'dev' : 'release', appVariant: isDevVariant ? 'dev' : 'release',
apiBaseUrl: isDevVariant ? devApiBaseUrl : releaseApiBaseUrl, apiBaseUrl: isDevVariant ? devApiBaseUrl : releaseApiBaseUrl,
updatesUrl: isDevVariant ? devUpdatesUrl : releaseUpdatesUrl, updatesUrl: isDevVariant ? devUpdatesUrl : releaseUpdatesUrl,
}, },
// Web 端配置
...(isWeb && {
web: {
...expo.web,
build: {
...expo.web?.build,
// 配置 Web 端别名,避免加载原生模块
babel: {
dangerouslyAddModulePathsToTranspile: [
'livekit-client',
'@livekit',
],
},
},
},
}),
}; };

100
app.json
View File

@@ -1,12 +1,11 @@
{ {
"expo": { "expo": {
"name": "威友", "name": "萝卜社区",
"slug": "qojo", "slug": "qojo",
"version": "1.0.1", "version": "1.0.10",
"orientation": "default", "orientation": "default",
"icon": "./assets/icon.png", "icon": "./assets/icon.png",
"userInterfaceStyle": "automatic", "userInterfaceStyle": "light",
"scheme": "withyou",
"splash": { "splash": {
"image": "./assets/splash-icon.png", "image": "./assets/splash-icon.png",
"resizeMode": "contain", "resizeMode": "contain",
@@ -15,31 +14,28 @@
"ios": { "ios": {
"supportsTablet": true, "supportsTablet": true,
"infoPlist": { "infoPlist": {
"NSMicrophoneUsageDescription": "允许威友访问您的麦克风以进行语音通话",
"NSCameraUsageDescription": "允许威友访问您的相机以进行视频通话",
"UIBackgroundModes": [ "UIBackgroundModes": [
"fetch", "fetch",
"remote-notification", "remote-notification",
"audio" "fetch",
"remote-notification"
], ],
"ITSAppUsesNonExemptEncryption": false "ITSAppUsesNonExemptEncryption": false
}, },
"bundleIdentifier": "cn.qczlit.weiyou" "bundleIdentifier": "skin.carrot.bbs"
}, },
"android": { "android": {
"googleServicesFile": "./google-services.json",
"adaptiveIcon": { "adaptiveIcon": {
"backgroundColor": "#0570F9", "backgroundColor": "#E6F4FE",
"foregroundImage": "./assets/android-icon-foreground.png", "foregroundImage": "./assets/android-icon-foreground.png",
"backgroundImage": "./assets/android-icon-background.png", "backgroundImage": "./assets/android-icon-background.png",
"monochromeImage": "./assets/android-icon-monochrome.png" "monochromeImage": "./assets/android-icon-monochrome.png"
}, },
"predictiveBackGestureEnabled": false, "predictiveBackGestureEnabled": false,
"package": "cn.qczlit.withyou", "package": "skin.carrot.bbs",
"versionCode": 5,
"permissions": [ "permissions": [
"VIBRATE", "VIBRATE",
"RECORD_AUDIO",
"CAMERA",
"RECEIVE_BOOT_COMPLETED", "RECEIVE_BOOT_COMPLETED",
"WAKE_LOCK", "WAKE_LOCK",
"READ_EXTERNAL_STORAGE", "READ_EXTERNAL_STORAGE",
@@ -52,32 +48,16 @@
"android.permission.READ_EXTERNAL_STORAGE", "android.permission.READ_EXTERNAL_STORAGE",
"android.permission.WRITE_EXTERNAL_STORAGE", "android.permission.WRITE_EXTERNAL_STORAGE",
"android.permission.READ_MEDIA_VISUAL_USER_SELECTED", "android.permission.READ_MEDIA_VISUAL_USER_SELECTED",
"android.permission.ACCESS_MEDIA_LOCATION",
"android.permission.READ_MEDIA_IMAGES", "android.permission.READ_MEDIA_IMAGES",
"android.permission.READ_MEDIA_VIDEO", "android.permission.READ_MEDIA_VIDEO",
"android.permission.READ_MEDIA_AUDIO", "android.permission.READ_MEDIA_AUDIO"
"android.permission.ACCESS_NETWORK_STATE",
"android.permission.ACCESS_WIFI_STATE",
"android.permission.POST_NOTIFICATIONS"
] ]
}, },
"web": { "web": {
"favicon": "./assets/favicon.png" "favicon": "./assets/favicon.png"
}, },
"plugins": [ "plugins": [
"./plugins/withCmakeJobLimit",
"./plugins/withJcorePatch",
"./plugins/withSigning",
"./plugins/withMainActivityConfigChange",
[
"./plugins/withForegroundService",
{
"notificationTitle": "威友",
"notificationBody": "正在后台同步消息",
"notificationIcon": "ic_notification",
"channelId": "withyou_sync_foreground",
"channelName": "消息同步"
}
],
[ [
"expo-router", "expo-router",
{ {
@@ -89,31 +69,30 @@
], ],
"expo-sqlite", "expo-sqlite",
[ [
"expo-camera", "expo-notifications",
{ {
"cameraPermission": "允许威友访问您的相机以扫描二维码" "sounds": []
} }
], ],
[ [
"expo-background-task", "expo-background-fetch",
{ {
"minimumInterval": 15 "minimumInterval": 900
} }
], ],
"./plugins/withRemoveAutoStart",
[ [
"expo-media-library", "expo-media-library",
{ {
"photosPermission": "允许威友访问您的照片以发布内容", "photosPermission": "允许萝卜社区访问您的照片以发布内容",
"savePhotosPermission": "允许威友保存照片到您的相册", "savePhotosPermission": "允许萝卜社区保存照片到您的相册",
"isAccessMediaLocationEnabled": false "isAccessMediaLocationEnabled": true
} }
], ],
[ [
"expo-image-picker", "expo-image-picker",
{ {
"photosPermission": "允许威友访问您的照片以选择图片", "photosPermission": "允许萝卜社区访问您的照片以选择图片",
"cameraPermission": "允许威友访问您的相机以拍摄图片", "cameraPermission": "允许萝卜社区访问您的相机以拍摄图片",
"colors": { "colors": {
"cropToolbarColor": "#111827", "cropToolbarColor": "#111827",
"cropToolbarIconColor": "#ffffff", "cropToolbarIconColor": "#ffffff",
@@ -138,44 +117,13 @@
"proguardRules": "-keep class com.google.android.exoplayer2.** { *; }" "proguardRules": "-keep class com.google.android.exoplayer2.** { *; }"
} }
], ],
"expo-font", "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"
], ],
"jsEngine": "hermes",
"extra": { "extra": {
"eas": { "eas": {
"projectId": "65540196-d37d-437b-8496-227df0317069" "projectId": "65540196-d37d-437b-8496-227df0317069"
}, }
"jpushAppKey": "2cbe4da12e2c24aa3dca4610",
"jpushChannel": "developer-default"
}, },
"owner": "qojo" "owner": "qojo"
} }

View File

@@ -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>
);
}

View File

@@ -1,5 +0,0 @@
import { Stack } from 'expo-router';
export default function AppsStackLayout() {
return <Stack screenOptions={{ headerShown: false }} />;
}

View File

@@ -1,5 +0,0 @@
import { AppsScreen } from '../../../../src/screens/apps';
export default function AppsRoute() {
return <AppsScreen />;
}

View File

@@ -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>
);
}

View File

@@ -1,5 +0,0 @@
import { MaterialDetailScreen } from '../../../../../src/screens/material';
export default function MaterialDetailRoute() {
return <MaterialDetailScreen />;
}

View File

@@ -1,5 +0,0 @@
import { MaterialsScreen } from '../../../../../src/screens/material';
export default function MaterialsRoute() {
return <MaterialsScreen />;
}

View File

@@ -1,5 +0,0 @@
import { SubjectMaterialsScreen } from '../../../../../src/screens/material';
export default function SubjectMaterialsRoute() {
return <SubjectMaterialsScreen />;
}

View File

@@ -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>
);
}

View File

@@ -1,5 +0,0 @@
import { CourseDetailScreen } from '../../../../../src/screens/schedule';
export default function CourseDetailRoute() {
return <CourseDetailScreen />;
}

View File

@@ -1,5 +0,0 @@
import { ScheduleScreen } from '../../../../../src/screens/schedule';
export default function ScheduleRoute() {
return <ScheduleScreen />;
}

View File

@@ -1,5 +0,0 @@
import { Stack } from 'expo-router';
export default function HomeStackLayout() {
return <Stack screenOptions={{ headerShown: false }} />;
}

View File

@@ -1,5 +0,0 @@
import { HomeScreen } from '../../../../src/screens/home';
export default function HomeRoute() {
return <HomeScreen />;
}

View File

@@ -1,5 +0,0 @@
import { SearchScreen } from '../../../../src/screens/home';
export default function HomeSearchRoute() {
return <SearchScreen />;
}

View File

@@ -1,5 +0,0 @@
import { Stack } from 'expo-router';
export default function MessagesStackLayout() {
return <Stack screenOptions={{ headerShown: false }} />;
}

View File

@@ -1,5 +0,0 @@
import { MessageListScreen } from '../../../../src/screens/message';
export default function MessagesRoute() {
return <MessageListScreen />;
}

View File

@@ -1,5 +0,0 @@
import { NotificationsScreen } from '../../../../src/screens/message';
export default function NotificationsRoute() {
return <NotificationsScreen />;
}

View File

@@ -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>
);
}

View File

@@ -1,5 +0,0 @@
import { AboutScreen } from '../../../../src/screens/profile/AboutScreen';
export default function AboutRoute() {
return <AboutScreen />;
}

View File

@@ -1,5 +0,0 @@
import { AccountDeletionScreen } from '../../../../src/screens/profile';
export default function AccountDeletionRoute() {
return <AccountDeletionScreen />;
}

View File

@@ -1,5 +0,0 @@
import { AccountSecurityScreen } from '../../../../src/screens/profile';
export default function AccountSecurityRoute() {
return <AccountSecurityScreen />;
}

View File

@@ -1,5 +0,0 @@
import { BlockedUsersScreen } from '../../../../src/screens/profile';
export default function BlockedUsersRoute() {
return <BlockedUsersScreen />;
}

View File

@@ -1,5 +0,0 @@
import { ProfileScreen } from '../../../../src/screens/profile';
export default function BookmarksRoute() {
return <ProfileScreen />;
}

View File

@@ -1,5 +0,0 @@
import { ChatSettingsScreen } from '../../../../src/screens/profile/ChatSettingsScreen';
export default function ChatSettingsRoute() {
return <ChatSettingsScreen />;
}

View File

@@ -1,5 +0,0 @@
import { DataStorageScreen } from '../../../../src/screens/profile';
export default function DataStorageRoute() {
return <DataStorageScreen />;
}

View File

@@ -1,5 +0,0 @@
import { EditProfileScreen } from '../../../../src/screens/profile';
export default function EditProfileRoute() {
return <EditProfileScreen />;
}

View File

@@ -1,5 +0,0 @@
import { ProfileScreen } from '../../../../src/screens/profile';
export default function ProfileRoute() {
return <ProfileScreen />;
}

View File

@@ -1,5 +0,0 @@
import { ProfileScreen } from '../../../../src/screens/profile';
export default function MyPostsRoute() {
return <ProfileScreen />;
}

View File

@@ -1,5 +0,0 @@
import { NotificationSettingsScreen } from '../../../../src/screens/profile';
export default function NotificationSettingsRoute() {
return <NotificationSettingsScreen />;
}

View File

@@ -1,5 +0,0 @@
import { PrivacySettingsScreen } from '../../../../src/screens/profile';
export default function PrivacySettingsRoute() {
return <PrivacySettingsScreen />;
}

View File

@@ -1,5 +0,0 @@
import { SettingsScreen } from '../../../../src/screens/profile';
export default function SettingsRoute() {
return <SettingsScreen />;
}

View File

@@ -1,5 +0,0 @@
import { VerificationSettingsScreen } from '@/screens/profile/VerificationSettingsScreen';
export default function VerificationSettingsPage() {
return <VerificationSettingsScreen />;
}

View File

@@ -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()} />;
}

View File

@@ -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()} />;
}

View File

@@ -1,5 +0,0 @@
import { ChatScreen } from '../../../src/screens/message';
export default function ChatRoute() {
return <ChatScreen />;
}

View File

@@ -1,5 +0,0 @@
import { MessageSearchScreen } from '../../../src/screens/message';
export default function MessageSearchRoute() {
return <MessageSearchScreen />;
}

View File

@@ -1,5 +0,0 @@
import { PrivateChatInfoScreen } from '../../../src/screens/message';
export default function PrivateChatInfoRoute() {
return <PrivateChatInfoScreen />;
}

View File

@@ -1,5 +0,0 @@
import { GroupInfoScreen } from '../../../../src/screens/message';
export default function GroupInfoRoute() {
return <GroupInfoScreen />;
}

View File

@@ -1,5 +0,0 @@
import { GroupMembersScreen } from '../../../../src/screens/message';
export default function GroupMembersRoute() {
return <GroupMembersScreen />;
}

View File

@@ -1,5 +0,0 @@
import { CreateGroupScreen } from '../../../src/screens/message';
export default function CreateGroupRoute() {
return <CreateGroupScreen />;
}

View File

@@ -1,5 +0,0 @@
import GroupInviteDetailScreen from '../../../src/screens/message/GroupInviteDetailScreen';
export default function GroupInviteRoute() {
return <GroupInviteDetailScreen />;
}

View File

@@ -1,5 +0,0 @@
import { JoinGroupScreen } from '../../../src/screens/message';
export default function JoinGroupRoute() {
return <JoinGroupScreen />;
}

View File

@@ -1,5 +0,0 @@
import GroupRequestDetailScreen from '../../../src/screens/message/GroupRequestDetailScreen';
export default function GroupRequestRoute() {
return <GroupRequestDetailScreen />;
}

View File

@@ -1,5 +0,0 @@
import { CreatePostScreen } from '../../../src/screens/create';
export default function CreatePostRoute() {
return <CreatePostScreen />;
}

View File

@@ -1,5 +0,0 @@
import { QRCodeConfirmScreen } from '../../../../src/screens/auth/QRCodeConfirmScreen';
export default function QrLoginConfirmRoute() {
return <QRCodeConfirmScreen />;
}

View File

@@ -1,5 +0,0 @@
import FollowListScreen from '../../../../src/screens/profile/FollowListScreen';
export default function FollowListRoute() {
return <FollowListScreen />;
}

View File

@@ -1,5 +0,0 @@
import { Stack } from 'expo-router';
export default function AuthLayout() {
return <Stack screenOptions={{ headerShown: false }} />;
}

View File

@@ -1,5 +0,0 @@
import { ForgotPasswordScreen } from '../../src/screens/auth';
export default function ForgotPasswordRoute() {
return <ForgotPasswordScreen />;
}

View File

@@ -1,5 +0,0 @@
import { LoginScreen } from '../../src/screens/auth';
export default function LoginRoute() {
return <LoginScreen />;
}

View File

@@ -1,5 +0,0 @@
import { RegisterScreen } from '../../src/screens/auth';
export default function RegisterRoute() {
return <RegisterScreen />;
}

View File

@@ -1,5 +0,0 @@
import { VerificationFormScreen } from '@/screens/auth/VerificationFormScreen';
export default function VerificationFormPage() {
return <VerificationFormScreen />;
}

View File

@@ -1,5 +0,0 @@
import { VerificationGuideScreen } from '@/screens/auth/VerificationGuideScreen';
export default function VerificationGuidePage() {
return <VerificationGuideScreen />;
}

View File

@@ -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>
);
}

View File

@@ -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()} />;
}

View File

@@ -1,5 +0,0 @@
import { PostDetailScreen } from '../../src/screens/home';
export default function PostDetailRoute() {
return <PostDetailScreen />;
}

View File

@@ -1,5 +0,0 @@
import { PrivacyPolicyScreen } from '../src/screens/profile/PrivacyPolicyScreen';
export default function PrivacyRoute() {
return <PrivacyPolicyScreen />;
}

View File

@@ -1,5 +0,0 @@
import { TermsOfServiceScreen } from '../src/screens/profile/TermsOfServiceScreen';
export default function TermsRoute() {
return <TermsOfServiceScreen />;
}

View File

@@ -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} />;
}

View File

@@ -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}
/>
);
}

View File

@@ -1,5 +0,0 @@
import { UserScreen } from '../../src/screens/profile';
export default function UserProfileRoute() {
return <UserScreen />;
}

View File

@@ -1,5 +0,0 @@
import { WelcomeScreen } from '../src/screens/auth';
export default function WelcomeRoute() {
return <WelcomeScreen />;
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.0 KiB

After

Width:  |  Height:  |  Size: 7.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.7 KiB

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 385 KiB

After

Width:  |  Height:  |  Size: 895 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 385 KiB

After

Width:  |  Height:  |  Size: 895 KiB

1725
docs/OPTIMIZATION_DESIGN.md Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -27,9 +27,6 @@
}, },
"production": { "production": {
"autoIncrement": true, "autoIncrement": true,
"android": {
"buildType": "apk"
},
"ios": { "ios": {
"resourceClass": "m-medium" "resourceClass": "m-medium"
} }

View File

@@ -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"
}

View File

@@ -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);

View File

@@ -1,72 +1,11 @@
/** @type {import('expo/metro-config').MetroConfig} */ /** @type {import('expo/metro-config').MetroConfig} */
const { getDefaultConfig } = require('expo/metro-config'); const { getDefaultConfig } = require('expo/metro-config');
const path = require('path');
const config = getDefaultConfig(__dirname); const config = getDefaultConfig(__dirname);
// Add wasm asset support // Add wasm asset support
config.resolver.assetExts.push('wasm'); 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, // NOTE: enhanceMiddleware is marked as deprecated in Metro's types,
// but there's currently no official alternative for custom dev server headers. // but there's currently no official alternative for custom dev server headers.
// For production, configure COEP/COOP headers on your hosting platform. // For production, configure COEP/COOP headers on your hosting platform.

View File

@@ -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

File diff suppressed because it is too large Load Diff

View File

@@ -1,7 +1,7 @@
{ {
"name": "with_you", "name": "carrot_bbs",
"version": "1.0.1", "version": "1.0.0",
"main": "expo-router/entry", "main": "index.ts",
"scripts": { "scripts": {
"start": "expo start", "start": "expo start",
"android": "expo run:android", "android": "expo run:android",
@@ -14,78 +14,55 @@
"ios:submit": "eas submit --platform ios --profile production" "ios:submit": "eas submit --platform ios --profile production"
}, },
"dependencies": { "dependencies": {
"@expo/ui": "~56.0.17",
"@expo/vector-icons": "^15.1.1", "@expo/vector-icons": "^15.1.1",
"@livekit/react-native": "^2.11.0", "@react-native-async-storage/async-storage": "^2.2.0",
"@livekit/react-native-webrtc": "^144.1.0", "@react-navigation/bottom-tabs": "^7.15.2",
"@react-native-async-storage/async-storage": "^3.1.1", "@react-navigation/material-top-tabs": "^7.4.16",
"@shopify/flash-list": "^2.3.1", "@react-navigation/native": "^7.1.31",
"@tanstack/react-query": "^5.100.14", "@react-navigation/native-stack": "^7.14.2",
"@types/react": "~19.2.14", "@shopify/flash-list": "2.0.2",
"axios": "^1.16.1", "@tanstack/react-query": "^5.90.21",
"date-fns": "^4.4.0", "axios": "^1.13.6",
"expo": "~56.0.11", "date-fns": "^4.1.0",
"expo-background-task": "~56.0.18", "expo": "~55.0.4",
"expo-callkit-telecom": "^0.3.9", "expo-background-fetch": "~55.0.9",
"expo-camera": "~56.0.8", "expo-constants": "~55.0.7",
"expo-constants": "~56.0.18", "expo-dev-client": "~55.0.10",
"expo-dev-client": "~56.0.20", "expo-file-system": "~55.0.10",
"expo-document-picker": "~56.0.4", "expo-font": "~55.0.4",
"expo-file-system": "~56.0.8", "expo-haptics": "~55.0.8",
"expo-font": "~56.0.6", "expo-image": "^55.0.5",
"expo-haptics": "~56.0.3", "expo-image-picker": "^55.0.10",
"expo-image": "~56.0.6", "expo-linear-gradient": "^55.0.8",
"expo-image-picker": "~56.0.17", "expo-media-library": "~55.0.9",
"expo-intent-launcher": "~56.0.4", "expo-notifications": "^55.0.10",
"expo-linear-gradient": "~56.0.4", "expo-router": "^55.0.4",
"expo-linking": "~56.0.14", "expo-sqlite": "~55.0.10",
"expo-media-library": "~56.0.7", "expo-status-bar": "~55.0.4",
"expo-notifications": "~56.0.17", "expo-system-ui": "^55.0.9",
"expo-router": "~56.2.10", "expo-task-manager": "~55.0.9",
"expo-splash-screen": "~56.0.10", "expo-updates": "^55.0.12",
"expo-sqlite": "~56.0.5", "expo-video": "^55.0.10",
"expo-status-bar": "~56.0.4", "react": "19.2.0",
"expo-system-ui": "~56.0.5", "react-dom": "19.2.0",
"expo-task-manager": "~56.0.18", "react-native": "0.83.2",
"expo-updates": "~56.0.19", "react-native-gesture-handler": "^2.30.0",
"expo-video": "~56.1.3", "react-native-pager-view": "^8.0.0",
"jcore-react-native": "^2.3.6", "react-native-paper": "^5.15.0",
"jpush-react-native": "^3.2.7", "react-native-reanimated": "^4.2.1",
"katex": "^0.17.0", "react-native-safe-area-context": "~5.6.2",
"livekit-client": "^2.19.2", "react-native-screens": "~4.23.0",
"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-sse": "^1.2.1", "react-native-sse": "^1.2.1",
"react-native-web": "^0.21.0", "react-native-web": "^0.21.0",
"react-native-webview": "13.16.1", "react-native-worklets": "0.7.2",
"react-native-worklets": "^0.8.3", "zod": "^4.3.6",
"zod": "^4.4.3", "zustand": "^5.0.11"
"zustand": "^5.0.14"
}, },
"devDependencies": { "devDependencies": {
"@react-native-community/cli": "^20.1.3", "@react-native-community/cli": "^20.1.2",
"@types/pako": "^2.0.4", "@types/react": "~19.2.2",
"@types/react": "~19.2.16",
"@types/react-native-vector-icons": "^6.4.18", "@types/react-native-vector-icons": "^6.4.18",
"typescript": "~6.0.3" "typescript": "~5.9.2"
}, },
"private": true, "private": true
"expo": {
"install": {
"exclude": [
"expo-image"
]
}
}
} }

View File

@@ -1,4 +0,0 @@
plugins {
kotlin("jvm") version "2.1.20" apply false
id("java-gradle-plugin")
}

View File

@@ -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" }

View File

@@ -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

View File

@@ -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
}
}

View File

@@ -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"}}]}

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

Some files were not shown because too many files have changed in this diff Show More