Compare commits
198 Commits
cf466a3959
...
feature/pu
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0e79dbf655 | ||
|
|
a921aacefd | ||
|
|
96e8de18bf | ||
|
|
b2979311bb | ||
|
|
9b5e76b310 | ||
|
|
b05da4e4a6 | ||
|
|
641de98dc1 | ||
|
|
33a9c2fad1 | ||
|
|
2195fe2c2b | ||
|
|
03a735b6ac | ||
|
|
c06463f576 | ||
|
|
c46260e4c0 | ||
|
|
4c4d28f0e2 | ||
|
|
a31225dce2 | ||
|
|
b0b868593d | ||
|
|
ed06c10f25 | ||
|
|
ce56824c2e | ||
|
|
2ae5999940 | ||
|
|
edde8f1c30 | ||
|
|
1012337e57 | ||
|
|
f3d54a3f4c | ||
|
|
d8ef51fa13 | ||
|
|
97477c3471 | ||
|
|
1e05d2bd54 | ||
|
|
afbbee337d | ||
|
|
b15e0c0b0b | ||
|
|
5c81795d39 | ||
|
|
1f7e25349f | ||
|
|
49e94fc056 | ||
|
|
cabac94aae | ||
|
|
c6d65fe545 | ||
|
|
f79415591d | ||
|
|
3cc3695d0e | ||
|
|
765fd8cce9 | ||
|
|
2e2f6e3467 | ||
|
|
2e6912dddf | ||
|
|
d6a94c7b4d | ||
|
|
8ee6e77cb4 | ||
|
|
52d2581dda | ||
|
|
e7eae716be | ||
|
|
ad06881b85 | ||
|
|
f39288f401 | ||
|
|
70ab00795a | ||
|
|
72c30ed156 | ||
|
|
b86fe324f5 | ||
|
|
313820ed20 | ||
|
|
f4db4eb1ed | ||
|
|
7a17323e8d | ||
|
|
4fde3e403a | ||
|
|
fb67fb6d5b | ||
|
|
404b3fabe7 | ||
|
|
d8d2b03f94 | ||
|
|
ea2cf7bcff | ||
|
|
4308077232 | ||
|
|
cbe708b53b | ||
|
|
7e0436a799 | ||
|
|
48f31e6617 | ||
|
|
7c7aaf9108 | ||
|
|
1d9c312c6c | ||
|
|
d42083a4aa | ||
|
|
d0ea2c5aea | ||
|
|
81ee9ba75b | ||
|
|
d4c3e1f268 | ||
|
|
ea9e51b0b0 | ||
|
|
7f606e14dd | ||
|
|
64493baf7f | ||
|
|
d879eea7d1 | ||
|
|
e18cac2dca | ||
|
|
3196972596 | ||
|
|
f5f9c3a619 | ||
|
|
c0ebebe3da | ||
|
|
8a271b2376 | ||
|
|
4213d13b8f | ||
|
|
490a99ab3c | ||
|
|
0f289e3182 | ||
|
|
d2120a257d | ||
|
|
cf9feeae68 | ||
|
|
2432f7bc1e | ||
|
|
be0c304711 | ||
|
|
b7ce9e3b9a | ||
|
|
296e649fbb | ||
|
|
88d9ffe9da | ||
|
|
9745d2209f | ||
|
|
2015a1e9dd | ||
|
|
ba89b3dc94 | ||
|
|
0e4306c3ac | ||
|
|
f478fa3fd3 | ||
|
|
e0b43ce60d | ||
|
|
0e2945b86b | ||
|
|
e519346261 | ||
|
|
1989cc8519 | ||
|
|
6cbd2092ac | ||
|
|
5815f1a5f7 | ||
|
|
e9d7098ad0 | ||
|
|
2dc6936aac | ||
|
|
5fa5403d6a | ||
|
|
de0afa93a1 | ||
|
|
eccfd67550 | ||
|
|
6b91a7ead1 | ||
|
|
a6a4198ac5 | ||
|
|
19054d64b3 | ||
|
|
f16f001f6c | ||
|
|
ad19bc2af7 | ||
|
|
e0d28535f4 | ||
|
|
738e75a79d | ||
|
|
b2c3d5e54e | ||
|
|
eb4e1c080d | ||
|
|
dcae47218f | ||
|
|
437dab3b6e | ||
|
|
1c3797ea7d | ||
|
|
30c493c88f | ||
|
|
b16759a147 | ||
|
|
6910fdec70 | ||
|
|
c00b915e5f | ||
|
|
57d7c7405c | ||
|
|
4f31926eb5 | ||
|
|
fe6a03da5d | ||
|
|
2adc9360a5 | ||
|
|
6610d2f173 | ||
|
|
4b5ce1ba21 | ||
|
|
6f84e17772 | ||
|
|
9bbed8cf5e | ||
|
|
be8f6de8cf | ||
|
|
96a5207cf8 | ||
|
|
25194313ae | ||
|
|
445c1c5561 | ||
|
|
accf7c04e8 | ||
|
|
542d385d08 | ||
|
|
82c2970a85 | ||
|
|
189b977fac | ||
|
|
69717ea407 | ||
|
|
775deeb9c4 | ||
|
|
e8651215f7 | ||
|
|
72842352d9 | ||
|
|
c771bd9755 | ||
|
|
5614b4078a | ||
|
|
20e9d69540 | ||
|
|
259de04f3e | ||
|
|
94c11062f0 | ||
|
|
1bee7ea551 | ||
|
|
2ef267a897 | ||
|
|
774b5c4b47 | ||
|
|
e0ee29caf8 | ||
|
|
584d98307c | ||
| e969e5bad4 | |||
| ebb0c003e3 | |||
|
|
3c071957ce | ||
|
|
fa10ef5116 | ||
|
|
7c33409624 | ||
|
|
f6176c945b | ||
|
|
b19a2ced6f | ||
|
|
db7885086f | ||
|
|
ba99900624 | ||
|
|
4b89b50006 | ||
|
|
b6583e07c8 | ||
|
|
6d1514b2d1 | ||
|
|
d280ad1656 | ||
|
|
c903990aaf | ||
|
|
405cd271db | ||
|
|
9529ea39c4 | ||
|
|
619f08275c | ||
|
|
f875b417c8 | ||
|
|
4ee3079b9f | ||
|
|
90d834695f | ||
|
|
dc8f9061ab | ||
|
|
583ac64dfd | ||
|
|
cedb8284ba | ||
|
|
c12b98e293 | ||
|
|
9e41871b91 | ||
|
|
126e204592 | ||
|
|
b49cc0f3bd | ||
|
|
2ddb9cadd8 | ||
|
|
b91e78c921 | ||
|
|
f9f4e73747 | ||
|
|
48339384d2 | ||
|
|
357c1d4995 | ||
|
|
82e99d24d8 | ||
|
|
5c9cb6acea | ||
|
|
aaf53d1c3b | ||
|
|
c98f1917f7 | ||
|
|
7305254e11 | ||
|
|
d97df0b2d4 | ||
|
|
26ae288470 | ||
|
|
88510aa6ae | ||
|
|
cee120a6f8 | ||
|
|
69e21471fe | ||
|
|
d273569911 | ||
|
|
25071d2303 | ||
|
|
37ddfa8ed6 | ||
|
|
a8c78f0c3f | ||
|
|
2c65330837 | ||
|
|
97c7762f2b | ||
| 001140c7e1 | |||
|
|
8a0aea1c59 | ||
|
|
a005fb0a15 | ||
|
|
59877e6ae3 | ||
|
|
7d9670e973 | ||
|
|
de94447844 |
15
.claude/settings.local.json
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"permissions": {
|
||||||
|
"allow": [
|
||||||
|
"Bash(python -c \"import sys,json; d=json.load\\(sys.stdin\\); deps={**d.get\\('dependencies',{}\\), **d.get\\('devDependencies',{}\\)}; [print\\(f'{k}: {v}'\\) for k,v in deps.items\\(\\) if 'navigation' in k or 'tab' in k.lower\\(\\)]\")",
|
||||||
|
"Bash(npx tsc *)",
|
||||||
|
"Bash(echo \"EXIT: $?\")",
|
||||||
|
"Bash(npm view *)",
|
||||||
|
"Bash(npx patch-package *)",
|
||||||
|
"Bash(rm -rf node_modules/expo-router)",
|
||||||
|
"Bash(npm install *)",
|
||||||
|
"Bash(python -c \"import struct, os; paths=['D:/codes/carrot_bbs/frontend/assets/splash-icon.png','D:/codes/carrot_bbs/frontend/assets/icon.png','D:/codes/carrot_bbs/frontend/assets/android-icon-foreground.png','D:/codes/carrot_bbs/frontend/assets/android-icon-background.png','D:/codes/carrot_bbs/frontend/android/app/src/main/res/drawable-mdpi/splashscreen_logo.png','D:/codes/carrot_bbs/frontend/android/app/src/main/res/drawable-hdpi/splashscreen_logo.png','D:/codes/carrot_bbs/frontend/android/app/src/main/res/drawable-xhdpi/splashscreen_logo.png','D:/codes/carrot_bbs/frontend/android/app/src/main/res/drawable-xxhdpi/splashscreen_logo.png','D:/codes/carrot_bbs/frontend/android/app/src/main/res/drawable-xxxhdpi/splashscreen_logo.png'];\\\\nfor p in paths:\\\\n try:\\\\n with open\\(p,'rb'\\) as f: data=f.read\\(33\\)\\\\n sig=data[:8]; w=h=None\\\\n if sig==b'\\\\\\\\x89PNG\\\\\\\\r\\\\\\\\n\\\\\\\\x1a\\\\\\\\n': w,h=struct.unpack\\('>II', data[16:24]\\)\\\\n print\\(f'{p} | exists={os.path.exists\\(p\\)} | bytes={os.path.getsize\\(p\\)} | png={sig==b\"\\\\\\\\x89PNG\\\\\\\\r\\\\\\\\n\\\\\\\\x1a\\\\\\\\n\"} | size={w}x{h}'\\)\\\\n except Exception as e:\\\\n print\\(f'{p} | ERROR {e}'\\)\")",
|
||||||
|
"Bash(python -c 'import struct, os; paths=[\"D:/codes/carrot_bbs/frontend/assets/splash-icon.png\",\"D:/codes/carrot_bbs/frontend/assets/icon.png\",\"D:/codes/carrot_bbs/frontend/assets/android-icon-foreground.png\",\"D:/codes/carrot_bbs/frontend/assets/android-icon-background.png\",\"D:/codes/carrot_bbs/frontend/android/app/src/main/res/drawable-mdpi/splashscreen_logo.png\",\"D:/codes/carrot_bbs/frontend/android/app/src/main/res/drawable-hdpi/splashscreen_logo.png\",\"D:/codes/carrot_bbs/frontend/android/app/src/main/res/drawable-xhdpi/splashscreen_logo.png\",\"D:/codes/carrot_bbs/frontend/android/app/src/main/res/drawable-xxhdpi/splashscreen_logo.png\",\"D:/codes/carrot_bbs/frontend/android/app/src/main/res/drawable-xxxhdpi/splashscreen_logo.png\"]; sig_png=b\"\\\\x89PNG\\\\r\\\\n\\\\x1a\\\\n\"; [print\\(f\"{p} | bytes={os.path.getsize\\(p\\)} | png={open\\(p,\"rb\"\\).read\\(8\\)==sig_png} | size={struct.unpack\\(\">II\", open\\(p,\"rb\"\\).read\\(24\\)[16:24]\\) if open\\(p,\"rb\"\\).read\\(8\\)==sig_png else None}\"\\) for p in paths]')"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,10 +1,40 @@
|
|||||||
|
# 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__
|
||||||
|
|||||||
@@ -21,16 +21,18 @@ 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-android:
|
ota:
|
||||||
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
|
||||||
@@ -39,7 +41,6 @@ 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
|
||||||
@@ -47,63 +48,61 @@ jobs:
|
|||||||
- name: Resolve runtime version
|
- name: Resolve runtime version
|
||||||
id: runtime
|
id: runtime
|
||||||
run: |
|
run: |
|
||||||
RUNTIME_VERSION="$(node -p "require('./app.json').expo.version")"
|
RUNTIME_VERSION="$(node -p "require('./app.json').expo.runtimeVersion")"
|
||||||
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 Android update bundle
|
- name: Export update bundle
|
||||||
run: |
|
run: |
|
||||||
rm -rf dist-android dist-android-update.zip
|
rm -rf dist-${{ matrix.platform }} dist-${{ matrix.platform }}-update.zip
|
||||||
npx expo export --platform android --output-dir dist-android
|
npx expo export --platform ${{ matrix.platform }} --output-dir dist-${{ matrix.platform }}
|
||||||
npx expo config --type public --json > dist-android/expoConfig.json
|
npx expo config --type public --json > dist-${{ matrix.platform }}/expoConfig.json
|
||||||
|
|
||||||
- name: Archive Android update bundle
|
- name: Archive update bundle
|
||||||
run: |
|
run: |
|
||||||
python - <<'PY'
|
python -c "
|
||||||
import os
|
import os, zipfile
|
||||||
import zipfile
|
p = '${{ matrix.platform }}'
|
||||||
|
dist = f'dist-{p}'
|
||||||
with zipfile.ZipFile('dist-android-update.zip', 'w', zipfile.ZIP_DEFLATED) as zf:
|
with zipfile.ZipFile(f'dist-{p}-update.zip', 'w', zipfile.ZIP_DEFLATED) as zf:
|
||||||
for root, _, files in os.walk('dist-android'):
|
for root, _, files in os.walk(dist):
|
||||||
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-android')
|
arc = os.path.relpath(src, dist)
|
||||||
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=${OTA_PLATFORM}" \
|
curl -fSs -X POST "${OTA_PUBLISH_URL}?runtimeVersion=${{ steps.runtime.outputs.runtime_version }}&platform=${{ matrix.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-android-update.zip"
|
--data-binary @"dist-${{ matrix.platform }}-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: ${OTA_PLATFORM}" \
|
-H "expo-platform: ${{ matrix.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-android/metadata.json')); print(m['fileMetadata']['android']['bundle'].split('/')[-1])")"
|
LOCAL="$(python -c "import json; m=json.load(open('dist-${{ matrix.platform }}/metadata.json')); print(m['fileMetadata']['${{ matrix.platform }}']['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: ubuntu-latest
|
runs-on: android-builder
|
||||||
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=true -Dorg.gradle.workers.max=4 -Xmx8g -XX:MaxMetaspaceSize=1g -XX:+UseG1GC -XX:SoftRefLRUPolicyMSPerMB=0 -XX:ReservedCodeCacheSize=512m"
|
GRADLE_OPTS: "-Dorg.gradle.daemon=false -Dorg.gradle.parallel=false -Dorg.gradle.workers.max=1"
|
||||||
_JAVA_OPTIONS: "-Xmx8g -XX:MaxMetaspaceSize=1g -XX:+UseG1GC -XX:SoftRefLRUPolicyMSPerMB=0 -XX:ReservedCodeCacheSize=512m"
|
NODE_OPTIONS: "--max-old-space-size=2048"
|
||||||
NODE_OPTIONS: "--max-old-space-size=8192"
|
|
||||||
NODE_ENV: "production"
|
NODE_ENV: "production"
|
||||||
NDK_NUM_JOBS: "4"
|
GRADLE_USER_HOME: /root/.gradle
|
||||||
CMAKE_BUILD_PARALLEL_LEVEL: "4"
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
steps:
|
steps:
|
||||||
@@ -120,7 +119,6 @@ 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
|
||||||
@@ -128,21 +126,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 Aliyun Maven mirror
|
- name: Configure Gradle with signing
|
||||||
run: |
|
run: |
|
||||||
cd android
|
cd android
|
||||||
|
|
||||||
# Update settings.gradle with Aliyun Maven mirror
|
# Decode Android signing keystore
|
||||||
|
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 {
|
||||||
@@ -165,6 +163,16 @@ 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 ->
|
||||||
@@ -176,7 +184,7 @@ jobs:
|
|||||||
}
|
}
|
||||||
expoAutolinking.useExpoModules()
|
expoAutolinking.useExpoModules()
|
||||||
|
|
||||||
rootProject.name = '萝卜社区'
|
rootProject.name = '威友'
|
||||||
|
|
||||||
expoAutolinking.useExpoVersionCatalog()
|
expoAutolinking.useExpoVersionCatalog()
|
||||||
|
|
||||||
@@ -184,21 +192,22 @@ jobs:
|
|||||||
includeBuild(expoAutolinking.reactNativeGradlePlugin)
|
includeBuild(expoAutolinking.reactNativeGradlePlugin)
|
||||||
SETTINGS_EOF
|
SETTINGS_EOF
|
||||||
|
|
||||||
# Update build.gradle with Aliyun Maven mirror
|
# Update build.gradle
|
||||||
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.android.tools.build:gradle')
|
classpath('com.huawei.agconnect:agcp:1.9.1.301') {
|
||||||
|
exclude group: 'com.huawei.agconnect', module: 'agconnect-apms-plugin'
|
||||||
|
}
|
||||||
|
classpath('com.google.gms:google-services:4.4.4')
|
||||||
|
classpath('com.android.tools.build:gradle:8.12.0')
|
||||||
classpath('com.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')
|
||||||
}
|
}
|
||||||
@@ -206,12 +215,10 @@ 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' }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -219,13 +226,14 @@ jobs:
|
|||||||
apply plugin: "com.facebook.react.rootproject"
|
apply plugin: "com.facebook.react.rootproject"
|
||||||
BUILD_EOF
|
BUILD_EOF
|
||||||
|
|
||||||
# Update gradle.properties
|
# Update gradle.properties (without secrets for better cache hit rate)
|
||||||
cat > gradle.properties << 'PROPS_EOF'
|
cat > gradle.properties << 'PROPS_EOF'
|
||||||
org.gradle.daemon=false
|
org.gradle.daemon=false
|
||||||
org.gradle.parallel=true
|
org.gradle.parallel=false
|
||||||
org.gradle.configureondemand=true
|
org.gradle.configureondemand=false
|
||||||
org.gradle.workers.max=4
|
org.gradle.workers.max=1
|
||||||
org.gradle.jvmargs=-Xmx8g -XX:MaxMetaspaceSize=1g -XX:+UseG1GC -XX:SoftRefLRUPolicyMSPerMB=0 -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError
|
org.gradle.jvmargs=-Xmx4g -XX:MaxMetaspaceSize=1g -XX:+UseG1GC -XX:ReservedCodeCacheSize=256m -XX:+HeapDumpOnOutOfMemoryError
|
||||||
|
kotlin.daemon.jvmargs=-Xmx4g -XX:MaxMetaspaceSize=1g -XX:+UseG1GC -XX:ReservedCodeCacheSize=256m
|
||||||
android.enableJetifier=false
|
android.enableJetifier=false
|
||||||
android.useAndroidX=true
|
android.useAndroidX=true
|
||||||
hermesEnabled=true
|
hermesEnabled=true
|
||||||
@@ -235,21 +243,72 @@ 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
|
||||||
./gradlew :app:assembleRelease -PreactNativeArchitectures=arm64-v8a --max-workers=4 --parallel
|
taskset -c 0-7 ./gradlew :app:assembleRelease -PreactNativeArchitectures=arm64-v8a --max-workers=1 -Dkotlin.daemon.jvm.options="-Xmx2g,XX:MaxMetaspaceSize=1g"
|
||||||
|
|
||||||
- name: Upload APK artifact
|
- name: Upload APK artifact
|
||||||
uses: actions/upload-artifact@v3
|
uses: actions/upload-artifact@v3
|
||||||
with:
|
with:
|
||||||
name: carrot-bbs-android-release-apk
|
name: withyou-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
@@ -51,3 +51,6 @@ dist-android-update.zip
|
|||||||
# Backend
|
# Backend
|
||||||
backend/data/
|
backend/data/
|
||||||
backend/logs/
|
backend/logs/
|
||||||
|
|
||||||
|
doc/
|
||||||
|
docs/
|
||||||
|
|||||||
10
.idea/.gitignore
generated
vendored
@@ -1,10 +0,0 @@
|
|||||||
# 默认忽略的文件
|
|
||||||
/shelf/
|
|
||||||
/workspace.xml
|
|
||||||
# 基于编辑器的 HTTP 客户端请求
|
|
||||||
/httpRequests/
|
|
||||||
# Datasource local storage ignored files
|
|
||||||
/dataSources/
|
|
||||||
/dataSources.local.xml
|
|
||||||
# Screenshots
|
|
||||||
screenshots/
|
|
||||||
9
.idea/frontend.iml
generated
@@ -1,9 +0,0 @@
|
|||||||
<?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
@@ -1,448 +0,0 @@
|
|||||||
<?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
@@ -1,7 +0,0 @@
|
|||||||
<?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
@@ -1,8 +0,0 @@
|
|||||||
<?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
@@ -1,6 +0,0 @@
|
|||||||
<?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
Normal file
@@ -0,0 +1,376 @@
|
|||||||
|
{
|
||||||
|
"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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,231 +0,0 @@
|
|||||||
# 架构修复方案文档
|
|
||||||
|
|
||||||
## 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错误
|
|
||||||
- [ ] 性能不劣化(启动时间、内存占用)
|
|
||||||
136
App.tsx
@@ -1,137 +1,7 @@
|
|||||||
/**
|
/**
|
||||||
* 萝卜BBS - 主应用入口
|
* 应用入口已由 Expo Router 接管(package.json main: expo-router/entry)。
|
||||||
* 配置导航、主题、状态管理等
|
* 全局 Provider 与根布局见 app/_layout.tsx。
|
||||||
*/
|
*/
|
||||||
|
|
||||||
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() {
|
export default function App() {
|
||||||
const appState = useRef<AppStateStatus>(AppState.currentState);
|
return null;
|
||||||
const notificationResponseListener = useRef<Notifications.EventSubscription | null>(null);
|
|
||||||
|
|
||||||
// 系统通知功能初始化
|
|
||||||
useEffect(() => {
|
|
||||||
const initNotifications = async () => {
|
|
||||||
const { systemNotificationService } = await import('./src/services/systemNotificationService');
|
|
||||||
await systemNotificationService.initialize();
|
|
||||||
|
|
||||||
// 初始化后台保活服务
|
|
||||||
const { initBackgroundService } = await import('./src/services/backgroundService');
|
|
||||||
await initBackgroundService();
|
|
||||||
|
|
||||||
// 监听 App 状态变化
|
|
||||||
const subscription = AppState.addEventListener('change', (nextAppState) => {
|
|
||||||
if (
|
|
||||||
appState.current.match(/inactive|background/) &&
|
|
||||||
nextAppState === 'active'
|
|
||||||
) {
|
|
||||||
systemNotificationService.clearBadge();
|
|
||||||
}
|
|
||||||
appState.current = nextAppState;
|
|
||||||
});
|
|
||||||
|
|
||||||
// 监听通知点击响应
|
|
||||||
notificationResponseListener.current = Notifications.addNotificationResponseReceivedListener(
|
|
||||||
(response) => {
|
|
||||||
const data = response.notification.request.content.data;
|
|
||||||
void data;
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
// 监听通知到达(用于前台显示时额外处理)
|
|
||||||
const notificationReceivedSubscription = Notifications.addNotificationReceivedListener(
|
|
||||||
(notification) => {
|
|
||||||
void notification;
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
subscription.remove();
|
|
||||||
notificationResponseListener.current?.remove();
|
|
||||||
notificationReceivedSubscription.remove();
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
initNotifications();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<GestureHandlerRootView style={{ flex: 1 }}>
|
|
||||||
<SafeAreaProvider>
|
|
||||||
<PaperProvider theme={paperTheme}>
|
|
||||||
<QueryClientProvider client={queryClient}>
|
|
||||||
<StatusBar style="light" />
|
|
||||||
<MainNavigator />
|
|
||||||
<AppPromptBar />
|
|
||||||
<AppDialogHost />
|
|
||||||
</QueryClientProvider>
|
|
||||||
</PaperProvider>
|
|
||||||
</SafeAreaProvider>
|
|
||||||
</GestureHandlerRootView>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
FROM node:20-alpine AS builder
|
FROM node:25-alpine AS builder
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
@@ -6,10 +6,11 @@ COPY package.json package-lock.json ./
|
|||||||
RUN npm ci
|
RUN npm ci
|
||||||
|
|
||||||
COPY . .
|
COPY . .
|
||||||
RUN npx expo export --platform web --output-dir dist-web
|
RUN node /app/node_modules/.bin/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
|
||||||
|
|||||||
@@ -1,10 +1,27 @@
|
|||||||
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://bbs.littlelan.cn/api/v1';
|
const releaseApiBaseUrl = 'https://withyou.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) {
|
||||||
@@ -23,27 +40,72 @@ 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: {
|
// runtimeVersion 用 build number(commit count + 偏移):单调递增、纯数字、与 version (语义版本) 解耦
|
||||||
policy: 'appVersion',
|
// 字符串形式,等价于 policy: 'custom'
|
||||||
},
|
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: expo.ios,
|
ios: {
|
||||||
|
...expo.ios,
|
||||||
|
buildNumber: buildNumber,
|
||||||
|
},
|
||||||
android: {
|
android: {
|
||||||
...expo.android,
|
...expo.android,
|
||||||
package: 'skin.carrot.bbs',
|
package: 'cn.qczlit.withyou',
|
||||||
|
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
@@ -1,11 +1,12 @@
|
|||||||
{
|
{
|
||||||
"expo": {
|
"expo": {
|
||||||
"name": "萝卜社区",
|
"name": "威友",
|
||||||
"slug": "qojo",
|
"slug": "qojo",
|
||||||
"version": "1.0.10",
|
"version": "1.0.1",
|
||||||
"orientation": "default",
|
"orientation": "default",
|
||||||
"icon": "./assets/icon.png",
|
"icon": "./assets/icon.png",
|
||||||
"userInterfaceStyle": "light",
|
"userInterfaceStyle": "automatic",
|
||||||
|
"scheme": "withyou",
|
||||||
"splash": {
|
"splash": {
|
||||||
"image": "./assets/splash-icon.png",
|
"image": "./assets/splash-icon.png",
|
||||||
"resizeMode": "contain",
|
"resizeMode": "contain",
|
||||||
@@ -14,28 +15,31 @@
|
|||||||
"ios": {
|
"ios": {
|
||||||
"supportsTablet": true,
|
"supportsTablet": true,
|
||||||
"infoPlist": {
|
"infoPlist": {
|
||||||
|
"NSMicrophoneUsageDescription": "允许威友访问您的麦克风以进行语音通话",
|
||||||
|
"NSCameraUsageDescription": "允许威友访问您的相机以进行视频通话",
|
||||||
"UIBackgroundModes": [
|
"UIBackgroundModes": [
|
||||||
"fetch",
|
"fetch",
|
||||||
"remote-notification",
|
"remote-notification",
|
||||||
"fetch",
|
"audio"
|
||||||
"remote-notification"
|
|
||||||
],
|
],
|
||||||
"ITSAppUsesNonExemptEncryption": false
|
"ITSAppUsesNonExemptEncryption": false
|
||||||
},
|
},
|
||||||
"bundleIdentifier": "skin.carrot.bbs"
|
"bundleIdentifier": "cn.qczlit.weiyou"
|
||||||
},
|
},
|
||||||
"android": {
|
"android": {
|
||||||
|
"googleServicesFile": "./google-services.json",
|
||||||
"adaptiveIcon": {
|
"adaptiveIcon": {
|
||||||
"backgroundColor": "#E6F4FE",
|
"backgroundColor": "#0570F9",
|
||||||
"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": "skin.carrot.bbs",
|
"package": "cn.qczlit.withyou",
|
||||||
"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",
|
||||||
@@ -48,16 +52,32 @@
|
|||||||
"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",
|
||||||
{
|
{
|
||||||
@@ -69,30 +89,31 @@
|
|||||||
],
|
],
|
||||||
"expo-sqlite",
|
"expo-sqlite",
|
||||||
[
|
[
|
||||||
"expo-notifications",
|
"expo-camera",
|
||||||
{
|
{
|
||||||
"sounds": []
|
"cameraPermission": "允许威友访问您的相机以扫描二维码"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
"expo-background-fetch",
|
"expo-background-task",
|
||||||
{
|
{
|
||||||
"minimumInterval": 900
|
"minimumInterval": 15
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
"./plugins/withRemoveAutoStart",
|
||||||
[
|
[
|
||||||
"expo-media-library",
|
"expo-media-library",
|
||||||
{
|
{
|
||||||
"photosPermission": "允许萝卜社区访问您的照片以发布内容",
|
"photosPermission": "允许威友访问您的照片以发布内容",
|
||||||
"savePhotosPermission": "允许萝卜社区保存照片到您的相册",
|
"savePhotosPermission": "允许威友保存照片到您的相册",
|
||||||
"isAccessMediaLocationEnabled": true
|
"isAccessMediaLocationEnabled": false
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
"expo-image-picker",
|
"expo-image-picker",
|
||||||
{
|
{
|
||||||
"photosPermission": "允许萝卜社区访问您的照片以选择图片",
|
"photosPermission": "允许威友访问您的照片以选择图片",
|
||||||
"cameraPermission": "允许萝卜社区访问您的相机以拍摄图片",
|
"cameraPermission": "允许威友访问您的相机以拍摄图片",
|
||||||
"colors": {
|
"colors": {
|
||||||
"cropToolbarColor": "#111827",
|
"cropToolbarColor": "#111827",
|
||||||
"cropToolbarIconColor": "#ffffff",
|
"cropToolbarIconColor": "#ffffff",
|
||||||
@@ -117,13 +138,44 @@
|
|||||||
"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"
|
||||||
}
|
}
|
||||||
|
|||||||
176
app/(app)/(tabs)/_layout.tsx
Normal file
@@ -0,0 +1,176 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
5
app/(app)/(tabs)/apps/_layout.tsx
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { Stack } from 'expo-router';
|
||||||
|
|
||||||
|
export default function AppsStackLayout() {
|
||||||
|
return <Stack screenOptions={{ headerShown: false }} />;
|
||||||
|
}
|
||||||
5
app/(app)/(tabs)/apps/index.tsx
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { AppsScreen } from '../../../../src/screens/apps';
|
||||||
|
|
||||||
|
export default function AppsRoute() {
|
||||||
|
return <AppsScreen />;
|
||||||
|
}
|
||||||
11
app/(app)/(tabs)/apps/materials/_layout.tsx
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
5
app/(app)/(tabs)/apps/materials/detail.tsx
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { MaterialDetailScreen } from '../../../../../src/screens/material';
|
||||||
|
|
||||||
|
export default function MaterialDetailRoute() {
|
||||||
|
return <MaterialDetailScreen />;
|
||||||
|
}
|
||||||
5
app/(app)/(tabs)/apps/materials/index.tsx
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { MaterialsScreen } from '../../../../../src/screens/material';
|
||||||
|
|
||||||
|
export default function MaterialsRoute() {
|
||||||
|
return <MaterialsScreen />;
|
||||||
|
}
|
||||||
5
app/(app)/(tabs)/apps/materials/subject.tsx
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { SubjectMaterialsScreen } from '../../../../../src/screens/material';
|
||||||
|
|
||||||
|
export default function SubjectMaterialsRoute() {
|
||||||
|
return <SubjectMaterialsScreen />;
|
||||||
|
}
|
||||||
17
app/(app)/(tabs)/apps/schedule/_layout.tsx
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
5
app/(app)/(tabs)/apps/schedule/course.tsx
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { CourseDetailScreen } from '../../../../../src/screens/schedule';
|
||||||
|
|
||||||
|
export default function CourseDetailRoute() {
|
||||||
|
return <CourseDetailScreen />;
|
||||||
|
}
|
||||||
5
app/(app)/(tabs)/apps/schedule/index.tsx
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { ScheduleScreen } from '../../../../../src/screens/schedule';
|
||||||
|
|
||||||
|
export default function ScheduleRoute() {
|
||||||
|
return <ScheduleScreen />;
|
||||||
|
}
|
||||||
5
app/(app)/(tabs)/home/_layout.tsx
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { Stack } from 'expo-router';
|
||||||
|
|
||||||
|
export default function HomeStackLayout() {
|
||||||
|
return <Stack screenOptions={{ headerShown: false }} />;
|
||||||
|
}
|
||||||
5
app/(app)/(tabs)/home/index.tsx
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { HomeScreen } from '../../../../src/screens/home';
|
||||||
|
|
||||||
|
export default function HomeRoute() {
|
||||||
|
return <HomeScreen />;
|
||||||
|
}
|
||||||
5
app/(app)/(tabs)/home/search.tsx
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { SearchScreen } from '../../../../src/screens/home';
|
||||||
|
|
||||||
|
export default function HomeSearchRoute() {
|
||||||
|
return <SearchScreen />;
|
||||||
|
}
|
||||||
5
app/(app)/(tabs)/messages/_layout.tsx
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { Stack } from 'expo-router';
|
||||||
|
|
||||||
|
export default function MessagesStackLayout() {
|
||||||
|
return <Stack screenOptions={{ headerShown: false }} />;
|
||||||
|
}
|
||||||
5
app/(app)/(tabs)/messages/index.tsx
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { MessageListScreen } from '../../../../src/screens/message';
|
||||||
|
|
||||||
|
export default function MessagesRoute() {
|
||||||
|
return <MessageListScreen />;
|
||||||
|
}
|
||||||
5
app/(app)/(tabs)/messages/notifications.tsx
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { NotificationsScreen } from '../../../../src/screens/message';
|
||||||
|
|
||||||
|
export default function NotificationsRoute() {
|
||||||
|
return <NotificationsScreen />;
|
||||||
|
}
|
||||||
26
app/(app)/(tabs)/profile/_layout.tsx
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
5
app/(app)/(tabs)/profile/about.tsx
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { AboutScreen } from '../../../../src/screens/profile/AboutScreen';
|
||||||
|
|
||||||
|
export default function AboutRoute() {
|
||||||
|
return <AboutScreen />;
|
||||||
|
}
|
||||||
5
app/(app)/(tabs)/profile/account-deletion.tsx
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { AccountDeletionScreen } from '../../../../src/screens/profile';
|
||||||
|
|
||||||
|
export default function AccountDeletionRoute() {
|
||||||
|
return <AccountDeletionScreen />;
|
||||||
|
}
|
||||||
5
app/(app)/(tabs)/profile/account-security.tsx
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { AccountSecurityScreen } from '../../../../src/screens/profile';
|
||||||
|
|
||||||
|
export default function AccountSecurityRoute() {
|
||||||
|
return <AccountSecurityScreen />;
|
||||||
|
}
|
||||||
5
app/(app)/(tabs)/profile/blocked-users.tsx
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { BlockedUsersScreen } from '../../../../src/screens/profile';
|
||||||
|
|
||||||
|
export default function BlockedUsersRoute() {
|
||||||
|
return <BlockedUsersScreen />;
|
||||||
|
}
|
||||||
5
app/(app)/(tabs)/profile/bookmarks.tsx
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { ProfileScreen } from '../../../../src/screens/profile';
|
||||||
|
|
||||||
|
export default function BookmarksRoute() {
|
||||||
|
return <ProfileScreen />;
|
||||||
|
}
|
||||||
5
app/(app)/(tabs)/profile/chat-settings.tsx
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { ChatSettingsScreen } from '../../../../src/screens/profile/ChatSettingsScreen';
|
||||||
|
|
||||||
|
export default function ChatSettingsRoute() {
|
||||||
|
return <ChatSettingsScreen />;
|
||||||
|
}
|
||||||
5
app/(app)/(tabs)/profile/data-storage.tsx
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { DataStorageScreen } from '../../../../src/screens/profile';
|
||||||
|
|
||||||
|
export default function DataStorageRoute() {
|
||||||
|
return <DataStorageScreen />;
|
||||||
|
}
|
||||||
5
app/(app)/(tabs)/profile/edit-profile.tsx
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { EditProfileScreen } from '../../../../src/screens/profile';
|
||||||
|
|
||||||
|
export default function EditProfileRoute() {
|
||||||
|
return <EditProfileScreen />;
|
||||||
|
}
|
||||||
5
app/(app)/(tabs)/profile/index.tsx
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { ProfileScreen } from '../../../../src/screens/profile';
|
||||||
|
|
||||||
|
export default function ProfileRoute() {
|
||||||
|
return <ProfileScreen />;
|
||||||
|
}
|
||||||
5
app/(app)/(tabs)/profile/my-posts.tsx
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { ProfileScreen } from '../../../../src/screens/profile';
|
||||||
|
|
||||||
|
export default function MyPostsRoute() {
|
||||||
|
return <ProfileScreen />;
|
||||||
|
}
|
||||||
5
app/(app)/(tabs)/profile/notification-settings.tsx
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { NotificationSettingsScreen } from '../../../../src/screens/profile';
|
||||||
|
|
||||||
|
export default function NotificationSettingsRoute() {
|
||||||
|
return <NotificationSettingsScreen />;
|
||||||
|
}
|
||||||
5
app/(app)/(tabs)/profile/privacy-settings.tsx
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { PrivacySettingsScreen } from '../../../../src/screens/profile';
|
||||||
|
|
||||||
|
export default function PrivacySettingsRoute() {
|
||||||
|
return <PrivacySettingsScreen />;
|
||||||
|
}
|
||||||
5
app/(app)/(tabs)/profile/settings.tsx
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { SettingsScreen } from '../../../../src/screens/profile';
|
||||||
|
|
||||||
|
export default function SettingsRoute() {
|
||||||
|
return <SettingsScreen />;
|
||||||
|
}
|
||||||
5
app/(app)/(tabs)/profile/verification.tsx
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { VerificationSettingsScreen } from '@/screens/profile/VerificationSettingsScreen';
|
||||||
|
|
||||||
|
export default function VerificationSettingsPage() {
|
||||||
|
return <VerificationSettingsScreen />;
|
||||||
|
}
|
||||||
54
app/(app)/_layout.tsx
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
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()} />;
|
||||||
|
}
|
||||||
56
app/(app)/_layout.web.tsx
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
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()} />;
|
||||||
|
}
|
||||||
5
app/(app)/chat/[conversationId].tsx
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { ChatScreen } from '../../../src/screens/message';
|
||||||
|
|
||||||
|
export default function ChatRoute() {
|
||||||
|
return <ChatScreen />;
|
||||||
|
}
|
||||||
5
app/(app)/chat/message-search.tsx
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { MessageSearchScreen } from '../../../src/screens/message';
|
||||||
|
|
||||||
|
export default function MessageSearchRoute() {
|
||||||
|
return <MessageSearchScreen />;
|
||||||
|
}
|
||||||
5
app/(app)/chat/private-info.tsx
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { PrivateChatInfoScreen } from '../../../src/screens/message';
|
||||||
|
|
||||||
|
export default function PrivateChatInfoRoute() {
|
||||||
|
return <PrivateChatInfoScreen />;
|
||||||
|
}
|
||||||
5
app/(app)/group/[groupId]/index.tsx
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { GroupInfoScreen } from '../../../../src/screens/message';
|
||||||
|
|
||||||
|
export default function GroupInfoRoute() {
|
||||||
|
return <GroupInfoScreen />;
|
||||||
|
}
|
||||||
5
app/(app)/group/[groupId]/members.tsx
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { GroupMembersScreen } from '../../../../src/screens/message';
|
||||||
|
|
||||||
|
export default function GroupMembersRoute() {
|
||||||
|
return <GroupMembersScreen />;
|
||||||
|
}
|
||||||
5
app/(app)/group/create.tsx
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { CreateGroupScreen } from '../../../src/screens/message';
|
||||||
|
|
||||||
|
export default function CreateGroupRoute() {
|
||||||
|
return <CreateGroupScreen />;
|
||||||
|
}
|
||||||
5
app/(app)/group/invite.tsx
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import GroupInviteDetailScreen from '../../../src/screens/message/GroupInviteDetailScreen';
|
||||||
|
|
||||||
|
export default function GroupInviteRoute() {
|
||||||
|
return <GroupInviteDetailScreen />;
|
||||||
|
}
|
||||||
5
app/(app)/group/join.tsx
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { JoinGroupScreen } from '../../../src/screens/message';
|
||||||
|
|
||||||
|
export default function JoinGroupRoute() {
|
||||||
|
return <JoinGroupScreen />;
|
||||||
|
}
|
||||||
5
app/(app)/group/request.tsx
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import GroupRequestDetailScreen from '../../../src/screens/message/GroupRequestDetailScreen';
|
||||||
|
|
||||||
|
export default function GroupRequestRoute() {
|
||||||
|
return <GroupRequestDetailScreen />;
|
||||||
|
}
|
||||||
5
app/(app)/posts/create.tsx
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { CreatePostScreen } from '../../../src/screens/create';
|
||||||
|
|
||||||
|
export default function CreatePostRoute() {
|
||||||
|
return <CreatePostScreen />;
|
||||||
|
}
|
||||||
5
app/(app)/qrcode/login/[sessionId].tsx
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { QRCodeConfirmScreen } from '../../../../src/screens/auth/QRCodeConfirmScreen';
|
||||||
|
|
||||||
|
export default function QrLoginConfirmRoute() {
|
||||||
|
return <QRCodeConfirmScreen />;
|
||||||
|
}
|
||||||
5
app/(app)/users/[userId]/[type].tsx
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import FollowListScreen from '../../../../src/screens/profile/FollowListScreen';
|
||||||
|
|
||||||
|
export default function FollowListRoute() {
|
||||||
|
return <FollowListScreen />;
|
||||||
|
}
|
||||||
5
app/(auth)/_layout.tsx
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { Stack } from 'expo-router';
|
||||||
|
|
||||||
|
export default function AuthLayout() {
|
||||||
|
return <Stack screenOptions={{ headerShown: false }} />;
|
||||||
|
}
|
||||||
5
app/(auth)/forgot-password.tsx
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { ForgotPasswordScreen } from '../../src/screens/auth';
|
||||||
|
|
||||||
|
export default function ForgotPasswordRoute() {
|
||||||
|
return <ForgotPasswordScreen />;
|
||||||
|
}
|
||||||
5
app/(auth)/login.tsx
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { LoginScreen } from '../../src/screens/auth';
|
||||||
|
|
||||||
|
export default function LoginRoute() {
|
||||||
|
return <LoginScreen />;
|
||||||
|
}
|
||||||
5
app/(auth)/register.tsx
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { RegisterScreen } from '../../src/screens/auth';
|
||||||
|
|
||||||
|
export default function RegisterRoute() {
|
||||||
|
return <RegisterScreen />;
|
||||||
|
}
|
||||||
5
app/(auth)/verification-form.tsx
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { VerificationFormScreen } from '@/screens/auth/VerificationFormScreen';
|
||||||
|
|
||||||
|
export default function VerificationFormPage() {
|
||||||
|
return <VerificationFormScreen />;
|
||||||
|
}
|
||||||
5
app/(auth)/verification-guide.tsx
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { VerificationGuideScreen } from '@/screens/auth/VerificationGuideScreen';
|
||||||
|
|
||||||
|
export default function VerificationGuidePage() {
|
||||||
|
return <VerificationGuideScreen />;
|
||||||
|
}
|
||||||
297
app/_layout.tsx
Normal file
@@ -0,0 +1,297 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
12
app/index.tsx
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
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()} />;
|
||||||
|
}
|
||||||
5
app/post/[postId].tsx
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { PostDetailScreen } from '../../src/screens/home';
|
||||||
|
|
||||||
|
export default function PostDetailRoute() {
|
||||||
|
return <PostDetailScreen />;
|
||||||
|
}
|
||||||
5
app/privacy.tsx
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { PrivacyPolicyScreen } from '../src/screens/profile/PrivacyPolicyScreen';
|
||||||
|
|
||||||
|
export default function PrivacyRoute() {
|
||||||
|
return <PrivacyPolicyScreen />;
|
||||||
|
}
|
||||||
5
app/terms.tsx
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { TermsOfServiceScreen } from '../src/screens/profile/TermsOfServiceScreen';
|
||||||
|
|
||||||
|
export default function TermsRoute() {
|
||||||
|
return <TermsOfServiceScreen />;
|
||||||
|
}
|
||||||
7
app/trade/[tradeId].tsx
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
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} />;
|
||||||
|
}
|
||||||
46
app/trade/edit/[tradeId].tsx
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
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}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
5
app/user/[userId].tsx
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { UserScreen } from '../../src/screens/profile';
|
||||||
|
|
||||||
|
export default function UserProfileRoute() {
|
||||||
|
return <UserScreen />;
|
||||||
|
}
|
||||||
5
app/welcome.tsx
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { WelcomeScreen } from '../src/screens/auth';
|
||||||
|
|
||||||
|
export default function WelcomeRoute() {
|
||||||
|
return <WelcomeScreen />;
|
||||||
|
}
|
||||||
|
Before Width: | Height: | Size: 7.0 KiB After Width: | Height: | Size: 7.0 KiB |
|
Before Width: | Height: | Size: 29 KiB After Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 66 KiB After Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 4.5 KiB After Width: | Height: | Size: 4.7 KiB |
BIN
assets/icon.png
|
Before Width: | Height: | Size: 895 KiB After Width: | Height: | Size: 385 KiB |
|
Before Width: | Height: | Size: 895 KiB After Width: | Height: | Size: 385 KiB |
3
eas.json
@@ -27,6 +27,9 @@
|
|||||||
},
|
},
|
||||||
"production": {
|
"production": {
|
||||||
"autoIncrement": true,
|
"autoIncrement": true,
|
||||||
|
"android": {
|
||||||
|
"buildType": "apk"
|
||||||
|
},
|
||||||
"ios": {
|
"ios": {
|
||||||
"resourceClass": "m-medium"
|
"resourceClass": "m-medium"
|
||||||
}
|
}
|
||||||
|
|||||||
23
google-services.json
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"project_info": {
|
||||||
|
"project_number": "000000000000",
|
||||||
|
"project_id": "withyou-dummy",
|
||||||
|
"storage_bucket": "withyou-dummy.appspot.com"
|
||||||
|
},
|
||||||
|
"client": [
|
||||||
|
{
|
||||||
|
"client_info": {
|
||||||
|
"mobilesdk_app_id": "1:000000000000:android:0000000000000000",
|
||||||
|
"android_client_info": {
|
||||||
|
"package_name": "cn.qczlit.withyou"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"api_key": [
|
||||||
|
{
|
||||||
|
"current_key": "AIzaSyDummyKeyForFirebaseInitOnly"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"configuration_version": "1"
|
||||||
|
}
|
||||||
9
index.ts
@@ -1,8 +1 @@
|
|||||||
import { registerRootComponent } from 'expo';
|
import 'expo-router/entry';
|
||||||
|
|
||||||
import App from './App';
|
|
||||||
|
|
||||||
// registerRootComponent calls AppRegistry.registerComponent('main', () => App);
|
|
||||||
// It also ensures that whether you load the app in Expo Go or in a native build,
|
|
||||||
// the environment is set up appropriately
|
|
||||||
registerRootComponent(App);
|
|
||||||
|
|||||||
@@ -1,11 +1,72 @@
|
|||||||
/** @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.
|
||||||
|
|||||||
19
nginx.conf
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
8485
package-lock.json
generated
117
package.json
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "carrot_bbs",
|
"name": "with_you",
|
||||||
"version": "1.0.0",
|
"version": "1.0.1",
|
||||||
"main": "index.ts",
|
"main": "expo-router/entry",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "expo start",
|
"start": "expo start",
|
||||||
"android": "expo run:android",
|
"android": "expo run:android",
|
||||||
@@ -14,55 +14,78 @@
|
|||||||
"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",
|
||||||
"@react-native-async-storage/async-storage": "^2.2.0",
|
"@livekit/react-native": "^2.11.0",
|
||||||
"@react-navigation/bottom-tabs": "^7.15.2",
|
"@livekit/react-native-webrtc": "^144.1.0",
|
||||||
"@react-navigation/material-top-tabs": "^7.4.16",
|
"@react-native-async-storage/async-storage": "^3.1.1",
|
||||||
"@react-navigation/native": "^7.1.31",
|
"@shopify/flash-list": "^2.3.1",
|
||||||
"@react-navigation/native-stack": "^7.14.2",
|
"@tanstack/react-query": "^5.100.14",
|
||||||
"@shopify/flash-list": "2.0.2",
|
"@types/react": "~19.2.14",
|
||||||
"@tanstack/react-query": "^5.90.21",
|
"axios": "^1.16.1",
|
||||||
"axios": "^1.13.6",
|
"date-fns": "^4.4.0",
|
||||||
"date-fns": "^4.1.0",
|
"expo": "~56.0.11",
|
||||||
"expo": "~55.0.4",
|
"expo-background-task": "~56.0.18",
|
||||||
"expo-background-fetch": "~55.0.9",
|
"expo-callkit-telecom": "^0.3.9",
|
||||||
"expo-constants": "~55.0.7",
|
"expo-camera": "~56.0.8",
|
||||||
"expo-dev-client": "~55.0.10",
|
"expo-constants": "~56.0.18",
|
||||||
"expo-file-system": "~55.0.10",
|
"expo-dev-client": "~56.0.20",
|
||||||
"expo-font": "~55.0.4",
|
"expo-document-picker": "~56.0.4",
|
||||||
"expo-haptics": "~55.0.8",
|
"expo-file-system": "~56.0.8",
|
||||||
"expo-image": "^55.0.5",
|
"expo-font": "~56.0.6",
|
||||||
"expo-image-picker": "^55.0.10",
|
"expo-haptics": "~56.0.3",
|
||||||
"expo-linear-gradient": "^55.0.8",
|
"expo-image": "~56.0.6",
|
||||||
"expo-media-library": "~55.0.9",
|
"expo-image-picker": "~56.0.17",
|
||||||
"expo-notifications": "^55.0.10",
|
"expo-intent-launcher": "~56.0.4",
|
||||||
"expo-router": "^55.0.4",
|
"expo-linear-gradient": "~56.0.4",
|
||||||
"expo-sqlite": "~55.0.10",
|
"expo-linking": "~56.0.14",
|
||||||
"expo-status-bar": "~55.0.4",
|
"expo-media-library": "~56.0.7",
|
||||||
"expo-system-ui": "^55.0.9",
|
"expo-notifications": "~56.0.17",
|
||||||
"expo-task-manager": "~55.0.9",
|
"expo-router": "~56.2.10",
|
||||||
"expo-updates": "^55.0.12",
|
"expo-splash-screen": "~56.0.10",
|
||||||
"expo-video": "^55.0.10",
|
"expo-sqlite": "~56.0.5",
|
||||||
"react": "19.2.0",
|
"expo-status-bar": "~56.0.4",
|
||||||
"react-dom": "19.2.0",
|
"expo-system-ui": "~56.0.5",
|
||||||
"react-native": "0.83.2",
|
"expo-task-manager": "~56.0.18",
|
||||||
"react-native-gesture-handler": "^2.30.0",
|
"expo-updates": "~56.0.19",
|
||||||
"react-native-pager-view": "^8.0.0",
|
"expo-video": "~56.1.3",
|
||||||
"react-native-paper": "^5.15.0",
|
"jcore-react-native": "^2.3.6",
|
||||||
"react-native-reanimated": "^4.2.1",
|
"jpush-react-native": "^3.2.7",
|
||||||
"react-native-safe-area-context": "~5.6.2",
|
"katex": "^0.17.0",
|
||||||
"react-native-screens": "~4.23.0",
|
"livekit-client": "^2.19.2",
|
||||||
|
"markdown-it": "^14.2.0",
|
||||||
|
"pako": "^2.1.0",
|
||||||
|
"prismjs": "^1.30.0",
|
||||||
|
"react": "19.2.3",
|
||||||
|
"react-dom": "19.2.3",
|
||||||
|
"react-native": "0.85.3",
|
||||||
|
"react-native-gesture-handler": "^3.0.0",
|
||||||
|
"react-native-pager-view": "^8.0.2",
|
||||||
|
"react-native-paper": "^5.15.3",
|
||||||
|
"react-native-reanimated": "~4.3.1",
|
||||||
|
"react-native-safe-area-context": "~5.8.0",
|
||||||
|
"react-native-screens": "4.25.2",
|
||||||
|
"react-native-share": "^12.3.1",
|
||||||
"react-native-sse": "^1.2.1",
|
"react-native-sse": "^1.2.1",
|
||||||
"react-native-web": "^0.21.0",
|
"react-native-web": "^0.21.0",
|
||||||
"react-native-worklets": "0.7.2",
|
"react-native-webview": "13.16.1",
|
||||||
"zod": "^4.3.6",
|
"react-native-worklets": "^0.8.3",
|
||||||
"zustand": "^5.0.11"
|
"zod": "^4.4.3",
|
||||||
|
"zustand": "^5.0.14"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@react-native-community/cli": "^20.1.2",
|
"@react-native-community/cli": "^20.1.3",
|
||||||
"@types/react": "~19.2.2",
|
"@types/pako": "^2.0.4",
|
||||||
|
"@types/react": "~19.2.16",
|
||||||
"@types/react-native-vector-icons": "^6.4.18",
|
"@types/react-native-vector-icons": "^6.4.18",
|
||||||
"typescript": "~5.9.2"
|
"typescript": "~6.0.3"
|
||||||
},
|
},
|
||||||
"private": true
|
"private": true,
|
||||||
|
"expo": {
|
||||||
|
"install": {
|
||||||
|
"exclude": [
|
||||||
|
"expo-image"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
4
package/android/expo-gradle-plugin/build.gradle.kts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
plugins {
|
||||||
|
kotlin("jvm") version "2.1.20" apply false
|
||||||
|
id("java-gradle-plugin")
|
||||||
|
}
|
||||||
22
package/gradle/libs.versions.toml
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
[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" }
|
||||||
7
package/gradle/wrapper/gradle-wrapper.properties
vendored
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
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
|
||||||
83
package/react-native-gradle-plugin/build.gradle.kts
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) Meta Platforms, Inc. and affiliates.
|
||||||
|
*
|
||||||
|
* This source code is licensed under the MIT license found in the
|
||||||
|
* LICENSE file in the root directory of this source tree.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import org.gradle.api.tasks.testing.logging.TestExceptionFormat
|
||||||
|
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
|
||||||
|
import org.jetbrains.kotlin.gradle.dsl.KotlinVersion
|
||||||
|
import org.jetbrains.kotlin.gradle.tasks.KotlinCompile
|
||||||
|
|
||||||
|
plugins {
|
||||||
|
alias(libs.plugins.kotlin.jvm)
|
||||||
|
alias(libs.plugins.ktfmt)
|
||||||
|
id("java-gradle-plugin")
|
||||||
|
}
|
||||||
|
|
||||||
|
repositories {
|
||||||
|
google()
|
||||||
|
mavenCentral()
|
||||||
|
}
|
||||||
|
|
||||||
|
gradlePlugin {
|
||||||
|
plugins {
|
||||||
|
create("react") {
|
||||||
|
id = "com.facebook.react"
|
||||||
|
implementationClass = "com.facebook.react.ReactPlugin"
|
||||||
|
}
|
||||||
|
create("reactrootproject") {
|
||||||
|
id = "com.facebook.react.rootproject"
|
||||||
|
implementationClass = "com.facebook.react.ReactRootProjectPlugin"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
group = "com.facebook.react"
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
implementation(project(":shared"))
|
||||||
|
|
||||||
|
implementation(gradleApi())
|
||||||
|
|
||||||
|
// The KGP/AGP version is defined by React Native Gradle plugin.
|
||||||
|
// Therefore we specify an implementation dep rather than a compileOnly.
|
||||||
|
implementation(libs.kotlin.gradle.plugin)
|
||||||
|
implementation(libs.android.gradle.plugin)
|
||||||
|
|
||||||
|
implementation(libs.gson)
|
||||||
|
implementation(libs.guava)
|
||||||
|
implementation(libs.javapoet)
|
||||||
|
|
||||||
|
testImplementation(libs.junit)
|
||||||
|
testImplementation(libs.assertj)
|
||||||
|
testImplementation(project(":shared-testutil"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// We intentionally don't build for Java 17 as users will see a cryptic bytecode version
|
||||||
|
// error first. Instead we produce a Java 11-compatible Gradle Plugin, so that AGP can print their
|
||||||
|
// nice message showing that JDK 11 (or 17) is required first
|
||||||
|
java { targetCompatibility = JavaVersion.VERSION_11 }
|
||||||
|
|
||||||
|
kotlin { jvmToolchain(17) }
|
||||||
|
|
||||||
|
tasks.withType<KotlinCompile>().configureEach {
|
||||||
|
compilerOptions {
|
||||||
|
apiVersion.set(KotlinVersion.KOTLIN_1_8)
|
||||||
|
// See comment above on JDK 11 support
|
||||||
|
jvmTarget.set(JvmTarget.JVM_11)
|
||||||
|
allWarningsAsErrors.set(
|
||||||
|
project.properties["enableWarningsAsErrors"]?.toString()?.toBoolean() ?: false
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tasks.withType<Test>().configureEach {
|
||||||
|
testLogging {
|
||||||
|
exceptionFormat = TestExceptionFormat.FULL
|
||||||
|
showExceptions = true
|
||||||
|
showCauses = true
|
||||||
|
showStackTraces = true
|
||||||
|
}
|
||||||
|
}
|
||||||
1
plugins/agconnect-services.json
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{"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"}}]}
|
||||||
40
plugins/withCmakeJobLimit.js
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
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;
|
||||||
467
plugins/withForegroundService.js
Normal file
@@ -0,0 +1,467 @@
|
|||||||
|
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;
|
||||||
205
plugins/withHonorPush.js
Normal file
@@ -0,0 +1,205 @@
|
|||||||
|
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;
|
||||||
239
plugins/withHuaweiPush.js
Normal file
@@ -0,0 +1,239 @@
|
|||||||
|
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;
|
||||||
321
plugins/withJPush.js
Normal file
@@ -0,0 +1,321 @@
|
|||||||
|
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;
|
||||||