feat(background): implement user consent mechanism for auto-start functionality
Add auto-start consent system that requires explicit user permission before enabling background sync services. Users can now choose between silent mode (no auto-start) or background mode (15-minute sync intervals). - Add consent service with AutoStartMode enum and storage utilities - Create withRemoveAutoStart Expo plugin to disable Android auto-start defaults - Integrate consent checks into JPush service, background task manager, and foreground service - Add auto-start consent UI in notification settings with descriptive dialog - Update privacy policy with section 8 explaining auto-start scenarios and user controls - Change default sync mode from BATTERY_SAVER to DISABLED - Export reinitBackgroundService function for re-initializing after consent changes - Merge OTA Android and iOS workflows into matrix build - Add release signing config in withSigning plugin for Android - Expand .dockerignore with native credential and build artifact exclusions
This commit is contained in:
@@ -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
|
||||||
android
|
dist-ios-update.zip
|
||||||
|
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,17 +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: android
|
|
||||||
OTA_PLATFORM_IOS: ios
|
|
||||||
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
|
||||||
@@ -41,12 +42,7 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
node-version: '25.6.1'
|
node-version: '25.6.1'
|
||||||
registry-url: 'https://registry.npmmirror.com'
|
registry-url: 'https://registry.npmmirror.com'
|
||||||
|
cache: 'npm'
|
||||||
- name: Remove deprecated always-auth npm config
|
|
||||||
run: |
|
|
||||||
if [ -f ~/.npmrc ]; then
|
|
||||||
sed -i '/always-auth/d' ~/.npmrc
|
|
||||||
fi
|
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: npm ci
|
run: npm ci
|
||||||
@@ -58,117 +54,44 @@ jobs:
|
|||||||
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_ANDROID}" \
|
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_ANDROID}" \
|
-H "expo-platform: ${{ matrix.platform }}" \
|
||||||
-H "expo-runtime-version: ${{ steps.runtime.outputs.runtime_version }}" \
|
|
||||||
-H "expo-protocol-version: 1" \
|
|
||||||
| python -c "import sys, json, re; s=sys.stdin.read(); m=re.search(r'\\{\\\"id\\\":.*\\\"extra\\\":\\{.*\\}\\}', s); j=json.loads(m.group(0)); print(j['launchAsset']['url'].split('/')[-1].split('&')[0])")"
|
|
||||||
LOCAL="$(python -c "import json; m=json.load(open('dist-android/metadata.json')); print(m['fileMetadata']['android']['bundle'].split('/')[-1])")"
|
|
||||||
echo "Remote bundle: ${REMOTE}"
|
|
||||||
echo "Local bundle: ${LOCAL}"
|
|
||||||
test "${REMOTE}" = "${LOCAL}"
|
|
||||||
|
|
||||||
ota-ios:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
if: github.event_name != 'pull_request' && (github.event_name != 'workflow_dispatch' || github.event.inputs.publish_ota == 'true')
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
steps:
|
|
||||||
- name: Checkout code
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Set up Node
|
|
||||||
uses: actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version: '25.6.1'
|
|
||||||
registry-url: 'https://registry.npmmirror.com'
|
|
||||||
|
|
||||||
- name: Remove deprecated always-auth npm config
|
|
||||||
run: |
|
|
||||||
if [ -f ~/.npmrc ]; then
|
|
||||||
sed -i '/always-auth/d' ~/.npmrc
|
|
||||||
fi
|
|
||||||
|
|
||||||
- name: Install dependencies
|
|
||||||
run: npm ci
|
|
||||||
|
|
||||||
- name: Resolve runtime version
|
|
||||||
id: runtime
|
|
||||||
run: |
|
|
||||||
RUNTIME_VERSION="$(node -p "require('./app.json').expo.version")"
|
|
||||||
echo "runtime_version=${RUNTIME_VERSION}" >> "$GITHUB_OUTPUT"
|
|
||||||
echo "Resolved runtimeVersion: ${RUNTIME_VERSION}"
|
|
||||||
|
|
||||||
- name: Export iOS update bundle
|
|
||||||
run: |
|
|
||||||
rm -rf dist-ios dist-ios-update.zip
|
|
||||||
npx expo export --platform ios --output-dir dist-ios
|
|
||||||
npx expo config --type public --json > dist-ios/expoConfig.json
|
|
||||||
|
|
||||||
- name: Archive iOS update bundle
|
|
||||||
run: |
|
|
||||||
python - <<'PY'
|
|
||||||
import os
|
|
||||||
import zipfile
|
|
||||||
|
|
||||||
with zipfile.ZipFile('dist-ios-update.zip', 'w', zipfile.ZIP_DEFLATED) as zf:
|
|
||||||
for root, _, files in os.walk('dist-ios'):
|
|
||||||
for name in files:
|
|
||||||
src = os.path.join(root, name)
|
|
||||||
arc = os.path.relpath(src, 'dist-ios')
|
|
||||||
zf.write(src, arc)
|
|
||||||
PY
|
|
||||||
|
|
||||||
- name: Publish OTA
|
|
||||||
env:
|
|
||||||
OTA_AUTH_TOKEN: ${{ secrets.OTA_AUTH_TOKEN }}
|
|
||||||
run: |
|
|
||||||
test -n "${OTA_AUTH_TOKEN}" || (echo "Missing secret OTA_AUTH_TOKEN" && exit 1)
|
|
||||||
curl -fSs -X POST "${OTA_PUBLISH_URL}?runtimeVersion=${{ steps.runtime.outputs.runtime_version }}&platform=${OTA_PLATFORM_IOS}" \
|
|
||||||
-H "Authorization: Bearer ${OTA_AUTH_TOKEN}" \
|
|
||||||
-H "Content-Type: application/zip" \
|
|
||||||
--data-binary @"dist-ios-update.zip"
|
|
||||||
|
|
||||||
- name: Verify OTA manifest
|
|
||||||
run: |
|
|
||||||
REMOTE="$(curl -sS "${OTA_MANIFEST_URL}" \
|
|
||||||
-H "expo-platform: ${OTA_PLATFORM_IOS}" \
|
|
||||||
-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-ios/metadata.json')); print(m['fileMetadata']['ios']['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}"
|
||||||
@@ -202,27 +125,25 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
node-version: '25.6.1'
|
node-version: '25.6.1'
|
||||||
registry-url: 'https://registry.npmmirror.com'
|
registry-url: 'https://registry.npmmirror.com'
|
||||||
|
cache: 'npm'
|
||||||
|
|
||||||
- name: Remove deprecated always-auth npm config
|
- name: Install dependencies
|
||||||
run: |
|
run: npm ci
|
||||||
if [ -f ~/.npmrc ]; then
|
|
||||||
sed -i '/always-auth/d' ~/.npmrc
|
|
||||||
fi
|
|
||||||
|
|
||||||
- name: Cache Android NDK
|
- name: Cache Android NDK
|
||||||
uses: actions/cache@v4
|
uses: actions/cache@v4
|
||||||
id: cache-ndk
|
id: cache-ndk
|
||||||
with:
|
with:
|
||||||
path: /opt/android/ndk/27.1.12297006
|
path: /opt/android/ndk
|
||||||
key: ndk-27.1.12297006-v1
|
key: ndk-v2
|
||||||
|
|
||||||
- name: Install Android NDK
|
- name: Install Android NDK
|
||||||
if: steps.cache-ndk.outputs.cache-hit != 'true'
|
if: steps.cache-ndk.outputs.cache-hit != 'true'
|
||||||
run: |
|
run: |
|
||||||
echo "Existing NDK versions:"
|
echo "Existing NDK versions:"
|
||||||
ls /opt/android/ndk/ 2>/dev/null || echo "No NDK dir"
|
ls /opt/android/ndk/ 2>/dev/null || echo "No NDK dir"
|
||||||
echo "Installing NDK 27.1.12297006..."
|
echo "Installing required NDK versions..."
|
||||||
yes | sdkmanager --install "ndk;27.1.12297006"
|
yes | sdkmanager --install "ndk;27.1.12297006" "ndk;27.0.12077973"
|
||||||
echo "NDK after install:"
|
echo "NDK after install:"
|
||||||
ls /opt/android/ndk/
|
ls /opt/android/ndk/
|
||||||
|
|
||||||
@@ -233,29 +154,19 @@ jobs:
|
|||||||
~/.gradle/caches
|
~/.gradle/caches
|
||||||
~/.gradle/wrapper
|
~/.gradle/wrapper
|
||||||
~/.android/build-cache
|
~/.android/build-cache
|
||||||
key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties', '**/gradle.properties') }}
|
key: ${{ runner.os }}-gradle-${{ hashFiles('package-lock.json') }}
|
||||||
restore-keys: |
|
restore-keys: |
|
||||||
${{ runner.os }}-gradle-
|
${{ runner.os }}-gradle-
|
||||||
|
|
||||||
- name: Cache node_modules
|
|
||||||
uses: actions/cache@v4
|
|
||||||
id: cache-node-modules
|
|
||||||
with:
|
|
||||||
path: node_modules
|
|
||||||
key: ${{ runner.os }}-node-modules-${{ hashFiles('package-lock.json') }}
|
|
||||||
restore-keys: |
|
|
||||||
${{ runner.os }}-node-modules-
|
|
||||||
|
|
||||||
- name: Install dependencies
|
|
||||||
if: steps.cache-node-modules.outputs.cache-hit != 'true'
|
|
||||||
run: npm ci
|
|
||||||
|
|
||||||
- name: Generate Android native project
|
- name: Generate Android native project
|
||||||
run: npx expo prebuild --platform android
|
run: npx expo prebuild --platform android
|
||||||
|
|
||||||
- name: Switch Gradle distribution to Tencent mirror
|
- name: Configure Gradle with China mirrors and signing
|
||||||
run: |
|
run: |
|
||||||
PROPS="android/gradle/wrapper/gradle-wrapper.properties"
|
cd android
|
||||||
|
|
||||||
|
# Switch Gradle distribution to Tencent mirror
|
||||||
|
PROPS="gradle/wrapper/gradle-wrapper.properties"
|
||||||
if [ -f "$PROPS" ]; then
|
if [ -f "$PROPS" ]; then
|
||||||
sed -i 's|distributionUrl=https\\://services.gradle.org/distributions/|distributionUrl=https\\://mirrors.cloud.tencent.com/gradle/|' "$PROPS"
|
sed -i 's|distributionUrl=https\\://services.gradle.org/distributions/|distributionUrl=https\\://mirrors.cloud.tencent.com/gradle/|' "$PROPS"
|
||||||
echo "Updated gradle-wrapper.properties:"
|
echo "Updated gradle-wrapper.properties:"
|
||||||
@@ -264,14 +175,9 @@ jobs:
|
|||||||
echo "gradle-wrapper.properties not found"
|
echo "gradle-wrapper.properties not found"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
- name: Decode Android signing keystore
|
# Decode Android signing keystore
|
||||||
run: |
|
echo "${{ secrets.ANDROID_KEYSTORE_BASE64 }}" | base64 -d > app/withyou-release-key.keystore
|
||||||
echo "${{ secrets.ANDROID_KEYSTORE_BASE64 }}" | base64 -d > android/app/withyou-release-key.keystore
|
|
||||||
|
|
||||||
- name: Configure Gradle with China Maven mirrors
|
|
||||||
run: |
|
|
||||||
cd android
|
|
||||||
|
|
||||||
# Update settings.gradle with China Maven mirrors
|
# Update settings.gradle with China Maven mirrors
|
||||||
cat > settings.gradle << 'SETTINGS_EOF'
|
cat > settings.gradle << 'SETTINGS_EOF'
|
||||||
@@ -368,7 +274,7 @@ 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=true
|
||||||
@@ -387,16 +293,18 @@ jobs:
|
|||||||
ndkVersion=27.1.12297006
|
ndkVersion=27.1.12297006
|
||||||
expo.useLegacyPackaging=false
|
expo.useLegacyPackaging=false
|
||||||
PROPS_EOF
|
PROPS_EOF
|
||||||
|
|
||||||
- name: Configure Gradle signing properties
|
# Append signing properties (secrets appended, not cached)
|
||||||
run: |
|
|
||||||
cd android
|
|
||||||
cat >> gradle.properties << SIGNING_PROPS
|
cat >> gradle.properties << SIGNING_PROPS
|
||||||
MYAPP_UPLOAD_STORE_FILE=withyou-release-key.keystore
|
MYAPP_UPLOAD_STORE_FILE=withyou-release-key.keystore
|
||||||
MYAPP_UPLOAD_STORE_PASSWORD=${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
|
MYAPP_UPLOAD_STORE_PASSWORD=${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
|
||||||
MYAPP_UPLOAD_KEY_ALIAS=withyou-key
|
MYAPP_UPLOAD_KEY_ALIAS=withyou-key
|
||||||
MYAPP_UPLOAD_KEY_PASSWORD=${{ secrets.ANDROID_KEY_PASSWORD }}
|
MYAPP_UPLOAD_KEY_PASSWORD=${{ secrets.ANDROID_KEY_PASSWORD }}
|
||||||
SIGNING_PROPS
|
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: |
|
||||||
@@ -405,7 +313,7 @@ jobs:
|
|||||||
./gradlew :app:assembleRelease -PreactNativeArchitectures=arm64-v8a --max-workers=4 --parallel
|
./gradlew :app:assembleRelease -PreactNativeArchitectures=arm64-v8a --max-workers=4 --parallel
|
||||||
|
|
||||||
- name: Upload APK artifact
|
- name: Upload APK artifact
|
||||||
uses: actions/upload-artifact@v3
|
uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: withyou-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
|
||||||
|
|||||||
5
.npmrc
Normal file
5
.npmrc
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
# Use China npm mirror for faster downloads in CI
|
||||||
|
registry=https://registry.npmmirror.com
|
||||||
|
|
||||||
|
# Disable always-auth to avoid deprecation warnings
|
||||||
|
always-auth=false
|
||||||
1
app.json
1
app.json
@@ -100,6 +100,7 @@
|
|||||||
"minimumInterval": 15
|
"minimumInterval": 15
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
"./plugins/withRemoveAutoStart",
|
||||||
[
|
[
|
||||||
"expo-media-library",
|
"expo-media-library",
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -146,6 +146,7 @@ function NotificationBootstrap() {
|
|||||||
const { systemNotificationService } = await import('@/services/notification');
|
const { systemNotificationService } = await import('@/services/notification');
|
||||||
await systemNotificationService.initialize();
|
await systemNotificationService.initialize();
|
||||||
const { initBackgroundService } = await import('@/services/background');
|
const { initBackgroundService } = await import('@/services/background');
|
||||||
|
// 默认静默模式,不会注册后台任务,不会触发自启动
|
||||||
await initBackgroundService();
|
await initBackgroundService();
|
||||||
|
|
||||||
const subscription = AppState.addEventListener('change', (nextAppState) => {
|
const subscription = AppState.addEventListener('change', (nextAppState) => {
|
||||||
|
|||||||
87
plugins/withRemoveAutoStart.js
Normal file
87
plugins/withRemoveAutoStart.js
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
/**
|
||||||
|
* withRemoveAutoStart — 移除应用退出后的自启动/关联启动行为
|
||||||
|
*
|
||||||
|
* 整改 2.2.6 APP频繁自启动和关联启动(存在风险)
|
||||||
|
*
|
||||||
|
* 问题:以下 SDK 注册了 BOOT_COMPLETED 等 intent-filter,
|
||||||
|
* 导致设备重启时应用被自动唤醒:
|
||||||
|
* - expo.modules.taskManager.TaskBroadcastReceiver
|
||||||
|
* - expo.modules.notifications.service.NotificationsService
|
||||||
|
* - androidx.work.impl.background.systemalarm.RescheduleReceiver
|
||||||
|
* - androidx.work.impl.background.systemalarm.ConstraintProxy* (多个)
|
||||||
|
*
|
||||||
|
* 本插件在 final manifest 合并阶段移除这些 intent-filter action,
|
||||||
|
* 从根本上消除应用退出后的自启动行为。
|
||||||
|
*/
|
||||||
|
|
||||||
|
const { withAndroidManifest } = require('expo/config-plugins');
|
||||||
|
|
||||||
|
// 需要从 receivers 中移除的自启动相关 action
|
||||||
|
const AUTO_START_ACTIONS = new Set([
|
||||||
|
'android.intent.action.BOOT_COMPLETED',
|
||||||
|
'android.intent.action.REBOOT',
|
||||||
|
'android.intent.action.QUICKBOOT_POWERON',
|
||||||
|
'com.htc.intent.action.QUICKBOOT_POWERON',
|
||||||
|
]);
|
||||||
|
|
||||||
|
function removeAutoStartActionsFromManifest(androidManifest) {
|
||||||
|
const app = androidManifest.manifest.application?.[0];
|
||||||
|
if (!app) return androidManifest;
|
||||||
|
|
||||||
|
// 处理 <receiver> 标签
|
||||||
|
const receivers = app.receiver || [];
|
||||||
|
for (const receiver of receivers) {
|
||||||
|
if (!receiver['intent-filter']) continue;
|
||||||
|
|
||||||
|
receiver['intent-filter'] = receiver['intent-filter'].map((filter) => {
|
||||||
|
if (!filter.action) return filter;
|
||||||
|
|
||||||
|
const originalCount = filter.action.length;
|
||||||
|
filter.action = filter.action.filter(
|
||||||
|
(action) => !AUTO_START_ACTIONS.has(action.$?.['android:name'])
|
||||||
|
);
|
||||||
|
|
||||||
|
if (filter.action.length !== originalCount) {
|
||||||
|
const removed = originalCount - filter.action.length;
|
||||||
|
console.log(
|
||||||
|
`[withRemoveAutoStart] 移除了 ${removed} 个自启动 action (receiver)`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return filter;
|
||||||
|
});
|
||||||
|
|
||||||
|
// 如果 intent-filter 中没有 action 了,移除整个 intent-filter
|
||||||
|
receiver['intent-filter'] = receiver['intent-filter'].filter(
|
||||||
|
(filter) => filter.action && filter.action.length > 0
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 移除 RECEIVE_BOOT_COMPLETED 权限声明
|
||||||
|
if (androidManifest.manifest['uses-permission']) {
|
||||||
|
const originalPerms = androidManifest.manifest['uses-permission'].length;
|
||||||
|
androidManifest.manifest['uses-permission'] = androidManifest.manifest[
|
||||||
|
'uses-permission'
|
||||||
|
].filter((perm) => {
|
||||||
|
const name = perm.$?.['android:name'];
|
||||||
|
return name !== 'android.permission.RECEIVE_BOOT_COMPLETED';
|
||||||
|
});
|
||||||
|
const removedPerms = originalPerms - androidManifest.manifest['uses-permission'].length;
|
||||||
|
if (removedPerms > 0) {
|
||||||
|
console.log(
|
||||||
|
`[withRemoveAutoStart] 移除了 RECEIVE_BOOT_COMPLETED 权限声明 (${removedPerms} 处)`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return androidManifest;
|
||||||
|
}
|
||||||
|
|
||||||
|
function withRemoveAutoStart(config) {
|
||||||
|
return withAndroidManifest(config, (config) => {
|
||||||
|
config.modResults = removeAutoStartActionsFromManifest(config.modResults);
|
||||||
|
return config;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = withRemoveAutoStart;
|
||||||
@@ -35,7 +35,95 @@ const withSigning = (config, options = {}) => {
|
|||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
config = withDangerousMod(config, [
|
||||||
|
'android',
|
||||||
|
async (config) => {
|
||||||
|
const platformRoot = config.modRequest.platformProjectRoot;
|
||||||
|
const buildGradlePath = path.join(platformRoot, 'app', 'build.gradle');
|
||||||
|
|
||||||
|
if (!fs.existsSync(buildGradlePath)) return config;
|
||||||
|
|
||||||
|
let lines = fs.readFileSync(buildGradlePath, 'utf-8').split('\n');
|
||||||
|
|
||||||
|
// 1. Add release signing config that reads from gradle.properties
|
||||||
|
const hasReleaseSigning = lines.some((l) => l.includes('MYAPP_UPLOAD_STORE_FILE'));
|
||||||
|
if (!hasReleaseSigning) {
|
||||||
|
// Find the debug signingConfig closing brace and insert release after it
|
||||||
|
let debugBraceLine = -1;
|
||||||
|
let inSigningConfigs = false;
|
||||||
|
let braceDepth = 0;
|
||||||
|
|
||||||
|
for (let i = 0; i < lines.length; i++) {
|
||||||
|
const line = lines[i];
|
||||||
|
if (line.includes('signingConfigs {') || line.match(/^\s*signingConfigs\s*\{/)) {
|
||||||
|
inSigningConfigs = true;
|
||||||
|
braceDepth = 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (inSigningConfigs) {
|
||||||
|
braceDepth += (line.match(/\{/g) || []).length;
|
||||||
|
braceDepth -= (line.match(/\}/g) || []).length;
|
||||||
|
if (braceDepth === 0) {
|
||||||
|
debugBraceLine = i; // This is the closing } of signingConfigs
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (debugBraceLine > 0) {
|
||||||
|
const releaseBlock = [
|
||||||
|
' release {',
|
||||||
|
" if (project.hasProperty('MYAPP_UPLOAD_STORE_FILE')) {",
|
||||||
|
' storeFile file(MYAPP_UPLOAD_STORE_FILE)',
|
||||||
|
' storePassword MYAPP_UPLOAD_STORE_PASSWORD',
|
||||||
|
' keyAlias MYAPP_UPLOAD_KEY_ALIAS',
|
||||||
|
' keyPassword MYAPP_UPLOAD_KEY_PASSWORD',
|
||||||
|
' }',
|
||||||
|
' }',
|
||||||
|
];
|
||||||
|
lines.splice(debugBraceLine, 0, ...releaseBlock);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Change release buildType to use release signingConfig
|
||||||
|
// Walk through buildTypes > release and replace signingConfigs.debug -> signingConfigs.release
|
||||||
|
let inBuildTypes = false;
|
||||||
|
let inRelease = false;
|
||||||
|
let buildTypesDepth = 0;
|
||||||
|
|
||||||
|
for (let i = 0; i < lines.length; i++) {
|
||||||
|
const line = lines[i];
|
||||||
|
if (line.includes('buildTypes {') || line.match(/^\s*buildTypes\s*\{/)) {
|
||||||
|
inBuildTypes = true;
|
||||||
|
buildTypesDepth = 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (inBuildTypes) {
|
||||||
|
buildTypesDepth += (line.match(/\{/g) || []).length;
|
||||||
|
buildTypesDepth -= (line.match(/\}/g) || []).length;
|
||||||
|
|
||||||
|
if (line.match(/^\s*release\s*\{/)) {
|
||||||
|
inRelease = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (inRelease && line.includes('signingConfig signingConfigs.debug')) {
|
||||||
|
lines[i] = line.replace('signingConfig signingConfigs.debug', 'signingConfig signingConfigs.release');
|
||||||
|
inRelease = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (buildTypesDepth === 0) {
|
||||||
|
inBuildTypes = false;
|
||||||
|
inRelease = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fs.writeFileSync(buildGradlePath, lines.join('\n'));
|
||||||
|
return config;
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
return config;
|
return config;
|
||||||
};
|
};
|
||||||
|
|
||||||
module.exports = withSigning;
|
module.exports = withSigning;
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useEffect, useRef } from 'react';
|
|||||||
import { Platform } from 'react-native';
|
import { Platform } from 'react-native';
|
||||||
|
|
||||||
import { jpushService } from '../services/notification/jpushService';
|
import { jpushService } from '../services/notification/jpushService';
|
||||||
|
import { isAutoStartAllowed } from '../services/consent/autoStartConsent';
|
||||||
|
|
||||||
export function useRegisterPushDevice(isAuthenticated: boolean, userID?: string) {
|
export function useRegisterPushDevice(isAuthenticated: boolean, userID?: string) {
|
||||||
const deviceRegistered = useRef(false);
|
const deviceRegistered = useRef(false);
|
||||||
@@ -9,6 +10,12 @@ export function useRegisterPushDevice(isAuthenticated: boolean, userID?: string)
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (Platform.OS === 'web' || !isAuthenticated || !userID) return;
|
if (Platform.OS === 'web' || !isAuthenticated || !userID) return;
|
||||||
|
|
||||||
|
// 仅在用户同意自启动后才初始化 JPush
|
||||||
|
if (!isAutoStartAllowed()) {
|
||||||
|
console.log('[useRegisterPushDevice] 用户未同意自启动,跳过 JPush 初始化');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (!deviceRegistered.current) {
|
if (!deviceRegistered.current) {
|
||||||
deviceRegistered.current = true;
|
deviceRegistered.current = true;
|
||||||
|
|
||||||
|
|||||||
@@ -29,6 +29,12 @@ import {
|
|||||||
} from '@/services/notification';
|
} from '@/services/notification';
|
||||||
import { useResponsive, useResponsiveSpacing } from '../../hooks';
|
import { useResponsive, useResponsiveSpacing } from '../../hooks';
|
||||||
import { backgroundSyncManager, BackgroundSyncMode } from '@/services/background';
|
import { backgroundSyncManager, BackgroundSyncMode } from '@/services/background';
|
||||||
|
import {
|
||||||
|
loadAutoStartConsent,
|
||||||
|
consentToAutoStart,
|
||||||
|
rejectAutoStart,
|
||||||
|
getAutoStartDescription,
|
||||||
|
} from '@/services/consent';
|
||||||
|
|
||||||
// 内容最大宽度
|
// 内容最大宽度
|
||||||
const CONTENT_MAX_WIDTH = 720;
|
const CONTENT_MAX_WIDTH = 720;
|
||||||
@@ -49,8 +55,9 @@ export const NotificationSettingsScreen: React.FC = () => {
|
|||||||
const [vibrationEnabled, setVibrationEnabledState] = useState(true);
|
const [vibrationEnabled, setVibrationEnabledState] = useState(true);
|
||||||
const [pushEnabled, setPushEnabled] = useState(true);
|
const [pushEnabled, setPushEnabled] = useState(true);
|
||||||
const [soundEnabled, setSoundEnabled] = useState(true);
|
const [soundEnabled, setSoundEnabled] = useState(true);
|
||||||
const [syncMode, setSyncMode] = useState<BackgroundSyncMode>(BackgroundSyncMode.BATTERY_SAVER);
|
const [syncMode, setSyncMode] = useState<BackgroundSyncMode>(BackgroundSyncMode.DISABLED);
|
||||||
const [systemPushEnabled, setSystemPushEnabled] = useState<boolean | null>(null);
|
const [systemPushEnabled, setSystemPushEnabled] = useState<boolean | null>(null);
|
||||||
|
const [autoStartConsented, setAutoStartConsented] = useState(false);
|
||||||
const { isWideScreen, isMobile } = useResponsive();
|
const { isWideScreen, isMobile } = useResponsive();
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
|
|
||||||
@@ -67,6 +74,10 @@ export const NotificationSettingsScreen: React.FC = () => {
|
|||||||
setSoundEnabled(prefs.soundEnabled);
|
setSoundEnabled(prefs.soundEnabled);
|
||||||
setSyncMode(backgroundSyncManager.getMode());
|
setSyncMode(backgroundSyncManager.getMode());
|
||||||
|
|
||||||
|
// 加载自启动同意状态
|
||||||
|
const consent = await loadAutoStartConsent();
|
||||||
|
setAutoStartConsented(consent.consented);
|
||||||
|
|
||||||
if (Platform.OS !== 'web') {
|
if (Platform.OS !== 'web') {
|
||||||
const enabled = await jpushService.checkNotificationPermission();
|
const enabled = await jpushService.checkNotificationPermission();
|
||||||
setSystemPushEnabled(enabled);
|
setSystemPushEnabled(enabled);
|
||||||
@@ -106,6 +117,41 @@ export const NotificationSettingsScreen: React.FC = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleSyncModeChange = async (mode: BackgroundSyncMode) => {
|
const handleSyncModeChange = async (mode: BackgroundSyncMode) => {
|
||||||
|
// 如果要切换到需要自启动的模式,先请求用户同意
|
||||||
|
if (mode !== BackgroundSyncMode.DISABLED && !autoStartConsented) {
|
||||||
|
Alert.alert(
|
||||||
|
'后台消息接收',
|
||||||
|
getAutoStartDescription() + '\n\n开启后,应用可在后台接收消息推送。',
|
||||||
|
[
|
||||||
|
{ text: '取消', style: 'cancel' },
|
||||||
|
{
|
||||||
|
text: '同意并开启',
|
||||||
|
onPress: async () => {
|
||||||
|
try {
|
||||||
|
await consentToAutoStart('接收实时消息推送');
|
||||||
|
setAutoStartConsented(true);
|
||||||
|
// 用户同意后,继续切换模式
|
||||||
|
await doSetSyncMode(mode);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('同意自启动失败:', error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果切换到禁用模式,撤销自启动同意
|
||||||
|
if (mode === BackgroundSyncMode.DISABLED && autoStartConsented) {
|
||||||
|
await rejectAutoStart();
|
||||||
|
setAutoStartConsented(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
await doSetSyncMode(mode);
|
||||||
|
};
|
||||||
|
|
||||||
|
const doSetSyncMode = async (mode: BackgroundSyncMode) => {
|
||||||
if (mode === BackgroundSyncMode.REALTIME) {
|
if (mode === BackgroundSyncMode.REALTIME) {
|
||||||
Alert.alert(
|
Alert.alert(
|
||||||
'实时模式',
|
'实时模式',
|
||||||
@@ -129,25 +175,56 @@ export const NotificationSettingsScreen: React.FC = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleAutoStartModeChange = async (mode: AutoStartMode) => {
|
||||||
|
if (mode === AutoStartMode.BACKGROUND) {
|
||||||
|
Alert.alert(
|
||||||
|
'后台消息接收',
|
||||||
|
getAutoStartDescription() + '\n\n开启后,应用可在后台接收消息推送。',
|
||||||
|
[
|
||||||
|
{ text: '取消', style: 'cancel' },
|
||||||
|
{
|
||||||
|
text: '同意并开启',
|
||||||
|
onPress: async () => {
|
||||||
|
await setAutoStartMode(AutoStartMode.BACKGROUND);
|
||||||
|
setAutoStartModeState(AutoStartMode.BACKGROUND);
|
||||||
|
setAutoStartConsented(true);
|
||||||
|
// 重新初始化后台服务以应用新设置
|
||||||
|
const { reinitBackgroundService } = await import('@/services/background');
|
||||||
|
await reinitBackgroundService();
|
||||||
|
Alert.alert('已开启', '后台消息接收已开启。您可随时在设置中关闭。');
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
await setAutoStartMode(AutoStartMode.SILENT);
|
||||||
|
setAutoStartModeState(AutoStartMode.SILENT);
|
||||||
|
setAutoStartConsented(false);
|
||||||
|
// 重新初始化后台服务以应用新设置
|
||||||
|
const { reinitBackgroundService } = await import('@/services/background');
|
||||||
|
await reinitBackgroundService();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const syncModeOptions: { mode: BackgroundSyncMode; title: string; subtitle: string; icon: string }[] = [
|
const syncModeOptions: { mode: BackgroundSyncMode; title: string; subtitle: string; icon: string }[] = [
|
||||||
|
{
|
||||||
|
mode: BackgroundSyncMode.DISABLED,
|
||||||
|
title: '静默模式',
|
||||||
|
subtitle: '不自启动,仅在使用时接收消息',
|
||||||
|
icon: 'bell-off-outline',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
mode: BackgroundSyncMode.BATTERY_SAVER,
|
mode: BackgroundSyncMode.BATTERY_SAVER,
|
||||||
title: '省电模式',
|
title: '后台模式',
|
||||||
subtitle: '系统后台任务,每 15 分钟检查一次',
|
subtitle: '允许自启动,每 15 分钟检查一次',
|
||||||
icon: 'leaf',
|
icon: 'leaf',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
mode: BackgroundSyncMode.REALTIME,
|
mode: BackgroundSyncMode.REALTIME,
|
||||||
title: '实时模式',
|
title: '实时模式',
|
||||||
subtitle: '通知栏常驻保活,即时同步消息',
|
subtitle: '允许自启动,通知栏常驻保活',
|
||||||
icon: 'lightning-bolt',
|
icon: 'lightning-bolt',
|
||||||
},
|
},
|
||||||
{
|
|
||||||
mode: BackgroundSyncMode.DISABLED,
|
|
||||||
title: '禁用',
|
|
||||||
subtitle: '仅在应用打开时接收消息',
|
|
||||||
icon: 'close-circle-outline',
|
|
||||||
},
|
|
||||||
];
|
];
|
||||||
|
|
||||||
const handleRequestSystemPermission = async () => {
|
const handleRequestSystemPermission = async () => {
|
||||||
@@ -304,12 +381,30 @@ export const NotificationSettingsScreen: React.FC = () => {
|
|||||||
))}
|
))}
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* 实时模式说明 */}
|
{/* 模式说明 */}
|
||||||
|
{syncMode === BackgroundSyncMode.DISABLED && (
|
||||||
|
<View style={styles.tipContainer}>
|
||||||
|
<MaterialCommunityIcons name="information-outline" size={16} color={colors.text.hint} />
|
||||||
|
<Text variant="caption" color={colors.text.hint} style={styles.tipText}>
|
||||||
|
静默模式下,应用不会自启动。您仅在打开应用时接收消息。此模式最省电,但可能错过实时消息。
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{syncMode === BackgroundSyncMode.BATTERY_SAVER && (
|
||||||
|
<View style={styles.tipContainer}>
|
||||||
|
<MaterialCommunityIcons name="information-outline" size={16} color={colors.text.hint} />
|
||||||
|
<Text variant="caption" color={colors.text.hint} style={styles.tipText}>
|
||||||
|
后台模式已开启,应用允许自启动以接收消息推送。系统会每 15 分钟检查一次新消息。
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
{syncMode === BackgroundSyncMode.REALTIME && (
|
{syncMode === BackgroundSyncMode.REALTIME && (
|
||||||
<View style={styles.tipContainer}>
|
<View style={styles.tipContainer}>
|
||||||
<MaterialCommunityIcons name="shield-check-outline" size={16} color={colors.text.hint} />
|
<MaterialCommunityIcons name="shield-check-outline" size={16} color={colors.text.hint} />
|
||||||
<Text variant="caption" color={colors.text.hint} style={styles.tipText}>
|
<Text variant="caption" color={colors.text.hint} style={styles.tipText}>
|
||||||
实时模式已开启,应用将在通知栏显示常驻通知以保持后台运行。如需更稳定的后台运行,请在系统设置中将本应用加入电池优化白名单。
|
实时模式已开启,应用允许自启动并在通知栏显示常驻通知以保持后台运行。如需更稳定的后台运行,请在系统设置中将本应用加入电池优化白名单。
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ const THEME_COLORS = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// 政策最后更新日期
|
// 政策最后更新日期
|
||||||
const LAST_UPDATED = '2026年4月28日';
|
const LAST_UPDATED = '2026年6月15日';
|
||||||
|
|
||||||
// 隐私政策内容
|
// 隐私政策内容
|
||||||
const PRIVACY_SECTIONS = [
|
const PRIVACY_SECTIONS = [
|
||||||
@@ -170,18 +170,68 @@ const PRIVACY_SECTIONS = [
|
|||||||
• 使用目的:辅助消息推送通道管理
|
• 使用目的:辅助消息推送通道管理
|
||||||
• 收集的个人信息:设备推送令牌(Push Token)、设备标识符
|
• 收集的个人信息:设备推送令牌(Push Token)、设备标识符
|
||||||
|
|
||||||
|
3. WorkManager(Android系统组件)
|
||||||
|
• 提供方:Google LLC
|
||||||
|
• 使用目的:在用户同意自启动后,执行后台消息同步任务
|
||||||
|
• 收集的个人信息:无额外个人信息收集
|
||||||
|
• 隐私政策:https://policies.google.com/privacy
|
||||||
|
|
||||||
|
4. expo-task-manager / expo-background-task
|
||||||
|
• 提供方:Expo(650 Industries, Inc.)
|
||||||
|
• 使用目的:在用户同意自启动后,管理后台任务调度
|
||||||
|
• 收集的个人信息:无额外个人信息收集
|
||||||
|
|
||||||
|
5. expo-notifications
|
||||||
|
• 提供方:Expo(650 Industries, Inc.)
|
||||||
|
• 使用目的:在用户同意自启动后,恢复通知服务
|
||||||
|
• 收集的个人信息:设备推送令牌
|
||||||
|
|
||||||
|
6. expo-callkit-telecom
|
||||||
|
• 提供方:Expo(650 Industries, Inc.)
|
||||||
|
• 使用目的:通话功能相关服务
|
||||||
|
• 收集的个人信息:无额外个人信息收集
|
||||||
|
|
||||||
如您后续接入其他第三方SDK(如微信登录、分享等功能),我们将在本章节更新相关说明,并告知您对应SDK收集的信息类型和用途。更新后的SDK目录将在应用内公布,请以最新版本为准。`,
|
如您后续接入其他第三方SDK(如微信登录、分享等功能),我们将在本章节更新相关说明,并告知您对应SDK收集的信息类型和用途。更新后的SDK目录将在应用内公布,请以最新版本为准。`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '八、隐私政策的更新',
|
title: '八、应用自启动与关联启动说明',
|
||||||
content: `8.1 我们可能会不时更新本隐私政策。更新后的隐私政策将在应用内公布,并标注更新日期。
|
content: `8.1 自启动/关联启动的目的与场景
|
||||||
|
为了及时向您推送消息通知,本应用可能需要在以下场景进行自启动或关联启动:
|
||||||
|
|
||||||
8.2 对于重大变更,我们会在变更生效前通过应用内通知、弹窗等方式告知您。
|
• 设备开机完成后:恢复后台消息推送服务,确保您能及时收到新消息提醒
|
||||||
|
• 应用更新后:恢复后台任务调度,保证消息同步功能正常运作
|
||||||
|
• 系统重启后:恢复通知服务,确保推送通道可用
|
||||||
|
• 关联启动场景:当系统或其他应用触发相关事件时,为保证推送服务连续性而进行关联启动
|
||||||
|
|
||||||
8.3 如您不同意变更后的内容,应立即停止使用本服务。如您继续使用本服务,即视为您已阅读并同意受变更后的隐私政策约束。`,
|
8.2 用户同意机制
|
||||||
|
• 自启动/关联启动功能仅在您明确同意后才启用
|
||||||
|
• 首次使用时,我们会在"通知设置"中向您说明自启动的目的、场景、规则及必要性,并征得您的同意
|
||||||
|
• 您可随时在"设置-通知设置-后台同步模式"中更改选择:
|
||||||
|
- 静默模式:不自启动,仅在使用应用时接收消息
|
||||||
|
- 后台模式:同意自启动,系统每15分钟检查一次新消息
|
||||||
|
- 实时模式:同意自启动,通知栏常驻保活,即时同步消息
|
||||||
|
|
||||||
|
8.3 关闭自启动的影响
|
||||||
|
• 选择静默模式后,应用不会自启动
|
||||||
|
• 您仅在打开应用时才能接收消息
|
||||||
|
• 此模式最省电,但可能错过实时消息
|
||||||
|
• 关闭自启动不会影响应用内的其他功能使用
|
||||||
|
|
||||||
|
8.4 我们承诺
|
||||||
|
• 自启动行为仅用于消息推送服务,不会用于收集额外个人信息
|
||||||
|
• 自启动行为不会用于广告推送或其他商业目的
|
||||||
|
• 我们仅在用户同意的范围内使用自启动功能`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '九、联系我们',
|
title: '九、隐私政策的更新',
|
||||||
|
content: `9.1 我们可能会不时更新本隐私政策。更新后的隐私政策将在应用内公布,并标注更新日期。
|
||||||
|
|
||||||
|
9.2 对于重大变更,我们会在变更生效前通过应用内通知、弹窗等方式告知您。
|
||||||
|
|
||||||
|
9.3 如您不同意变更后的内容,应立即停止使用本服务。如您继续使用本服务,即视为您已阅读并同意受变更后的隐私政策约束。`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '十、联系我们',
|
||||||
content: `如果您对本隐私政策有任何疑问、意见或建议,或者您希望行使您的权利,请通过以下方式与我们联系:
|
content: `如果您对本隐私政策有任何疑问、意见或建议,或者您希望行使您的权利,请通过以下方式与我们联系:
|
||||||
|
|
||||||
• 邮箱:system@qczlit.cn
|
• 邮箱:system@qczlit.cn
|
||||||
|
|||||||
@@ -12,25 +12,34 @@
|
|||||||
import { AppState, AppStateStatus, Platform } from 'react-native';
|
import { AppState, AppStateStatus, Platform } from 'react-native';
|
||||||
import { ForegroundServiceModule } from './ForegroundServiceModule';
|
import { ForegroundServiceModule } from './ForegroundServiceModule';
|
||||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||||
|
import {
|
||||||
|
loadAutoStartConsent,
|
||||||
|
saveAutoStartConsent,
|
||||||
|
AutoStartMode,
|
||||||
|
} from '../consent/autoStartConsent';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 后台同步模式
|
* 后台同步模式
|
||||||
|
* 整合自启动同意机制:
|
||||||
|
* - DISABLED(静默模式):不自启动,不注册后台任务
|
||||||
|
* - BATTERY_SAVER(后台模式):用户同意自启动后,使用 WorkManager 后台任务(15分钟间隔)
|
||||||
|
* - REALTIME(实时模式):用户同意自启动后,使用前台服务保活
|
||||||
*/
|
*/
|
||||||
export enum BackgroundSyncMode {
|
export enum BackgroundSyncMode {
|
||||||
/** 省电模式:仅依赖 JPush 推送 */
|
/** 静默模式:不自启动,仅在使用时接收消息 */
|
||||||
BATTERY_SAVER = 'battery_saver',
|
|
||||||
|
|
||||||
/** 实时模式:前台服务保活(仅用于通话) */
|
|
||||||
REALTIME = 'realtime',
|
|
||||||
|
|
||||||
/** 禁用后台同步 */
|
|
||||||
DISABLED = 'disabled',
|
DISABLED = 'disabled',
|
||||||
|
|
||||||
|
/** 后台模式:用户同意自启动,使用 WorkManager 后台任务 */
|
||||||
|
BATTERY_SAVER = 'battery_saver',
|
||||||
|
|
||||||
|
/** 实时模式:用户同意自启动,前台服务保活 */
|
||||||
|
REALTIME = 'realtime',
|
||||||
}
|
}
|
||||||
|
|
||||||
const STORAGE_KEY = 'background_sync_mode';
|
const STORAGE_KEY = 'background_sync_mode';
|
||||||
|
|
||||||
class BackgroundSyncManager {
|
class BackgroundSyncManager {
|
||||||
private mode: BackgroundSyncMode = BackgroundSyncMode.BATTERY_SAVER;
|
private mode: BackgroundSyncMode = BackgroundSyncMode.DISABLED;
|
||||||
private appStateSubscription: ReturnType<typeof AppState.addEventListener> | null = null;
|
private appStateSubscription: ReturnType<typeof AppState.addEventListener> | null = null;
|
||||||
private isInitialized: boolean = false;
|
private isInitialized: boolean = false;
|
||||||
private lastSyncAt: number = 0;
|
private lastSyncAt: number = 0;
|
||||||
@@ -64,8 +73,18 @@ class BackgroundSyncManager {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 切换后台同步模式
|
* 切换后台同步模式
|
||||||
|
* 需要用户同意自启动才能切换到 BATTERY_SAVER 或 REALTIME 模式
|
||||||
*/
|
*/
|
||||||
async setMode(mode: BackgroundSyncMode): Promise<void> {
|
async setMode(mode: BackgroundSyncMode): Promise<void> {
|
||||||
|
// 如果要切换到需要自启动的模式,先检查用户是否同意
|
||||||
|
if (mode !== BackgroundSyncMode.DISABLED) {
|
||||||
|
const consent = await loadAutoStartConsent();
|
||||||
|
if (!consent.consented || consent.mode !== AutoStartMode.BACKGROUND) {
|
||||||
|
console.log('[BackgroundSyncManager] 用户未同意自启动,无法切换到模式:', mode);
|
||||||
|
throw new Error('用户未同意自启动');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (AppState.currentState !== 'active' && this.mode === BackgroundSyncMode.REALTIME) {
|
if (AppState.currentState !== 'active' && this.mode === BackgroundSyncMode.REALTIME) {
|
||||||
await ForegroundServiceModule.stop();
|
await ForegroundServiceModule.stop();
|
||||||
}
|
}
|
||||||
@@ -161,9 +180,9 @@ class BackgroundSyncManager {
|
|||||||
private async loadMode(): Promise<BackgroundSyncMode> {
|
private async loadMode(): Promise<BackgroundSyncMode> {
|
||||||
try {
|
try {
|
||||||
const saved = await AsyncStorage.getItem(STORAGE_KEY);
|
const saved = await AsyncStorage.getItem(STORAGE_KEY);
|
||||||
return (saved as BackgroundSyncMode) || BackgroundSyncMode.BATTERY_SAVER;
|
return (saved as BackgroundSyncMode) || BackgroundSyncMode.DISABLED;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return BackgroundSyncMode.BATTERY_SAVER;
|
return BackgroundSyncMode.DISABLED;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,11 @@ import {
|
|||||||
} from './BackgroundSyncManager';
|
} from './BackgroundSyncManager';
|
||||||
import { messageService } from '../message/messageService';
|
import { messageService } from '../message/messageService';
|
||||||
import { api } from '../core/api';
|
import { api } from '../core/api';
|
||||||
|
import {
|
||||||
|
loadAutoStartConsent,
|
||||||
|
isAutoStartAllowed,
|
||||||
|
AutoStartMode,
|
||||||
|
} from '../consent/autoStartConsent';
|
||||||
|
|
||||||
// 后台任务名称
|
// 后台任务名称
|
||||||
const BACKGROUND_SYNC_TASK = 'background-sync-task';
|
const BACKGROUND_SYNC_TASK = 'background-sync-task';
|
||||||
@@ -36,6 +41,13 @@ TaskManager.defineTask(BACKGROUND_SYNC_TASK, async () => {
|
|||||||
return BackgroundTask.BackgroundTaskResult.Success;
|
return BackgroundTask.BackgroundTaskResult.Success;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 检查用户是否同意自启动
|
||||||
|
const consent = await loadAutoStartConsent();
|
||||||
|
if (!consent.consented || consent.mode !== AutoStartMode.BACKGROUND) {
|
||||||
|
console.log('[BackgroundService] 用户未同意自启动,跳过后台同步');
|
||||||
|
return BackgroundTask.BackgroundTaskResult.Success;
|
||||||
|
}
|
||||||
|
|
||||||
// 执行同步
|
// 执行同步
|
||||||
await syncMessages();
|
await syncMessages();
|
||||||
|
|
||||||
@@ -82,6 +94,7 @@ async function syncMessages(): Promise<void> {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 注册后台任务(expo-background-task)
|
* 注册后台任务(expo-background-task)
|
||||||
|
* 仅在用户同意自启动且模式为 BATTERY_SAVER 时注册
|
||||||
*/
|
*/
|
||||||
async function registerBackgroundTask(): Promise<void> {
|
async function registerBackgroundTask(): Promise<void> {
|
||||||
if (Platform.OS === 'web') {
|
if (Platform.OS === 'web') {
|
||||||
@@ -89,6 +102,13 @@ async function registerBackgroundTask(): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
// 检查用户是否同意自启动
|
||||||
|
const consent = await loadAutoStartConsent();
|
||||||
|
if (!consent.consented || consent.mode !== AutoStartMode.BACKGROUND) {
|
||||||
|
console.log('[BackgroundService] 用户未同意自启动,跳过注册后台任务');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// 检查后台任务状态
|
// 检查后台任务状态
|
||||||
const status = await BackgroundTask.getStatusAsync();
|
const status = await BackgroundTask.getStatusAsync();
|
||||||
if (status !== BackgroundTask.BackgroundTaskStatus.Available) {
|
if (status !== BackgroundTask.BackgroundTaskStatus.Available) {
|
||||||
@@ -129,7 +149,7 @@ async function unregisterBackgroundTask(): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 初始化后台保活服务
|
* 根据用户同意的自启动模式初始化后台服务
|
||||||
*/
|
*/
|
||||||
export async function initBackgroundService(): Promise<boolean> {
|
export async function initBackgroundService(): Promise<boolean> {
|
||||||
if (isInitialized) {
|
if (isInitialized) {
|
||||||
@@ -142,14 +162,21 @@ export async function initBackgroundService(): Promise<boolean> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
// 加载用户同意状态
|
||||||
|
await loadAutoStartConsent();
|
||||||
|
|
||||||
// 设置同步回调
|
// 设置同步回调
|
||||||
backgroundSyncManager.setSyncMessagesCallback(syncMessages);
|
backgroundSyncManager.setSyncMessagesCallback(syncMessages);
|
||||||
|
|
||||||
// 初始化后台同步管理器
|
// 初始化后台同步管理器
|
||||||
await backgroundSyncManager.initialize();
|
await backgroundSyncManager.initialize();
|
||||||
|
|
||||||
// 注册 expo-background-task 任务(用于 BATTERY_SAVER 模式)
|
// 仅在用户同意自启动时注册后台任务
|
||||||
await registerBackgroundTask();
|
if (isAutoStartAllowed()) {
|
||||||
|
await registerBackgroundTask();
|
||||||
|
} else {
|
||||||
|
console.log('[BackgroundService] 用户选择静默模式,不注册后台自启动任务');
|
||||||
|
}
|
||||||
|
|
||||||
isInitialized = true;
|
isInitialized = true;
|
||||||
console.log('[BackgroundService] 初始化完成');
|
console.log('[BackgroundService] 初始化完成');
|
||||||
@@ -160,6 +187,39 @@ export async function initBackgroundService(): Promise<boolean> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 重新初始化后台服务(在用户更改自启动同意后调用)
|
||||||
|
*/
|
||||||
|
export async function reinitBackgroundService(): Promise<boolean> {
|
||||||
|
if (!isInitialized) {
|
||||||
|
return initBackgroundService();
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const consent = await loadAutoStartConsent();
|
||||||
|
|
||||||
|
if (consent.consented && consent.mode === AutoStartMode.BACKGROUND) {
|
||||||
|
// 用户同意自启动,注册后台任务
|
||||||
|
await registerBackgroundTask();
|
||||||
|
// 如果模式是实时模式,启动前台服务
|
||||||
|
if (backgroundSyncManager.getMode() === BackgroundSyncMode.REALTIME) {
|
||||||
|
await backgroundSyncManager.setMode(BackgroundSyncMode.REALTIME);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 用户拒绝自启动,取消后台任务
|
||||||
|
await unregisterBackgroundTask();
|
||||||
|
// 停止前台服务
|
||||||
|
await backgroundSyncManager.setMode(BackgroundSyncMode.DISABLED);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('[BackgroundService] 根据用户同意状态重新初始化完成');
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[BackgroundService] 重新初始化失败:', error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 停止后台保活服务
|
* 停止后台保活服务
|
||||||
*/
|
*/
|
||||||
@@ -205,6 +265,7 @@ export async function checkBackgroundStatus(): Promise<{
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 设置后台同步模式
|
* 设置后台同步模式
|
||||||
|
* 同时更新自启动同意状态
|
||||||
*/
|
*/
|
||||||
export async function setBackgroundSyncMode(mode: BackgroundSyncMode): Promise<void> {
|
export async function setBackgroundSyncMode(mode: BackgroundSyncMode): Promise<void> {
|
||||||
await backgroundSyncManager.setMode(mode);
|
await backgroundSyncManager.setMode(mode);
|
||||||
@@ -239,6 +300,7 @@ export { BackgroundSyncMode };
|
|||||||
// 后台服务实例
|
// 后台服务实例
|
||||||
export const backgroundService = {
|
export const backgroundService = {
|
||||||
init: initBackgroundService,
|
init: initBackgroundService,
|
||||||
|
reinit: reinitBackgroundService,
|
||||||
stop: stopBackgroundService,
|
stop: stopBackgroundService,
|
||||||
setMode: setBackgroundSyncMode,
|
setMode: setBackgroundSyncMode,
|
||||||
getMode: getBackgroundSyncMode,
|
getMode: getBackgroundSyncMode,
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
export {
|
export {
|
||||||
backgroundService,
|
backgroundService,
|
||||||
initBackgroundService,
|
initBackgroundService,
|
||||||
|
reinitBackgroundService,
|
||||||
stopBackgroundService,
|
stopBackgroundService,
|
||||||
triggerVibration,
|
triggerVibration,
|
||||||
vibrateOnMessage,
|
vibrateOnMessage,
|
||||||
|
|||||||
201
src/services/consent/autoStartConsent.ts
Normal file
201
src/services/consent/autoStartConsent.ts
Normal file
@@ -0,0 +1,201 @@
|
|||||||
|
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||||
|
import { Platform } from 'react-native';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 自启动权限同意管理
|
||||||
|
*
|
||||||
|
* 管理用户对应用自启动/关联启动行为的同意状态
|
||||||
|
* 符合隐私合规要求:未经用户同意不得进行自启动
|
||||||
|
*/
|
||||||
|
|
||||||
|
const STORAGE_KEY = 'auto_start_consent';
|
||||||
|
const STORAGE_KEY_FIRST_LAUNCH = 'auto_start_first_launch';
|
||||||
|
|
||||||
|
export enum AutoStartMode {
|
||||||
|
/** 静默模式:不自启动,仅在使用时接收消息 */
|
||||||
|
SILENT = 'silent',
|
||||||
|
/** 后台模式:允许自启动以接收实时推送 */
|
||||||
|
BACKGROUND = 'background',
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AutoStartConsent {
|
||||||
|
/** 用户是否同意自启动 */
|
||||||
|
consented: boolean;
|
||||||
|
/** 当前模式 */
|
||||||
|
mode: AutoStartMode;
|
||||||
|
/** 同意时间 */
|
||||||
|
consentedAt?: string;
|
||||||
|
/** 用户同意的目的描述 */
|
||||||
|
purpose?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_CONSENT: AutoStartConsent = {
|
||||||
|
consented: false,
|
||||||
|
mode: AutoStartMode.SILENT,
|
||||||
|
};
|
||||||
|
|
||||||
|
let cachedConsent: AutoStartConsent = { ...DEFAULT_CONSENT };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 加载自启动同意状态
|
||||||
|
*/
|
||||||
|
export async function loadAutoStartConsent(): Promise<AutoStartConsent> {
|
||||||
|
if (Platform.OS === 'web') {
|
||||||
|
return { ...DEFAULT_CONSENT };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const raw = await AsyncStorage.getItem(STORAGE_KEY);
|
||||||
|
if (raw) {
|
||||||
|
cachedConsent = JSON.parse(raw) as AutoStartConsent;
|
||||||
|
return { ...cachedConsent };
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[AutoStartConsent] 加载同意状态失败:', error);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ...DEFAULT_CONSENT };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 保存自启动同意状态
|
||||||
|
*/
|
||||||
|
export async function saveAutoStartConsent(consent: AutoStartConsent): Promise<void> {
|
||||||
|
if (Platform.OS === 'web') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
cachedConsent = { ...consent };
|
||||||
|
await AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(consent));
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[AutoStartConsent] 保存同意状态失败:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取同步的自启动同意状态
|
||||||
|
*/
|
||||||
|
export function getAutoStartConsentSync(): AutoStartConsent {
|
||||||
|
return { ...cachedConsent };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查是否是首次启动(用于显示首次同意弹窗)
|
||||||
|
*/
|
||||||
|
export async function isFirstLaunch(): Promise<boolean> {
|
||||||
|
if (Platform.OS === 'web') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const raw = await AsyncStorage.getItem(STORAGE_KEY_FIRST_LAUNCH);
|
||||||
|
if (raw === null) {
|
||||||
|
await AsyncStorage.setItem(STORAGE_KEY_FIRST_LAUNCH, 'false');
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 用户同意自启动(后台模式)
|
||||||
|
* @param purpose 用户同意的目的描述
|
||||||
|
*/
|
||||||
|
export async function consentToAutoStart(purpose: string = '接收实时消息推送'): Promise<void> {
|
||||||
|
await saveAutoStartConsent({
|
||||||
|
consented: true,
|
||||||
|
mode: AutoStartMode.BACKGROUND,
|
||||||
|
consentedAt: new Date().toISOString(),
|
||||||
|
purpose,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 用户拒绝自启动(静默模式)
|
||||||
|
*/
|
||||||
|
export async function rejectAutoStart(): Promise<void> {
|
||||||
|
await saveAutoStartConsent({
|
||||||
|
consented: false,
|
||||||
|
mode: AutoStartMode.SILENT,
|
||||||
|
consentedAt: new Date().toISOString(),
|
||||||
|
purpose: '用户拒绝自启动',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 切换自启动模式
|
||||||
|
*/
|
||||||
|
export async function setAutoStartMode(mode: AutoStartMode): Promise<void> {
|
||||||
|
const current = getAutoStartConsentSync();
|
||||||
|
|
||||||
|
if (mode === AutoStartMode.BACKGROUND) {
|
||||||
|
await saveAutoStartConsent({
|
||||||
|
consented: true,
|
||||||
|
mode: AutoStartMode.BACKGROUND,
|
||||||
|
consentedAt: new Date().toISOString(),
|
||||||
|
purpose: current.purpose || '接收实时消息推送',
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await saveAutoStartConsent({
|
||||||
|
consented: false,
|
||||||
|
mode: AutoStartMode.SILENT,
|
||||||
|
consentedAt: new Date().toISOString(),
|
||||||
|
purpose: '用户选择静默模式',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查是否允许自启动
|
||||||
|
*/
|
||||||
|
export function isAutoStartAllowed(): boolean {
|
||||||
|
return cachedConsent.consented && cachedConsent.mode === AutoStartMode.BACKGROUND;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取当前模式
|
||||||
|
*/
|
||||||
|
export function getCurrentAutoStartMode(): AutoStartMode {
|
||||||
|
return cachedConsent.mode;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 重置同意状态(用于测试或用户撤销同意)
|
||||||
|
*/
|
||||||
|
export async function resetAutoStartConsent(): Promise<void> {
|
||||||
|
cachedConsent = { ...DEFAULT_CONSENT };
|
||||||
|
await AsyncStorage.removeItem(STORAGE_KEY);
|
||||||
|
await AsyncStorage.removeItem(STORAGE_KEY_FIRST_LAUNCH);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取自启动说明文本(用于隐私政策和弹窗)
|
||||||
|
*/
|
||||||
|
export function getAutoStartDescription(): string {
|
||||||
|
return `为了及时接收消息推送,应用需要在以下场景自启动:
|
||||||
|
|
||||||
|
1. 设备开机完成后:恢复后台消息推送服务
|
||||||
|
2. 应用更新后:恢复后台任务调度
|
||||||
|
3. 系统重启后:恢复通知服务
|
||||||
|
|
||||||
|
自启动行为仅用于消息推送,不会收集额外个人信息。
|
||||||
|
|
||||||
|
您可以在"设置-通知设置"中随时更改此选项。`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取自启动目的说明(用于隐私政策)
|
||||||
|
*/
|
||||||
|
export function getAutoStartPurposeForPrivacy(): string {
|
||||||
|
return `应用自启动/关联启动的目的与规则:
|
||||||
|
|
||||||
|
• 目的:确保用户能够及时接收消息推送通知
|
||||||
|
• 场景:设备开机、应用更新、系统重启后
|
||||||
|
• 规则:仅在用户同意后才启用自启动功能
|
||||||
|
• 必要性:对于需要实时消息通知的用户是必要的
|
||||||
|
• 用户控制:用户可随时在设置中关闭此功能
|
||||||
|
• 关闭影响:关闭后需手动打开应用才能接收消息`;
|
||||||
|
}
|
||||||
16
src/services/consent/index.ts
Normal file
16
src/services/consent/index.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
export {
|
||||||
|
AutoStartMode,
|
||||||
|
loadAutoStartConsent,
|
||||||
|
saveAutoStartConsent,
|
||||||
|
getAutoStartConsentSync,
|
||||||
|
isFirstLaunch,
|
||||||
|
consentToAutoStart,
|
||||||
|
rejectAutoStart,
|
||||||
|
setAutoStartMode,
|
||||||
|
isAutoStartAllowed,
|
||||||
|
getCurrentAutoStartMode,
|
||||||
|
resetAutoStartConsent,
|
||||||
|
getAutoStartDescription,
|
||||||
|
getAutoStartPurposeForPrivacy,
|
||||||
|
} from './autoStartConsent';
|
||||||
|
export type { AutoStartConsent } from './autoStartConsent';
|
||||||
@@ -112,8 +112,10 @@ class JPushService {
|
|||||||
// Register listeners only once (idempotent guard)
|
// Register listeners only once (idempotent guard)
|
||||||
this._registerListeners();
|
this._registerListeners();
|
||||||
|
|
||||||
// 退后台保持极光长连接,确保推送实时到达
|
// 检查用户是否同意自启动,仅在同意后才保持后台长连接
|
||||||
this._setBackgroundKeepLongConn(true);
|
const { isAutoStartAllowed } = await import('../consent/autoStartConsent');
|
||||||
|
const keepLongConn = isAutoStartAllowed();
|
||||||
|
this._setBackgroundKeepLongConn(keepLongConn);
|
||||||
|
|
||||||
// 初始化 SDK
|
// 初始化 SDK
|
||||||
JPush!.init({
|
JPush!.init({
|
||||||
|
|||||||
Reference in New Issue
Block a user