Compare commits
141 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cdbcd20d65 | ||
|
|
79a1114069 | ||
|
|
d724e5c615 | ||
|
|
73225b7151 | ||
|
|
546c9e1410 | ||
|
|
c0c395b100 | ||
|
|
beede425df | ||
|
|
ed66ae2998 | ||
|
|
9934bd2f23 | ||
|
|
cb2c0dded9 | ||
|
|
d4a5a60cc1 | ||
|
|
cbe9754fba | ||
|
|
f3fb9667d8 | ||
|
|
347115f8d3 | ||
|
|
d19257869b | ||
|
|
5978717065 | ||
|
|
c988c489e7 | ||
|
|
0e549a1565 | ||
|
|
2be1c3258d | ||
|
|
c13759e99a | ||
|
|
fb94a2e8a3 | ||
|
|
5651d48d96 | ||
|
|
8f8c696431 | ||
|
|
e408a505ab | ||
|
|
335cc00dfb | ||
|
|
ded49b8b1b | ||
|
|
d9593fa629 | ||
|
|
a20496329c | ||
|
|
29f05a2d7f | ||
|
|
ac2e39eccf | ||
|
|
a18fc8ce37 | ||
|
|
cab8c69f72 | ||
|
|
4bd13a9d09 | ||
|
|
616ae0d3b3 | ||
|
|
c0c5503864 | ||
|
|
98e04a81cb | ||
|
|
61dcfd9b57 | ||
|
|
947bb399c8 | ||
|
|
fa5c7e0771 | ||
|
|
dde94aeffc | ||
|
|
4e79a22b43 | ||
|
|
5037f393f7 | ||
|
|
7d30e3b632 | ||
|
|
05b4e01d27 | ||
|
|
5b12844a24 | ||
|
|
e23bf443a1 | ||
|
|
b5a31bee7b | ||
|
|
9901812db6 | ||
|
|
0767289210 | ||
|
|
5de180336e | ||
|
|
632df791bd | ||
|
|
c09a5df615 | ||
|
|
9d5504dee0 | ||
|
|
0ad9d0a5dc | ||
|
|
7499b08494 | ||
|
|
ec6e4deae8 | ||
|
|
839ab8232a | ||
|
|
50def2be77 | ||
|
|
cf1f691762 | ||
|
|
22bbadf926 | ||
|
|
760d652f5f | ||
|
|
22ee866205 | ||
|
|
ce3361992c | ||
|
|
768a3b7c09 | ||
|
|
b0715b3f27 | ||
|
|
b6435f9010 | ||
|
|
e0431bd001 | ||
|
|
d86d9db508 | ||
|
|
068b272b5e | ||
|
|
0aa1317950 | ||
|
|
5a4fdd3664 | ||
|
|
65086b79b3 | ||
|
|
e201d7046c | ||
|
|
1e55a83ca0 | ||
|
|
f95a4d8552 | ||
|
|
ff097fbe41 | ||
|
|
4b92304d84 | ||
|
|
9c226d1bb9 | ||
|
|
7005fdf567 | ||
|
|
10a820e93e | ||
|
|
cd8d358fa6 | ||
|
|
73616f2158 | ||
|
|
8e1628e28c | ||
|
|
9223d2debc | ||
|
|
d61eb68d8b | ||
|
|
3dccadc5c0 | ||
|
|
98f0db6a15 | ||
|
|
9fa96125b2 | ||
|
|
992a48fa81 | ||
|
|
360540edb4 | ||
|
|
4a17989c96 | ||
|
|
81796c8824 | ||
|
|
73ed1a9647 | ||
|
|
2faa368722 | ||
|
|
2913cfe794 | ||
|
|
4e3ef029a1 | ||
|
|
3e07d4c419 | ||
|
|
83e8865266 | ||
|
|
dbf6de8a65 | ||
|
|
6f4e746a66 | ||
|
|
df76d13904 | ||
|
|
7b7bceec80 | ||
|
|
61bcb6d743 | ||
|
|
0689cc2005 | ||
|
|
279e847d00 | ||
|
|
a859d4825b | ||
|
|
25dd438da2 | ||
|
|
2c9f5d45e2 | ||
|
|
e89628e5c2 | ||
|
|
6f0639b8e9 | ||
|
|
ba607fb419 | ||
|
|
b682ee8432 | ||
|
|
7d7488d05e | ||
|
|
4f0db150b0 | ||
|
|
810afd88f7 | ||
|
|
ed6786e65b | ||
|
|
0db1c5036e | ||
|
|
cc7539d262 | ||
|
|
ca37953476 | ||
|
|
050dea3214 | ||
|
|
724d7efb1e | ||
|
|
a855983c46 | ||
|
|
7dd9ada91d | ||
|
|
3d48b14def | ||
|
|
6d1eb89adf | ||
|
|
b44558babd | ||
|
|
e97a37d18e | ||
|
|
281d55689a | ||
|
|
7475998961 | ||
|
|
f01b6c2f9a | ||
|
|
98f8d6ebea | ||
|
|
015226cb40 | ||
|
|
ad06b0dffb | ||
|
|
4b4d64e8eb | ||
|
|
3eec22c615 | ||
|
|
9a2e6065dc | ||
|
|
8e00c29a59 | ||
|
|
9ec9517ec5 | ||
|
|
e822fc20b4 | ||
|
|
f3c794fc47 | ||
|
|
6e6d311c23 |
98
.github/workflows/debug.yml
vendored
98
.github/workflows/debug.yml
vendored
@@ -105,101 +105,3 @@ jobs:
|
||||
with:
|
||||
name: purrfectsnap-armv8-debug
|
||||
path: app/build/outputs/apk/armv8/debug/*.apk
|
||||
job_armv7:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
armv7_apk: ${{ steps.upload_apk.outputs.artifact-paths }}
|
||||
steps:
|
||||
- name: Checkout repo
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
submodules: 'recursive'
|
||||
- name: Set up JDK 21
|
||||
uses: actions/setup-java@v4
|
||||
with:
|
||||
java-version: '21'
|
||||
distribution: 'temurin'
|
||||
cache: gradle
|
||||
- name: Grant execute permission for gradlew
|
||||
run: chmod +x gradlew
|
||||
- name: Setup NPM Dependencies
|
||||
run: npm install typescript -g
|
||||
- name: Add Android targets for Rust
|
||||
run: rustup target add armv7-linux-androideabi aarch64-linux-android
|
||||
- name: Prepare release keystore for Gradle signing
|
||||
env:
|
||||
SIGNING_KEY_BASE64: ${{ secrets.PS_BASE_64 }}
|
||||
SIGNING_KEY_ALIAS: ${{ secrets.PS_RELEASE_KEY_ALIAS }}
|
||||
SIGNING_KEY_PASSWORD: ${{ secrets.PS_RELEASE_KEY_PASSWORD }}
|
||||
SIGNING_STORE_PASSWORD: ${{ secrets.PS_RELEASE_STORE_PASSWORD }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ -z "${SIGNING_KEY_BASE64}" ] || [ -z "${SIGNING_KEY_ALIAS}" ] || [ -z "${SIGNING_STORE_PASSWORD}" ] || [ -z "${SIGNING_KEY_PASSWORD}" ]; then
|
||||
echo "Signing secrets are missing. Ensure PS_BASE_64, PS_RELEASE_KEY_ALIAS, PS_RELEASE_STORE_PASSWORD, PS_RELEASE_KEY_PASSWORD are set." >&2
|
||||
exit 1
|
||||
fi
|
||||
mkdir -p "${HOME}/.android"
|
||||
printf '%s' "${SIGNING_KEY_BASE64}" | base64 --decode > "${HOME}/.android/purrfectsnap-release.keystore"
|
||||
if [ ! -s "${HOME}/.android/purrfectsnap-release.keystore" ]; then
|
||||
echo "Decoded keystore is empty or invalid." >&2
|
||||
exit 1
|
||||
fi
|
||||
mkdir -p "${HOME}/.gradle"
|
||||
{
|
||||
echo "PS_RELEASE_STORE_PASSWORD=${SIGNING_STORE_PASSWORD}"
|
||||
echo "PS_RELEASE_KEY_ALIAS=${SIGNING_KEY_ALIAS}"
|
||||
echo "PS_RELEASE_KEY_PASSWORD=${SIGNING_KEY_PASSWORD}"
|
||||
} >> "${HOME}/.gradle/gradle.properties"
|
||||
- name: Build
|
||||
run: ./gradlew --configuration-cache assembleArmv7Debug
|
||||
- name: Determine the latest Build Tools version installed
|
||||
shell: bash
|
||||
run: |
|
||||
SDK_ROOT="${ANDROID_SDK_ROOT:-$ANDROID_HOME}"
|
||||
echo "SDK_ROOT=${SDK_ROOT}" >> $GITHUB_ENV
|
||||
echo "BUILD_TOOL_VERSION=$(ls "${SDK_ROOT}/build-tools/" | tail -n 1)" >> $GITHUB_ENV
|
||||
- name: Sign APK
|
||||
env:
|
||||
BUILD_TOOLS_VERSION: ${{ env.BUILD_TOOL_VERSION }}
|
||||
SIGNING_KEY_BASE64: ${{ secrets.PS_BASE_64 }}
|
||||
SIGNING_KEY_ALIAS: ${{ secrets.PS_RELEASE_KEY_ALIAS }}
|
||||
SIGNING_KEY_PASSWORD: ${{ secrets.PS_RELEASE_KEY_PASSWORD }}
|
||||
SIGNING_STORE_PASSWORD: ${{ secrets.PS_RELEASE_STORE_PASSWORD }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
SIGNING_DIR="app/build/outputs/apk/armv7/debug"
|
||||
KEYSTORE_PATH="${SIGNING_DIR}/signingKey.jks"
|
||||
if [ -z "${SIGNING_KEY_BASE64}" ] || [ -z "${SIGNING_KEY_ALIAS}" ] || [ -z "${SIGNING_STORE_PASSWORD}" ] || [ -z "${SIGNING_KEY_PASSWORD}" ]; then
|
||||
echo "Signing secrets are missing. Ensure PS_BASE_64, PS_RELEASE_KEY_ALIAS, PS_RELEASE_STORE_PASSWORD, PS_RELEASE_KEY_PASSWORD are set." >&2
|
||||
exit 1
|
||||
fi
|
||||
printf '%s' "${SIGNING_KEY_BASE64}" | base64 --decode > "${KEYSTORE_PATH}"
|
||||
if [ ! -s "${KEYSTORE_PATH}" ]; then
|
||||
echo "Decoded keystore is empty or invalid." >&2
|
||||
exit 1
|
||||
fi
|
||||
APK_INPUT=$(ls "${SIGNING_DIR}"/*.apk | head -n 1)
|
||||
"${ANDROID_HOME}/build-tools/${BUILD_TOOLS_VERSION}/zipalign" -v 4 "${APK_INPUT}" "${SIGNING_DIR}/purrfectsnap-armv7Debug-aligned.apk"
|
||||
"${ANDROID_HOME}/build-tools/${BUILD_TOOLS_VERSION}/apksigner" sign \
|
||||
--ks "${KEYSTORE_PATH}" \
|
||||
--ks-key-alias "${SIGNING_KEY_ALIAS}" \
|
||||
--ks-pass "pass:${SIGNING_STORE_PASSWORD}" \
|
||||
--key-pass "pass:${SIGNING_KEY_PASSWORD}" \
|
||||
--ks-type JKS \
|
||||
--out "${SIGNING_DIR}/purrfectsnap-armv7Debug-signed.apk" \
|
||||
"${SIGNING_DIR}/purrfectsnap-armv7Debug-aligned.apk"
|
||||
- name: Get current build version
|
||||
id: version-env
|
||||
run: |
|
||||
./gradlew --configuration-cache getVersion
|
||||
echo "version=$(cat app/build/version.txt)" >> $GITHUB_ENV
|
||||
- name: Delete unsigned APK file and rename the signed one
|
||||
run: |
|
||||
find app/build/outputs/apk/armv7/debug/ -type f ! -name '*-signed*' -delete
|
||||
mv app/build/outputs/apk/armv7/debug/purrfectsnap-armv7Debug-signed.apk app/build/outputs/apk/armv7/debug/purrfectsnap-${{ env.version }}-armv7-${GITHUB_SHA::7}.apk
|
||||
- name: Upload artifact
|
||||
id: upload_apk
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: purrfectsnap-armv7-debug
|
||||
path: app/build/outputs/apk/armv7/debug/*.apk
|
||||
|
||||
47
.github/workflows/pull_request.yml
vendored
47
.github/workflows/pull_request.yml
vendored
@@ -26,7 +26,7 @@ jobs:
|
||||
run: npm install typescript -g
|
||||
|
||||
- name: Add Android targets for Rust
|
||||
run: rustup target add armv7-linux-androideabi aarch64-linux-android
|
||||
run: rustup target add aarch64-linux-android
|
||||
|
||||
- name: Build
|
||||
run: ./gradlew --configuration-cache assembleArmv8Debug
|
||||
@@ -46,49 +46,6 @@ jobs:
|
||||
with:
|
||||
name: purrfectsnap-armv8-debug
|
||||
path: app/build/outputs/apk/armv8/debug/*.apk
|
||||
|
||||
job_armv7:
|
||||
runs-on: macos-latest
|
||||
steps:
|
||||
- name: Checkout repo
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
submodules: 'recursive'
|
||||
|
||||
- name: Set up JDK 21
|
||||
uses: actions/setup-java@v4
|
||||
with:
|
||||
java-version: '21'
|
||||
distribution: 'temurin'
|
||||
cache: gradle
|
||||
|
||||
- name: Grant execute permission for gradlew
|
||||
run: chmod +x gradlew
|
||||
|
||||
- name: Setup NPM Dependencies
|
||||
run: npm install typescript -g
|
||||
|
||||
- name: Add Android targets for Rust
|
||||
run: rustup target add armv7-linux-androideabi aarch64-linux-android
|
||||
|
||||
- name: Build
|
||||
run: ./gradlew --configuration-cache assembleArmv7Debug
|
||||
|
||||
- name: Get current build version
|
||||
id: version-env
|
||||
run: |
|
||||
./gradlew --configuration-cache getVersion
|
||||
echo "version=$(cat app/build/version.txt)" >> $GITHUB_ENV
|
||||
|
||||
- name: Rename APK file
|
||||
run: |
|
||||
mv app/build/outputs/apk/armv7/debug/*.apk app/build/outputs/apk/armv7/debug/purrfectsnap-${{ env.version }}-armv7-${GITHUB_SHA::7}.apk
|
||||
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: purrfectsnap-armv7-debug
|
||||
path: app/build/outputs/apk/armv7/debug/*.apk
|
||||
|
||||
job_manager:
|
||||
runs-on: ubuntu-latest
|
||||
@@ -136,7 +93,7 @@ jobs:
|
||||
run: chmod +x gradlew
|
||||
|
||||
- name: Add Android targets for Rust
|
||||
run: rustup target add armv7-linux-androideabi aarch64-linux-android
|
||||
run: rustup target add aarch64-linux-android
|
||||
|
||||
- name: Build
|
||||
run: ./gradlew --configuration-cache assembleCoreDebug
|
||||
|
||||
114
.github/workflows/release.yml
vendored
114
.github/workflows/release.yml
vendored
@@ -6,6 +6,8 @@ on:
|
||||
description: 'Upload to CI channel'
|
||||
required: false
|
||||
type: boolean
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
||||
jobs:
|
||||
job_armv8:
|
||||
runs-on: ubuntu-latest
|
||||
@@ -13,11 +15,11 @@ jobs:
|
||||
armv8_apk: ${{ steps.upload_apk.outputs.artifact-paths }}
|
||||
steps:
|
||||
- name: Checkout repo
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v4.2.2
|
||||
with:
|
||||
submodules: 'recursive'
|
||||
- name: Set up JDK 21
|
||||
uses: actions/setup-java@v4
|
||||
uses: actions/setup-java@v4.7.0
|
||||
with:
|
||||
java-version: '21'
|
||||
distribution: 'temurin'
|
||||
@@ -27,7 +29,7 @@ jobs:
|
||||
- name: Setup NPM Dependencies
|
||||
run: npm install typescript -g
|
||||
- name: Add Android targets for Rust
|
||||
run: rustup target add armv7-linux-androideabi aarch64-linux-android
|
||||
run: rustup target add aarch64-linux-android
|
||||
- name: Prepare release keystore for Gradle signing
|
||||
env:
|
||||
SIGNING_KEY_BASE64: ${{ secrets.PS_BASE_64 }}
|
||||
@@ -101,118 +103,20 @@ jobs:
|
||||
mv app/build/outputs/apk/armv8/release/purrfectsnap-armv8Release-signed.apk app/build/outputs/apk/armv8/release/purrfectsnap_${{ env.version }}-armv8Release.apk
|
||||
- name: Upload artifact
|
||||
id: upload_apk
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v4.6.2
|
||||
with:
|
||||
name: purrfectsnap-armv8-release
|
||||
path: app/build/outputs/apk/armv8/release/*.apk
|
||||
job_armv7:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
armv7_apk: ${{ steps.upload_apk.outputs.artifact-paths }}
|
||||
steps:
|
||||
- name: Checkout repo
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
submodules: 'recursive'
|
||||
- name: Set up JDK 21
|
||||
uses: actions/setup-java@v4
|
||||
with:
|
||||
java-version: '21'
|
||||
distribution: 'temurin'
|
||||
cache: gradle
|
||||
- name: Grant execute permission for gradlew
|
||||
run: chmod +x gradlew
|
||||
- name: Setup NPM Dependencies
|
||||
run: npm install typescript -g
|
||||
- name: Add Android targets for Rust
|
||||
run: rustup target add armv7-linux-androideabi aarch64-linux-android
|
||||
- name: Prepare release keystore for Gradle signing
|
||||
env:
|
||||
SIGNING_KEY_BASE64: ${{ secrets.PS_BASE_64 }}
|
||||
SIGNING_KEY_ALIAS: ${{ secrets.PS_RELEASE_KEY_ALIAS }}
|
||||
SIGNING_KEY_PASSWORD: ${{ secrets.PS_RELEASE_KEY_PASSWORD }}
|
||||
SIGNING_STORE_PASSWORD: ${{ secrets.PS_RELEASE_STORE_PASSWORD }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ -z "${SIGNING_KEY_BASE64}" ] || [ -z "${SIGNING_KEY_ALIAS}" ] || [ -z "${SIGNING_STORE_PASSWORD}" ] || [ -z "${SIGNING_KEY_PASSWORD}" ]; then
|
||||
echo "Signing secrets are missing. Ensure PS_BASE_64, PS_RELEASE_KEY_ALIAS, PS_RELEASE_STORE_PASSWORD, PS_RELEASE_KEY_PASSWORD are set." >&2
|
||||
exit 1
|
||||
fi
|
||||
mkdir -p "${HOME}/.android"
|
||||
printf '%s' "${SIGNING_KEY_BASE64}" | base64 --decode > "${HOME}/.android/purrfectsnap-release.keystore"
|
||||
if [ ! -s "${HOME}/.android/purrfectsnap-release.keystore" ]; then
|
||||
echo "Decoded keystore is empty or invalid." >&2
|
||||
exit 1
|
||||
fi
|
||||
mkdir -p "${HOME}/.gradle"
|
||||
{
|
||||
echo "PS_RELEASE_STORE_PASSWORD=${SIGNING_STORE_PASSWORD}"
|
||||
echo "PS_RELEASE_KEY_ALIAS=${SIGNING_KEY_ALIAS}"
|
||||
echo "PS_RELEASE_KEY_PASSWORD=${SIGNING_KEY_PASSWORD}"
|
||||
} >> "${HOME}/.gradle/gradle.properties"
|
||||
- name: Build
|
||||
run: ./gradlew --configuration-cache assembleArmv7Release
|
||||
- name: Determine the latest Build Tools version installed
|
||||
shell: bash
|
||||
run: |
|
||||
SDK_ROOT="${ANDROID_SDK_ROOT:-$ANDROID_HOME}"
|
||||
echo "SDK_ROOT=${SDK_ROOT}" >> $GITHUB_ENV
|
||||
echo "BUILD_TOOL_VERSION=$(ls "${SDK_ROOT}/build-tools/" | tail -n 1)" >> $GITHUB_ENV
|
||||
- name: Sign APK
|
||||
env:
|
||||
BUILD_TOOLS_VERSION: ${{ env.BUILD_TOOL_VERSION }}
|
||||
SIGNING_KEY_BASE64: ${{ secrets.PS_BASE_64 }}
|
||||
SIGNING_KEY_ALIAS: ${{ secrets.PS_RELEASE_KEY_ALIAS }}
|
||||
SIGNING_KEY_PASSWORD: ${{ secrets.PS_RELEASE_KEY_PASSWORD }}
|
||||
SIGNING_STORE_PASSWORD: ${{ secrets.PS_RELEASE_STORE_PASSWORD }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
SIGNING_DIR="app/build/outputs/apk/armv7/release"
|
||||
KEYSTORE_PATH="${SIGNING_DIR}/signingKey.jks"
|
||||
if [ -z "${SIGNING_KEY_BASE64}" ] || [ -z "${SIGNING_KEY_ALIAS}" ] || [ -z "${SIGNING_STORE_PASSWORD}" ] || [ -z "${SIGNING_KEY_PASSWORD}" ]; then
|
||||
echo "Signing secrets are missing. Ensure PS_BASE_64, PS_RELEASE_KEY_ALIAS, PS_RELEASE_STORE_PASSWORD, PS_RELEASE_KEY_PASSWORD are set." >&2
|
||||
exit 1
|
||||
fi
|
||||
printf '%s' "${SIGNING_KEY_BASE64}" | base64 --decode > "${KEYSTORE_PATH}"
|
||||
if [ ! -s "${KEYSTORE_PATH}" ]; then
|
||||
echo "Decoded keystore is empty or invalid." >&2
|
||||
exit 1
|
||||
fi
|
||||
APK_INPUT=$(ls "${SIGNING_DIR}"/*.apk | head -n 1)
|
||||
"${SDK_ROOT}/build-tools/${BUILD_TOOLS_VERSION}/zipalign" -v 4 "${APK_INPUT}" "${SIGNING_DIR}/purrfectsnap-armv7Release-aligned.apk"
|
||||
"${SDK_ROOT}/build-tools/${BUILD_TOOLS_VERSION}/apksigner" sign \
|
||||
--ks "${KEYSTORE_PATH}" \
|
||||
--ks-key-alias "${SIGNING_KEY_ALIAS}" \
|
||||
--ks-pass "pass:${SIGNING_STORE_PASSWORD}" \
|
||||
--key-pass "pass:${SIGNING_KEY_PASSWORD}" \
|
||||
--ks-type JKS \
|
||||
--out "${SIGNING_DIR}/purrfectsnap-armv7Release-signed.apk" \
|
||||
"${SIGNING_DIR}/purrfectsnap-armv7Release-aligned.apk"
|
||||
- name: Get current build version
|
||||
id: version-env
|
||||
run: |
|
||||
./gradlew --configuration-cache getVersion
|
||||
echo "version=$(cat app/build/version.txt)" >> $GITHUB_ENV
|
||||
- name: Delete unsigned APK file and rename the signed one
|
||||
run: |
|
||||
find app/build/outputs/apk/armv7/release/ -type f ! -name '*-signed*' -delete
|
||||
mv app/build/outputs/apk/armv7/release/purrfectsnap-armv7Release-signed.apk app/build/outputs/apk/armv7/release/purrfectsnap_${{ env.version }}-armv7Release.apk
|
||||
- name: Upload artifact
|
||||
id: upload_apk
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: purrfectsnap-armv7-release
|
||||
path: app/build/outputs/apk/armv7/release/*.apk
|
||||
make_release:
|
||||
needs: [job_armv7, job_armv8]
|
||||
needs: [job_armv8]
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repo
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v4.2.2
|
||||
with:
|
||||
submodules: 'recursive'
|
||||
- name: Download all build artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
uses: actions/download-artifact@v4.2.1
|
||||
with:
|
||||
path: ./all-apks
|
||||
- name: Display all files for debug
|
||||
|
||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -4,9 +4,11 @@ local.properties
|
||||
/.idea/
|
||||
.DS_Store
|
||||
/build
|
||||
**/build/
|
||||
/captures
|
||||
.externalNativeBuild
|
||||
.cxx
|
||||
.local/
|
||||
native/.omvll/
|
||||
cloudflare/**/.wrangler/
|
||||
cloudflare/**/node_modules/
|
||||
@@ -20,4 +22,4 @@ security/allowed_codes.local.*
|
||||
valdi/node_modules/
|
||||
hs_err_pid*.log
|
||||
replay_pid*.log
|
||||
.vs
|
||||
.vs
|
||||
|
||||
@@ -1 +1 @@
|
||||
- Test
|
||||
- Temporary fix for conversations disappearing: Turn off Block Ads feature
|
||||
|
||||
@@ -113,7 +113,6 @@ android {
|
||||
compose = true
|
||||
buildConfig = true
|
||||
}
|
||||
|
||||
signingConfigs {
|
||||
create("release") {
|
||||
storeFile = File(System.getProperty("user.home"), ".android/purrfectsnap-release.keystore")
|
||||
@@ -166,6 +165,9 @@ android {
|
||||
val releaseKeyAlias = gradleOrEnv("PS_RELEASE_KEY_ALIAS", providers)
|
||||
if (releaseStore.exists() && !releaseStorePass.isNullOrBlank() && !releaseKeyAlias.isNullOrBlank()) {
|
||||
signingConfig = signingConfigs.getByName("release")
|
||||
} else {
|
||||
// Keep local release builds installable when private release credentials are unavailable.
|
||||
signingConfig = signingConfigs.getByName("debug")
|
||||
}
|
||||
}
|
||||
debug {
|
||||
@@ -356,6 +358,7 @@ afterEvaluate {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
properties["debug_flavor"]?.let {
|
||||
|
||||
@@ -5,6 +5,9 @@ import android.content.Intent
|
||||
import android.os.IBinder
|
||||
import android.os.ParcelFileDescriptor
|
||||
import android.os.RemoteException
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import me.eternal.purrfectsnap.RemoteSideContext
|
||||
import me.eternal.purrfectsnap.SharedContextHolder
|
||||
@@ -219,20 +222,42 @@ class BridgeService : Service() {
|
||||
triggerScopeSync(SocialScope.getByName(scope), id, true)
|
||||
}
|
||||
|
||||
private val friendAccumulator = mutableListOf<MessagingFriendInfo>()
|
||||
private val groupAccumulator = mutableListOf<MessagingGroupInfo>()
|
||||
|
||||
override fun passGroupsAndFriends(
|
||||
groups: List<String>,
|
||||
friends: List<String>
|
||||
friends: List<String>,
|
||||
chunkIndex: Int,
|
||||
totalChunks: Int
|
||||
) {
|
||||
remoteSideContext.log.verbose("Received ${groups.size} groups and ${friends.size} friends")
|
||||
val parsedFriends = friends.mapNotNull { toParcelable<MessagingFriendInfo>(it) }
|
||||
val parsedGroups = groups.mapNotNull { toParcelable<MessagingGroupInfo>(it) }
|
||||
pendingSocialSnapshotCallback?.let { callback ->
|
||||
pendingSocialSnapshotCallback = null
|
||||
callback(parsedFriends, parsedGroups)
|
||||
return
|
||||
synchronized(friendAccumulator) {
|
||||
if (chunkIndex == 0) {
|
||||
friendAccumulator.clear()
|
||||
groupAccumulator.clear()
|
||||
}
|
||||
|
||||
remoteSideContext.log.verbose("Received chunk $chunkIndex/$totalChunks: ${groups.size} groups, ${friends.size} friends")
|
||||
friendAccumulator.addAll(friends.mapNotNull { toParcelable<MessagingFriendInfo>(it) })
|
||||
groupAccumulator.addAll(groups.mapNotNull { toParcelable<MessagingGroupInfo>(it) })
|
||||
|
||||
if (chunkIndex == totalChunks - 1) {
|
||||
val finalFriends = friendAccumulator.toList()
|
||||
val finalGroups = groupAccumulator.toList()
|
||||
|
||||
friendAccumulator.clear()
|
||||
groupAccumulator.clear()
|
||||
|
||||
remoteSideContext.coroutineScope.launch(Dispatchers.IO) {
|
||||
pendingSocialSnapshotCallback?.let { callback ->
|
||||
pendingSocialSnapshotCallback = null
|
||||
callback(finalFriends, finalGroups)
|
||||
}
|
||||
remoteSideContext.database.replaceMessagingData(finalFriends, finalGroups)
|
||||
remoteSideContext.database.messagingDataFlow.tryEmit(finalFriends to finalGroups)
|
||||
}
|
||||
}
|
||||
}
|
||||
remoteSideContext.database.replaceMessagingData(parsedFriends, parsedGroups)
|
||||
remoteSideContext.database.receiveMessagingDataCallback(parsedFriends, parsedGroups)
|
||||
}
|
||||
|
||||
override fun getScopeNotes(id: String): String? {
|
||||
|
||||
@@ -121,6 +121,7 @@ class DownloadProcessor (
|
||||
inputFile.outputStream().use {
|
||||
bitmap.compress(compressFormat, 100, it)
|
||||
}
|
||||
bitmap.recycle()
|
||||
fileType = FileType.fromFile(inputFile)
|
||||
}
|
||||
}
|
||||
@@ -282,7 +283,7 @@ class DownloadProcessor (
|
||||
while (true) {
|
||||
val existingFile = outputFileFolder.findFile(finalFileName) ?: break
|
||||
|
||||
if (existingFile.length() == inputFile.length()) {
|
||||
if (existingFile.length() == inputFile.length() && !remoteSideContext.config.root.downloader.allowDuplicate.get()) {
|
||||
val existingInputStream = remoteSideContext.androidContext.contentResolver.openInputStream(existingFile.uri)
|
||||
if (existingInputStream != null && streamsMatch(existingInputStream, inputFile.inputStream())) {
|
||||
return GallerySaveResult(existingFile.uri, alreadyDownloaded = true)
|
||||
@@ -375,7 +376,7 @@ class DownloadProcessor (
|
||||
var destFile = File(destDir, fileName)
|
||||
var suffix = 1
|
||||
while (destFile.exists()) {
|
||||
if (destFile.length() == inputFile.length() && streamsMatch(destFile.inputStream(), inputFile.inputStream())) {
|
||||
if (destFile.length() == inputFile.length() && !remoteSideContext.config.root.downloader.allowDuplicate.get() && streamsMatch(destFile.inputStream(), inputFile.inputStream())) {
|
||||
return GallerySaveResult(Uri.fromFile(destFile), alreadyDownloaded = true)
|
||||
}
|
||||
destFile = File(destDir, appendNameSuffix(fileName, suffix++))
|
||||
@@ -615,32 +616,51 @@ class DownloadProcessor (
|
||||
|
||||
var shouldMergeOverlay = downloadRequest.shouldMergeOverlay
|
||||
|
||||
//if there is a zip file, extract it and replace the downloaded media with the extracted ones
|
||||
//if there is a zip file, extract it and save only the base media (discard overlay)
|
||||
downloadedMedias.values.find { FileType.fromFile(it) == FileType.ZIP }?.let { zipFile ->
|
||||
val oldDownloadedMedias = downloadedMedias.toMap()
|
||||
downloadedMedias.clear()
|
||||
|
||||
// Debug Stories: save raw ZIP to the configured debug folder
|
||||
val debugConfig = remoteSideContext.config.root.developer.debugStories
|
||||
if (debugConfig.enabled.get()) {
|
||||
val debugFolder = debugConfig.saveFolder.get().orEmpty().trim()
|
||||
if (debugFolder.isNotBlank()) {
|
||||
runCatching {
|
||||
val debugDir = android.net.Uri.parse(debugFolder).let { uri ->
|
||||
androidx.documentfile.provider.DocumentFile.fromTreeUri(remoteSideContext.androidContext, uri)
|
||||
}
|
||||
val timestamp = System.currentTimeMillis()
|
||||
val debugFile = debugDir?.createFile("application/zip", "story_debug_${timestamp}.zip")
|
||||
debugFile?.uri?.let { fileUri ->
|
||||
remoteSideContext.androidContext.contentResolver.openOutputStream(fileUri)?.use { out ->
|
||||
zipFile.inputStream().use { it.copyTo(out) }
|
||||
}
|
||||
}
|
||||
}.onFailure {
|
||||
remoteSideContext.log.warn("Debug Stories: failed to save zip: ${it.message}", "DownloadProcessor")
|
||||
}
|
||||
} else {
|
||||
remoteSideContext.log.warn("Debug Stories: enabled but no folder configured", "DownloadProcessor")
|
||||
}
|
||||
}
|
||||
|
||||
var baseMediaFile: File? = null
|
||||
zipFile.inputStream().use { zipFileInputStream ->
|
||||
MediaDownloaderHelper.getSplitElements(zipFileInputStream) { type, inputStream ->
|
||||
createMediaTempFile().apply {
|
||||
outputStream().use {
|
||||
inputStream.copyTo(it)
|
||||
}
|
||||
}.also {
|
||||
downloadedMedias[InputMedia(
|
||||
type = DownloadMediaType.LOCAL_MEDIA,
|
||||
content = it.absolutePath,
|
||||
isOverlay = type == SplitMediaAssetType.OVERLAY
|
||||
)] = it
|
||||
if (type == SplitMediaAssetType.OVERLAY) return@getSplitElements
|
||||
baseMediaFile = createMediaTempFile().apply {
|
||||
outputStream().use { inputStream.copyTo(it) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
oldDownloadedMedias.forEach { (_, value) ->
|
||||
value.delete()
|
||||
}
|
||||
oldDownloadedMedias.forEach { (_, value) -> value.delete() }
|
||||
|
||||
shouldMergeOverlay = true
|
||||
baseMediaFile?.let { baseMedia ->
|
||||
saveMediaToGallery(pendingTask, baseMedia, downloadMetadata)
|
||||
baseMedia.delete()
|
||||
}
|
||||
return@launch
|
||||
}
|
||||
|
||||
if (shouldMergeOverlay) {
|
||||
@@ -648,8 +668,45 @@ class DownloadProcessor (
|
||||
val media = downloadedMedias.entries.first { !it.key.isOverlay }.value
|
||||
val overlayMedia = downloadedMedias.entries.first { it.key.isOverlay }.value
|
||||
|
||||
val renamedMedia = renameFromFileType(media, FileType.fromFile(media))
|
||||
val renamedOverlayMedia = renameFromFileType(overlayMedia, FileType.fromFile(overlayMedia))
|
||||
val mediaFileType = FileType.fromFile(media)
|
||||
val overlayFileType = FileType.fromFile(overlayMedia)
|
||||
|
||||
val renamedMedia = renameFromFileType(media, mediaFileType)
|
||||
val renamedOverlayMedia = renameFromFileType(overlayMedia, overlayFileType)
|
||||
|
||||
if (mediaFileType.isImage && overlayFileType.isImage) {
|
||||
runCatching {
|
||||
callbackOnProgress(translation.format("processing_toast", "path" to media.nameWithoutExtension))
|
||||
val originalBitmap = BitmapFactory.decodeFile(renamedMedia.absolutePath) ?: throw Exception("Failed to decode original image")
|
||||
val overlayBitmap = BitmapFactory.decodeFile(renamedOverlayMedia.absolutePath) ?: throw Exception("Failed to decode overlay image")
|
||||
|
||||
val mergedBitmap = me.eternal.purrfectsnap.core.util.media.PreviewUtils.mergeBitmapOverlay(originalBitmap, overlayBitmap)
|
||||
val mergedImage: File = File.createTempFile("merged", "." + (mediaFileType.fileExtension ?: "jpg"))
|
||||
|
||||
val compressFormat = when (mediaFileType) {
|
||||
FileType.PNG -> Bitmap.CompressFormat.PNG
|
||||
FileType.WEBP -> if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) Bitmap.CompressFormat.WEBP_LOSSLESS else Bitmap.CompressFormat.WEBP
|
||||
else -> Bitmap.CompressFormat.JPEG
|
||||
}
|
||||
|
||||
mergedImage.outputStream().use {
|
||||
mergedBitmap.compress(compressFormat, 100, it)
|
||||
}
|
||||
|
||||
originalBitmap.recycle()
|
||||
overlayBitmap.recycle()
|
||||
mergedBitmap.recycle()
|
||||
|
||||
saveMediaToGallery(pendingTask, mergedImage, downloadMetadata)
|
||||
mergedImage.delete()
|
||||
renamedOverlayMedia.delete()
|
||||
renamedMedia.delete()
|
||||
return@launch
|
||||
}.onFailure {
|
||||
remoteSideContext.log.error("Failed to merge image overlay using Bitmap, falling back to FFmpeg", it)
|
||||
}
|
||||
}
|
||||
|
||||
val mergedOverlay: File = File.createTempFile("merged", ".mp4")
|
||||
runCatching {
|
||||
callbackOnProgress(translation.format("processing_toast", "path" to media.nameWithoutExtension))
|
||||
|
||||
@@ -90,6 +90,12 @@ class FFMpegProcessor(
|
||||
)
|
||||
|
||||
|
||||
private val sharedExecutor = Executors.newSingleThreadExecutor()
|
||||
|
||||
protected fun finalize() {
|
||||
runCatching { sharedExecutor.shutdown() }
|
||||
}
|
||||
|
||||
private suspend fun newFFMpegTask(globalArguments: ArgumentList, inputArguments: ArgumentList, outputArguments: ArgumentList) = suspendCancellableCoroutine<FFmpegSession> {
|
||||
val stringBuilder = StringBuilder()
|
||||
arrayOf(globalArguments, inputArguments, outputArguments).forEach { argumentList ->
|
||||
@@ -127,7 +133,7 @@ class FFMpegProcessor(
|
||||
Level.AV_LOG_VERBOSE -> LogLevel.VERBOSE
|
||||
else -> return@logFunction
|
||||
}, log.message)
|
||||
}, { onStatistics(it) }, Executors.newSingleThreadExecutor())
|
||||
}, { onStatistics(it) }, sharedExecutor)
|
||||
}
|
||||
|
||||
suspend fun execute(args: Request) {
|
||||
@@ -145,22 +151,27 @@ class FFMpegProcessor(
|
||||
}
|
||||
|
||||
val outputArguments = ArgumentList().apply {
|
||||
this += "-preset" to (ffmpegOptions.preset.getNullable() ?: "ultrafast")
|
||||
this += "-c:v" to (ffmpegOptions.customVideoCodec.get().takeIf { it.isNotEmpty() } ?: "libx264")
|
||||
this += "-c:a" to (ffmpegOptions.customAudioCodec.get().takeIf { it.isNotEmpty() } ?: "copy")
|
||||
this += "-crf" to ffmpegOptions.constantRateFactor.get().let { "\"$it\"" }
|
||||
this += "-b:v" to ffmpegOptions.videoBitrate.get().toString() + "K"
|
||||
this += "-c:a" to (ffmpegOptions.customAudioCodec.get().takeIf { it.isNotEmpty() }?.lowercase() ?: "copy")
|
||||
this += "-b:a" to ffmpegOptions.audioBitrate.get().toString() + "K"
|
||||
}
|
||||
|
||||
fun applyVideoArguments() {
|
||||
outputArguments += "-preset" to (ffmpegOptions.preset.getNullable() ?: "ultrafast")
|
||||
outputArguments += "-c:v" to (ffmpegOptions.customVideoCodec.get().takeIf { it.isNotEmpty() }?.lowercase() ?: "libx264")
|
||||
outputArguments += "-crf" to ffmpegOptions.constantRateFactor.get().let { "\"$it\"" }
|
||||
outputArguments += "-b:v" to ffmpegOptions.videoBitrate.get().toString() + "K"
|
||||
}
|
||||
|
||||
when (args.action) {
|
||||
Action.DOWNLOAD_DASH -> {
|
||||
applyVideoArguments()
|
||||
outputArguments += "-ss" to "'${args.startTime}ms'"
|
||||
if (args.duration != null) {
|
||||
outputArguments += "-t" to "'${args.duration}ms'"
|
||||
}
|
||||
}
|
||||
Action.MERGE_OVERLAY -> {
|
||||
applyVideoArguments()
|
||||
inputArguments += "-i" to args.overlay!!.absolutePath
|
||||
outputArguments += "-filter_complex" to "\"[0]scale2ref[img][vid];[img]setsar=1[img];[vid]nullsink;[img][1]overlay=(W-w)/2:(H-h)/2,scale=2*trunc(iw*sar/2):2*trunc(ih/2)\""
|
||||
}
|
||||
@@ -168,8 +179,9 @@ class FFMpegProcessor(
|
||||
if (ffmpegOptions.customAudioCodec.isEmpty()) {
|
||||
outputArguments -= "-c:a"
|
||||
}
|
||||
outputArguments -= "-c:v"
|
||||
args.videoCodec?.let {
|
||||
applyVideoArguments()
|
||||
outputArguments -= "-c:v"
|
||||
outputArguments += "-c:v" to it
|
||||
} ?: run {
|
||||
outputArguments += "-vn"
|
||||
@@ -180,6 +192,7 @@ class FFMpegProcessor(
|
||||
}
|
||||
}
|
||||
Action.MERGE_MEDIA -> {
|
||||
applyVideoArguments()
|
||||
inputArguments.clear()
|
||||
val filesInfo = args.inputs.mapNotNull { file ->
|
||||
runCatching {
|
||||
@@ -187,45 +200,47 @@ class FFMpegProcessor(
|
||||
}.getOrNull()?.let { file to it }
|
||||
}
|
||||
|
||||
val (maxWidth, maxHeight) = filesInfo.maxByOrNull { (_, r) ->
|
||||
r.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_WIDTH)?.toIntOrNull() ?: 0
|
||||
}?.let { (_, r) ->
|
||||
r.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_WIDTH)?.toIntOrNull() to
|
||||
r.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_HEIGHT)?.toIntOrNull()
|
||||
} ?: throw Exception("Failed to get video size")
|
||||
try {
|
||||
val (maxWidth, maxHeight) = filesInfo.maxByOrNull { (_, r) ->
|
||||
r.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_WIDTH)?.toIntOrNull() ?: 0
|
||||
}?.let { (_, r) ->
|
||||
r.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_WIDTH)?.toIntOrNull() to
|
||||
r.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_HEIGHT)?.toIntOrNull()
|
||||
} ?: throw Exception("Failed to get video size")
|
||||
|
||||
val filterFirstPart = StringBuilder()
|
||||
val filterSecondPart = StringBuilder()
|
||||
var containsNoSound = false
|
||||
val filterFirstPart = StringBuilder()
|
||||
val filterSecondPart = StringBuilder()
|
||||
var containsNoSound = false
|
||||
|
||||
filesInfo.forEachIndexed { index, (file, retriever) ->
|
||||
filterFirstPart.append("[$index:v]scale=$maxWidth:$maxHeight,setsar=1[v$index];")
|
||||
if (retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_HAS_AUDIO) == "yes") {
|
||||
filterSecondPart.append("[v$index][$index:a]")
|
||||
} else {
|
||||
containsNoSound = true
|
||||
filterSecondPart.append("[v$index][${filesInfo.size}]")
|
||||
filesInfo.forEachIndexed { index, (file, retriever) ->
|
||||
filterFirstPart.append("[$index:v]scale=$maxWidth:$maxHeight,setsar=1[v$index];")
|
||||
if (retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_HAS_AUDIO) == "yes") {
|
||||
filterSecondPart.append("[v$index][$index:a]")
|
||||
} else {
|
||||
containsNoSound = true
|
||||
filterSecondPart.append("[v$index][${filesInfo.size}:a]")
|
||||
}
|
||||
inputArguments += "-i" to file
|
||||
}
|
||||
inputArguments += "-i" to file
|
||||
|
||||
if (containsNoSound) {
|
||||
inputArguments += "-f" to "lavfi"
|
||||
inputArguments += "-t" to "0.1"
|
||||
inputArguments += "-i" to "anullsrc=channel_layout=stereo:sample_rate=44100"
|
||||
}
|
||||
|
||||
if (outputArguments["-c:a"] == "copy") {
|
||||
outputArguments -= "-c:a"
|
||||
}
|
||||
|
||||
outputArguments += "-fps_mode" to "vfr"
|
||||
|
||||
outputArguments += "-filter_complex" to "$filterFirstPart ${filterSecondPart}concat=n=${filesInfo.size}:v=1:a=1[vout][aout]"
|
||||
outputArguments += "-map" to "[aout]"
|
||||
outputArguments += "-map" to "[vout]"
|
||||
} finally {
|
||||
filesInfo.forEach { it.second.close() }
|
||||
}
|
||||
|
||||
if (containsNoSound) {
|
||||
inputArguments += "-f" to "lavfi"
|
||||
inputArguments += "-t" to "0.1"
|
||||
inputArguments += "-i" to "anullsrc=channel_layout=stereo:sample_rate=44100"
|
||||
}
|
||||
|
||||
if (outputArguments["-c:a"] == "copy") {
|
||||
outputArguments -= "-c:a"
|
||||
}
|
||||
|
||||
outputArguments += "-fps_mode" to "vfr"
|
||||
|
||||
outputArguments += "-filter_complex" to "\"$filterFirstPart ${filterSecondPart}concat=n=${filesInfo.size}:v=1:a=1[vout][aout]\""
|
||||
outputArguments += "-map" to "\"[aout]\""
|
||||
outputArguments += "-map" to "\"[vout]\""
|
||||
|
||||
filesInfo.forEach { it.second.close() }
|
||||
}
|
||||
Action.DOWNLOAD_AUDIO_STREAM -> {
|
||||
outputArguments.clear()
|
||||
@@ -256,8 +271,8 @@ class FFMpegProcessor(
|
||||
filterParts.append("[a$index]")
|
||||
}
|
||||
filterParts.append("amix=inputs=${args.inputs.size}:duration=longest:normalize=0[aout]")
|
||||
outputArguments += "-filter_complex" to "\"$filterParts\""
|
||||
outputArguments += "-map" to "\"[aout]\""
|
||||
outputArguments += "-filter_complex" to filterParts.toString()
|
||||
outputArguments += "-map" to "[aout]"
|
||||
}
|
||||
}
|
||||
outputArguments += args.output.absolutePath
|
||||
|
||||
@@ -5,6 +5,8 @@ import me.eternal.purrfectsnap.RemoteSideContext
|
||||
import me.eternal.purrfectsnap.common.data.MessagingFriendInfo
|
||||
import me.eternal.purrfectsnap.common.data.MessagingGroupInfo
|
||||
import me.eternal.purrfectsnap.common.util.SQLiteDatabaseHelper
|
||||
import kotlinx.coroutines.channels.BufferOverflow
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import java.util.concurrent.ExecutorService
|
||||
import java.util.concurrent.Executors
|
||||
|
||||
@@ -15,7 +17,11 @@ class AppDatabase(
|
||||
val executor: ExecutorService = Executors.newSingleThreadExecutor()
|
||||
lateinit var database: SQLiteDatabase
|
||||
|
||||
var receiveMessagingDataCallback: (friends: List<MessagingFriendInfo>, groups: List<MessagingGroupInfo>) -> Unit = { _, _ -> }
|
||||
// Multi-subscriber event stream for messaging data updates
|
||||
val messagingDataFlow = MutableSharedFlow<Pair<List<MessagingFriendInfo>, List<MessagingGroupInfo>>>(
|
||||
extraBufferCapacity = 1,
|
||||
onBufferOverflow = BufferOverflow.DROP_OLDEST
|
||||
)
|
||||
|
||||
fun executeAsync(block: () -> Unit) {
|
||||
executor.execute {
|
||||
@@ -113,6 +119,22 @@ class AppDatabase(
|
||||
"id CHAR(36) PRIMARY KEY",
|
||||
"content TEXT",
|
||||
),
|
||||
"assistant_registry" to listOf(
|
||||
"id VARCHAR PRIMARY KEY",
|
||||
"kind VARCHAR",
|
||||
"title VARCHAR",
|
||||
"category VARCHAR",
|
||||
"path TEXT",
|
||||
"description TEXT",
|
||||
"settingKey VARCHAR",
|
||||
"screenRoute VARCHAR",
|
||||
"allowedActions TEXT",
|
||||
"allowedValues TEXT",
|
||||
"aliases TEXT",
|
||||
"commonTypos TEXT",
|
||||
"examples TEXT",
|
||||
"searchTokens TEXT",
|
||||
),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
package me.eternal.purrfectsnap.storage
|
||||
|
||||
import android.content.ContentValues
|
||||
import me.eternal.purrfectsnap.common.util.ktx.getStringOrNull
|
||||
import org.json.JSONArray
|
||||
|
||||
data class AssistantRegistryEntry(
|
||||
val id: String,
|
||||
val kind: String,
|
||||
val title: String,
|
||||
val category: String,
|
||||
val path: String,
|
||||
val description: String,
|
||||
val settingKey: String? = null,
|
||||
val screenRoute: String? = null,
|
||||
val allowedActions: List<String> = emptyList(),
|
||||
val allowedValues: List<String> = emptyList(),
|
||||
val aliases: List<String> = emptyList(),
|
||||
val commonTypos: List<String> = emptyList(),
|
||||
val examples: List<String> = emptyList(),
|
||||
val searchTokens: List<String> = emptyList()
|
||||
)
|
||||
|
||||
private fun List<String>.toJsonArrayString(): String = JSONArray(this).toString()
|
||||
|
||||
private fun parseStringList(raw: String?): List<String> {
|
||||
if (raw.isNullOrBlank()) return emptyList()
|
||||
return runCatching {
|
||||
val array = JSONArray(raw)
|
||||
buildList {
|
||||
for (index in 0 until array.length()) {
|
||||
array.optString(index).takeIf { it.isNotBlank() }?.let(::add)
|
||||
}
|
||||
}
|
||||
}.getOrDefault(emptyList())
|
||||
}
|
||||
|
||||
fun AppDatabase.replaceAssistantRegistry(entries: List<AssistantRegistryEntry>) {
|
||||
database.beginTransaction()
|
||||
try {
|
||||
database.execSQL("DELETE FROM assistant_registry")
|
||||
entries.forEach { entry ->
|
||||
database.insert(
|
||||
"assistant_registry",
|
||||
null,
|
||||
ContentValues().apply {
|
||||
put("id", entry.id)
|
||||
put("kind", entry.kind)
|
||||
put("title", entry.title)
|
||||
put("category", entry.category)
|
||||
put("path", entry.path)
|
||||
put("description", entry.description)
|
||||
put("settingKey", entry.settingKey)
|
||||
put("screenRoute", entry.screenRoute)
|
||||
put("allowedActions", entry.allowedActions.toJsonArrayString())
|
||||
put("allowedValues", entry.allowedValues.toJsonArrayString())
|
||||
put("aliases", entry.aliases.toJsonArrayString())
|
||||
put("commonTypos", entry.commonTypos.toJsonArrayString())
|
||||
put("examples", entry.examples.toJsonArrayString())
|
||||
put("searchTokens", entry.searchTokens.toJsonArrayString())
|
||||
}
|
||||
)
|
||||
}
|
||||
database.setTransactionSuccessful()
|
||||
} finally {
|
||||
database.endTransaction()
|
||||
}
|
||||
}
|
||||
|
||||
fun AppDatabase.getAssistantRegistryEntries(): List<AssistantRegistryEntry> {
|
||||
return database.rawQuery("SELECT * FROM assistant_registry", null).use { cursor ->
|
||||
val entries = mutableListOf<AssistantRegistryEntry>()
|
||||
while (cursor.moveToNext()) {
|
||||
entries += AssistantRegistryEntry(
|
||||
id = cursor.getStringOrNull("id") ?: continue,
|
||||
kind = cursor.getStringOrNull("kind") ?: "feature",
|
||||
title = cursor.getStringOrNull("title") ?: "",
|
||||
category = cursor.getStringOrNull("category") ?: "",
|
||||
path = cursor.getStringOrNull("path") ?: "",
|
||||
description = cursor.getStringOrNull("description") ?: "",
|
||||
settingKey = cursor.getStringOrNull("settingKey"),
|
||||
screenRoute = cursor.getStringOrNull("screenRoute"),
|
||||
allowedActions = parseStringList(cursor.getStringOrNull("allowedActions")),
|
||||
allowedValues = parseStringList(cursor.getStringOrNull("allowedValues")),
|
||||
aliases = parseStringList(cursor.getStringOrNull("aliases")),
|
||||
commonTypos = parseStringList(cursor.getStringOrNull("commonTypos")),
|
||||
examples = parseStringList(cursor.getStringOrNull("examples")),
|
||||
searchTokens = parseStringList(cursor.getStringOrNull("searchTokens"))
|
||||
)
|
||||
}
|
||||
entries
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,9 @@ import me.eternal.purrfectsnap.common.data.FriendStreaks
|
||||
import me.eternal.purrfectsnap.common.data.MessagingFriendInfo
|
||||
import me.eternal.purrfectsnap.common.data.MessagingGroupInfo
|
||||
import me.eternal.purrfectsnap.common.data.MessagingRuleType
|
||||
import me.eternal.purrfectsnap.common.data.isStealthRule
|
||||
import me.eternal.purrfectsnap.common.data.normalizeStealthRules
|
||||
import me.eternal.purrfectsnap.common.data.withNormalizedRuleToggle
|
||||
import me.eternal.purrfectsnap.common.util.ktx.getInteger
|
||||
import me.eternal.purrfectsnap.common.util.ktx.getLongOrNull
|
||||
import me.eternal.purrfectsnap.common.util.ktx.getStringOrNull
|
||||
@@ -94,15 +97,16 @@ fun AppDatabase.replaceMessagingData(
|
||||
database.beginTransaction()
|
||||
try {
|
||||
friends.forEach { friend ->
|
||||
// Industrial Filter: Only update existing friends, never auto-insert new ones.
|
||||
database.execSQL(
|
||||
"INSERT OR REPLACE INTO friends (userId, dmConversationId, displayName, mutableUsername, bitmojiId, selfieId) VALUES (?, ?, ?, ?, ?, ?)",
|
||||
"UPDATE friends SET dmConversationId = ?, displayName = ?, mutableUsername = ?, bitmojiId = ?, selfieId = ? WHERE userId = ?",
|
||||
arrayOf<Any?>(
|
||||
friend.userId,
|
||||
friend.dmConversationId,
|
||||
friend.displayName,
|
||||
friend.mutableUsername,
|
||||
friend.bitmojiId,
|
||||
friend.selfieId
|
||||
friend.selfieId,
|
||||
friend.userId
|
||||
)
|
||||
)
|
||||
|
||||
@@ -121,12 +125,13 @@ fun AppDatabase.replaceMessagingData(
|
||||
}
|
||||
|
||||
groups.forEach { group ->
|
||||
// Industrial Filter: Only update existing groups.
|
||||
database.execSQL(
|
||||
"INSERT OR REPLACE INTO groups (conversationId, name, participantsCount) VALUES (?, ?, ?)",
|
||||
"UPDATE groups SET name = ?, participantsCount = ? WHERE conversationId = ?",
|
||||
arrayOf<Any?>(
|
||||
group.conversationId,
|
||||
group.name,
|
||||
group.participantsCount
|
||||
group.participantsCount,
|
||||
group.conversationId
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -135,6 +140,9 @@ fun AppDatabase.replaceMessagingData(
|
||||
} finally {
|
||||
database.endTransaction()
|
||||
}
|
||||
|
||||
// Notify all observers with the raw sync data (AddFriendDialog needs this)
|
||||
messagingDataFlow.tryEmit(friends to groups)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -154,12 +162,42 @@ fun AppDatabase.getRules(targetUuid: String): List<MessagingRuleType> {
|
||||
context.log.error("Failed to parse rule", it)
|
||||
}
|
||||
}
|
||||
rules
|
||||
rules.normalizeStealthRules().toList()
|
||||
}
|
||||
}
|
||||
|
||||
fun AppDatabase.setRule(targetUuid: String, type: String, enabled: Boolean) {
|
||||
executeAsync {
|
||||
val ruleType = MessagingRuleType.getByName(type)
|
||||
if (ruleType?.isStealthRule() == true) {
|
||||
val updatedStealthRules = getRules(targetUuid)
|
||||
.withNormalizedRuleToggle(ruleType, enabled)
|
||||
.filter { it.isStealthRule() }
|
||||
|
||||
database.beginTransaction()
|
||||
try {
|
||||
database.execSQL(
|
||||
"DELETE FROM rules WHERE targetUuid = ? AND type IN (?, ?, ?)",
|
||||
arrayOf(
|
||||
targetUuid,
|
||||
MessagingRuleType.STEALTH.key,
|
||||
MessagingRuleType.SNAP_STEALTH.key,
|
||||
MessagingRuleType.CHAT_STEALTH.key
|
||||
)
|
||||
)
|
||||
updatedStealthRules.forEach { stealthRule ->
|
||||
database.execSQL(
|
||||
"INSERT OR REPLACE INTO rules (targetUuid, type) VALUES (?, ?)",
|
||||
arrayOf(targetUuid, stealthRule.key)
|
||||
)
|
||||
}
|
||||
database.setTransactionSuccessful()
|
||||
} finally {
|
||||
database.endTransaction()
|
||||
}
|
||||
return@executeAsync
|
||||
}
|
||||
|
||||
if (enabled) {
|
||||
database.execSQL(
|
||||
"INSERT OR REPLACE INTO rules (targetUuid, type) VALUES (?, ?)",
|
||||
|
||||
@@ -86,7 +86,8 @@ class AnnouncementCheckWorker(
|
||||
val pendingIntent = PendingIntent.getActivity(appContext, 0, intent, PendingIntent.FLAG_IMMUTABLE)
|
||||
|
||||
val builder = NotificationCompat.Builder(appContext, channelId)
|
||||
.setSmallIcon(R.drawable.launcher_icon_monochrome)
|
||||
.setSmallIcon(R.mipmap.ic_launcher_monochrome)
|
||||
.setLargeIcon(android.graphics.BitmapFactory.decodeResource(appContext.resources, R.mipmap.ic_launcher))
|
||||
.setContentTitle(title)
|
||||
.setContentText(text)
|
||||
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
|
||||
|
||||
2420
app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/AI.kt
Normal file
2420
app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/AI.kt
Normal file
File diff suppressed because it is too large
Load Diff
@@ -133,16 +133,16 @@ class MainActivity : ComponentActivity() {
|
||||
if (shouldShowAbiWarning) {
|
||||
AestheticDialog(
|
||||
onDismissRequest = {},
|
||||
title = managerContext.translation["wrong_apk_title"],
|
||||
title = managerContext.translation["setup.activity.wrong_apk_title"],
|
||||
text = "",
|
||||
icon = Icons.Filled.Warning,
|
||||
confirmButtonText = managerContext.translation["common.close"],
|
||||
confirmButtonText = managerContext.translation["setup.activity.close_button"],
|
||||
onConfirm = { (context as? Activity)?.finishAffinity() },
|
||||
showCloseButton = false,
|
||||
opaque = true,
|
||||
customContent = {
|
||||
Text(
|
||||
text = managerContext.translation["wrong_apk_message"],
|
||||
text = managerContext.translation["setup.activity.wrong_apk_message"],
|
||||
color = PurrfectPalette.textSecondary,
|
||||
lineHeight = 18.sp
|
||||
)
|
||||
|
||||
@@ -269,7 +269,7 @@ fun FloatingTopBar(
|
||||
if (onBack != null) {
|
||||
translationX = morphingParams.horizontalShift.toPx()
|
||||
}
|
||||
},
|
||||
},
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp)
|
||||
) {
|
||||
|
||||
@@ -113,8 +113,8 @@ object Updater {
|
||||
|
||||
fun getLatestRelease(channel: Channel): LatestRelease? {
|
||||
return cache.getOrPut(channel) {
|
||||
if (BuildConfig.DEBUG) {
|
||||
fetchLatestDebugCI() ?: fetchLatestRelease(channel)
|
||||
if (BuildConfig.DEBUG && channel == Channel.STABLE) {
|
||||
fetchLatestDebugCI() ?: fetchLatestRelease(Channel.STABLE)
|
||||
} else {
|
||||
fetchLatestRelease(channel)
|
||||
}
|
||||
|
||||
@@ -77,6 +77,7 @@ import com.google.gson.Gson
|
||||
import com.google.gson.reflect.TypeToken
|
||||
import me.eternal.purrfectsnap.common.ui.TopBarActionButton
|
||||
import me.eternal.purrfectsnap.common.ui.rememberAsyncMutableStateList
|
||||
import me.eternal.purrfectsnap.core.features.impl.experiments.RandomizedDeviceProfile
|
||||
import me.eternal.purrfectsnap.ui.manager.components.AestheticDialog
|
||||
import me.eternal.purrfectsnap.ui.manager.Routes
|
||||
import me.eternal.purrfectsnap.ui.manager.ManagerTheme
|
||||
@@ -180,7 +181,6 @@ class FeaturesRootSection : Routes.Route() {
|
||||
}
|
||||
|
||||
internal fun getRandomizedProfileSnapshot(): String {
|
||||
context.config.load()
|
||||
return context.config.root.experimental.spoof.randomizeDeviceProfile.currentProfileSnapshot.getNullable()
|
||||
?.takeIf { it.isNotBlank() }
|
||||
?: (context.translation["manager.dialogs.randomize_device_profile.empty"]
|
||||
@@ -188,7 +188,77 @@ class FeaturesRootSection : Routes.Route() {
|
||||
}
|
||||
|
||||
internal fun isRandomizedProfileActionProperty(propertyName: String): Boolean {
|
||||
return propertyName == "generate_fresh_profile_action" || propertyName == "view_current_profile_action"
|
||||
return propertyName == "generate_fresh_profile_action" ||
|
||||
propertyName == "view_current_profile_action" ||
|
||||
propertyName == "backup_profile_action" ||
|
||||
propertyName == "restore_profile_action"
|
||||
}
|
||||
|
||||
internal fun backupRandomizedProfile(onConfigChanged: () -> Unit) {
|
||||
val profileSnapshot = getRandomizedProfileSnapshot()
|
||||
if (profileSnapshot.startsWith("No generated profile")) {
|
||||
context.shortToast(
|
||||
context.translation["manager.dialogs.randomize_device_profile.empty"]
|
||||
?: "No generated profile is available yet. Enable the feature in Snapchat first."
|
||||
)
|
||||
return
|
||||
}
|
||||
activityLauncher {
|
||||
saveFile("randomized-device-profile.json", "application/json") { uri ->
|
||||
runCatching {
|
||||
context.androidContext.contentResolver.openOutputStream(uri.toUri())?.bufferedWriter()?.use {
|
||||
it.write(profileSnapshot)
|
||||
} ?: error("Failed to open backup destination")
|
||||
onConfigChanged()
|
||||
context.shortToast("Randomized profile backup saved")
|
||||
}.onFailure {
|
||||
context.log.error("Failed to back up randomized profile", it)
|
||||
context.shortToast("Failed to back up randomized profile")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal fun restoreRandomizedProfile(onConfigChanged: () -> Unit) {
|
||||
activityLauncher {
|
||||
openFile("application/json") { uri ->
|
||||
runCatching {
|
||||
val importedJson = context.androidContext.contentResolver.openInputStream(uri.toUri())
|
||||
?.bufferedReader()
|
||||
?.use { it.readText() }
|
||||
?.trim()
|
||||
?: error("Failed to read randomized profile backup")
|
||||
val profile = RandomizedDeviceProfile.fromJson(importedJson)
|
||||
val generationToken = UUID.randomUUID().toString()
|
||||
val profileJson = profile.toJson().toString()
|
||||
|
||||
// Save to local prefs for legacy compatibility
|
||||
context.androidContext.getSharedPreferences("purrfectsnap_spoof", 0)
|
||||
.edit()
|
||||
.putString("randomized_device_profile", profileJson)
|
||||
.putString("randomized_device_profile_token", generationToken)
|
||||
.putString("android_id", profile.androidId)
|
||||
.putString("advertising_id", profile.advertisingId)
|
||||
.putString("bluetooth_address", profile.bluetoothMacAddress)
|
||||
.putString("gsf_id", profile.gsfId)
|
||||
.putString("random_device", profile.deviceInfo.model)
|
||||
.putString("device_fingerprint", profile.buildFingerprint)
|
||||
.apply()
|
||||
|
||||
val randomizeConfig = context.config.root.experimental.spoof.randomizeDeviceProfile
|
||||
randomizeConfig.profileGenerationToken.set(generationToken)
|
||||
randomizeConfig.currentProfileSnapshot.set(profile.toJson().toString(2))
|
||||
randomizeConfig.profileData.set(profileJson) // Shared storage fix
|
||||
|
||||
context.config.writeConfig()
|
||||
onConfigChanged()
|
||||
context.shortToast("Randomized profile restored. Restart Snapchat to apply it.")
|
||||
}.onFailure {
|
||||
context.log.error("Failed to restore randomized profile", it)
|
||||
context.shortToast("Failed to restore randomized profile")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun navigateToMainRoot() {
|
||||
@@ -664,6 +734,8 @@ class FeaturesRootSection : Routes.Route() {
|
||||
val actionLabel = when (property.name) {
|
||||
"generate_fresh_profile_action" -> context.translation[property.key.propertyName()] ?: "Generate Fresh Profile"
|
||||
"view_current_profile_action" -> context.translation[property.key.propertyName()] ?: "View Current Profile"
|
||||
"backup_profile_action" -> context.translation[property.key.propertyName()] ?: "Backup Profile"
|
||||
"restore_profile_action" -> context.translation[property.key.propertyName()] ?: "Restore Profile"
|
||||
else -> property.name
|
||||
}
|
||||
Button(
|
||||
@@ -685,8 +757,12 @@ class FeaturesRootSection : Routes.Route() {
|
||||
?: "Fresh randomized profile requested. Restart Snapchat to apply it."
|
||||
)
|
||||
}
|
||||
} else {
|
||||
} else if (property.name == "view_current_profile_action") {
|
||||
showCurrentRandomProfileDialog = true
|
||||
} else if (property.name == "backup_profile_action") {
|
||||
backupRandomizedProfile(onConfigChanged)
|
||||
} else if (property.name == "restore_profile_action") {
|
||||
restoreRandomizedProfile(onConfigChanged)
|
||||
}
|
||||
},
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
@@ -707,11 +783,14 @@ class FeaturesRootSection : Routes.Route() {
|
||||
DataProcessors.Type.STRING, DataProcessors.Type.INTEGER, DataProcessors.Type.FLOAT -> {
|
||||
val isMessageListProperty = property.key.name.endsWith("_messages")
|
||||
val isSleepWindowProperty = property.key.name.contains("sleep_window")
|
||||
val isSnapchatPlusPurchaseDateProperty = property.key.name == "snapchat_plus_purchase_date"
|
||||
|
||||
if (isMessageListProperty) {
|
||||
alertDialogs.MessageListPropertyDialog(property) { showDialog = false }
|
||||
} else if (isSleepWindowProperty) {
|
||||
alertDialogs.AutoOpenScheduleDialog(property as PropertyPair<String>) { showDialog = false }
|
||||
} else if (isSnapchatPlusPurchaseDateProperty) {
|
||||
alertDialogs.DatePickerPropertyDialog(property) { showDialog = false }
|
||||
} else {
|
||||
alertDialogs.KeyboardInputDialog(property) { showDialog = false }
|
||||
}
|
||||
@@ -729,6 +808,7 @@ class FeaturesRootSection : Routes.Route() {
|
||||
)
|
||||
} else {
|
||||
val isMessageListProperty = property.key.name.endsWith("_messages")
|
||||
val isSnapchatPlusPurchaseDateProperty = property.key.name == "snapchat_plus_purchase_date"
|
||||
if (isMessageListProperty) {
|
||||
val messageCount = try {
|
||||
val messageList: List<String> = gson.fromJson(propertyValue.get().toString(), listTypeToken) ?: emptyList()
|
||||
@@ -750,6 +830,16 @@ class FeaturesRootSection : Routes.Route() {
|
||||
color = Color.White
|
||||
)
|
||||
}
|
||||
} else if (isSnapchatPlusPurchaseDateProperty) {
|
||||
Button(
|
||||
onClick = click,
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = PurrfectPalette.glowPrimary.copy(alpha = 0.28f),
|
||||
contentColor = Color.White
|
||||
)
|
||||
) {
|
||||
Text(translation["button.set"] ?: "Set")
|
||||
}
|
||||
} else {
|
||||
IconButton(onClick = click) {
|
||||
Icon(Icons.AutoMirrored.Filled.OpenInNew, contentDescription = null)
|
||||
|
||||
@@ -4,18 +4,14 @@ import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.DeleteSweep
|
||||
import androidx.compose.material.icons.filled.GroupAdd
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.RadioButton
|
||||
import androidx.compose.material3.RadioButtonDefaults
|
||||
import androidx.compose.material3.Surface
|
||||
@@ -29,6 +25,7 @@ import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.layout.onGloballyPositioned
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
@@ -38,10 +35,10 @@ import androidx.navigation.compose.currentBackStackEntryAsState
|
||||
import kotlinx.coroutines.asCoroutineDispatcher
|
||||
import kotlinx.coroutines.launch
|
||||
import me.eternal.purrfectsnap.common.data.MessagingRuleType
|
||||
import me.eternal.purrfectsnap.ui.manager.rememberRouteScrollState
|
||||
import me.eternal.purrfectsnap.common.data.RuleState
|
||||
import me.eternal.purrfectsnap.common.ui.rememberAsyncMutableState
|
||||
import me.eternal.purrfectsnap.common.ui.rememberAsyncUpdateDispatcher
|
||||
import me.eternal.purrfectsnap.common.ui.rememberAsyncMutableStateList
|
||||
import me.eternal.purrfectsnap.storage.clearRuleIds
|
||||
import me.eternal.purrfectsnap.storage.getRuleIds
|
||||
import me.eternal.purrfectsnap.storage.setRule
|
||||
@@ -140,9 +137,12 @@ class ManageRuleFeature : Routes.Route() {
|
||||
}
|
||||
|
||||
val updateDispatcher = rememberAsyncUpdateDispatcher()
|
||||
val currentRuleIds by rememberAsyncMutableState(defaultValue = mutableListOf(), updateDispatcher = updateDispatcher) {
|
||||
val currentRuleIds = rememberAsyncMutableStateList(defaultValue = emptyList()) {
|
||||
context.database.getRuleIds(currentRuleType.key)
|
||||
}
|
||||
val currentRuleIdSet = remember(currentRuleIds.size) {
|
||||
currentRuleIds.toSet()
|
||||
}
|
||||
|
||||
fun setRuleState(newState: RuleState?) {
|
||||
ruleState = newState
|
||||
@@ -163,12 +163,12 @@ class ManageRuleFeature : Routes.Route() {
|
||||
fun showAddFriendDialog() {
|
||||
addFriendDialog = AddFriendDialog(
|
||||
context = context,
|
||||
pinnedIds = currentRuleIds,
|
||||
pinnedIds = currentRuleIds.toList(),
|
||||
actionHandler = Actions(
|
||||
onFriendState = { friend, state ->
|
||||
context.database.setRule(friend.userId, currentRuleType.key, state)
|
||||
if (state) {
|
||||
currentRuleIds.add(friend.userId)
|
||||
if (!currentRuleIdSet.contains(friend.userId)) currentRuleIds.add(friend.userId)
|
||||
} else {
|
||||
currentRuleIds.remove(friend.userId)
|
||||
}
|
||||
@@ -176,16 +176,16 @@ class ManageRuleFeature : Routes.Route() {
|
||||
onGroupState = { group, state ->
|
||||
context.database.setRule(group.conversationId, currentRuleType.key, state)
|
||||
if (state) {
|
||||
currentRuleIds.add(group.conversationId)
|
||||
if (!currentRuleIdSet.contains(group.conversationId)) currentRuleIds.add(group.conversationId)
|
||||
} else {
|
||||
currentRuleIds.remove(group.conversationId)
|
||||
}
|
||||
},
|
||||
getFriendState = { friend ->
|
||||
currentRuleIds.contains(friend.userId)
|
||||
currentRuleIdSet.contains(friend.userId)
|
||||
},
|
||||
getGroupState = { group ->
|
||||
currentRuleIds.contains(group.conversationId)
|
||||
currentRuleIdSet.contains(group.conversationId)
|
||||
}
|
||||
)
|
||||
)
|
||||
@@ -230,59 +230,62 @@ class ManageRuleFeature : Routes.Route() {
|
||||
title = remember { context.translation[propertyKeyPair.key.propertyName()] },
|
||||
onBack = { routes.navController.popBackStack() },
|
||||
modifier = Modifier
|
||||
.zIndex(2f)
|
||||
.zIndex(10f)
|
||||
.onGloballyPositioned {
|
||||
val newHeight = with(density) { it.size.height.toDp() }
|
||||
if (newHeight != topBarHeight) topBarHeight = newHeight
|
||||
}
|
||||
)
|
||||
|
||||
Column(
|
||||
LazyColumn(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(top = topBarHeight + 10.dp)
|
||||
.padding(horizontal = 12.dp, vertical = 10.dp)
|
||||
.verticalScroll(rememberRouteScrollState(routeInfo.id)),
|
||||
.padding(top = topBarHeight + 10.dp),
|
||||
contentPadding = PaddingValues(horizontal = 12.dp, vertical = 10.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
val headerShape = RoundedCornerShape(22.dp)
|
||||
Surface(
|
||||
shape = headerShape,
|
||||
color = Color.White.copy(alpha = 0.04f),
|
||||
tonalElevation = 0.dp,
|
||||
shadowElevation = 0.dp,
|
||||
border = BorderStroke(
|
||||
1.dp,
|
||||
Brush.linearGradient(
|
||||
listOf(
|
||||
PurrfectPalette.glowPrimary.copy(alpha = 0.45f),
|
||||
PurrfectPalette.glowSecondary.copy(alpha = 0.35f)
|
||||
item {
|
||||
val headerShape = RoundedCornerShape(22.dp)
|
||||
Surface(
|
||||
shape = headerShape,
|
||||
color = Color.White.copy(alpha = 0.04f),
|
||||
tonalElevation = 0.dp,
|
||||
shadowElevation = 0.dp,
|
||||
border = BorderStroke(
|
||||
1.dp,
|
||||
Brush.linearGradient(
|
||||
listOf(
|
||||
PurrfectPalette.glowPrimary.copy(alpha = 0.45f),
|
||||
PurrfectPalette.glowSecondary.copy(alpha = 0.35f)
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.background(PurrfectPalette.cardOverlay, headerShape)
|
||||
.padding(horizontal = 16.dp, vertical = 14.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(6.dp)
|
||||
) {
|
||||
Text(
|
||||
text = context.translation[propertyKeyPair.key.propertyDescription()],
|
||||
fontWeight = FontWeight.Normal,
|
||||
fontSize = 12.sp,
|
||||
lineHeight = 16.sp,
|
||||
color = PurrfectPalette.textSecondary
|
||||
)
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.background(PurrfectPalette.cardOverlay, headerShape)
|
||||
.padding(horizontal = 16.dp, vertical = 14.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(6.dp)
|
||||
) {
|
||||
Text(
|
||||
text = context.translation[propertyKeyPair.key.propertyDescription()] ?: "",
|
||||
fontWeight = FontWeight.Normal,
|
||||
fontSize = 12.sp,
|
||||
lineHeight = 16.sp,
|
||||
color = PurrfectPalette.textSecondary
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
SelectRuleTypeRadio(
|
||||
checked = ruleState == null,
|
||||
text = translation["disable_state_option"],
|
||||
onStateChanged = { setRuleState(null) }
|
||||
) {
|
||||
Text(text = translation["disable_state_subtext"], fontWeight = FontWeight.Normal, fontSize = 12.sp, color = PurrfectPalette.textSecondary)
|
||||
item {
|
||||
SelectRuleTypeRadio(
|
||||
checked = ruleState == null,
|
||||
text = translation["disable_state_option"] ?: "Disabled",
|
||||
onStateChanged = { setRuleState(null) }
|
||||
) {
|
||||
Text(text = translation["disable_state_subtext"] ?: "", fontWeight = FontWeight.Normal, fontSize = 12.sp, color = PurrfectPalette.textSecondary)
|
||||
}
|
||||
}
|
||||
|
||||
val manageLabel = when (ruleState) {
|
||||
@@ -291,112 +294,120 @@ class ManageRuleFeature : Routes.Route() {
|
||||
else -> null
|
||||
}
|
||||
|
||||
SelectRuleTypeRadio(
|
||||
checked = ruleState == RuleState.WHITELIST,
|
||||
text = translation["whitelist_state_option"],
|
||||
onStateChanged = { setRuleState(RuleState.WHITELIST) }
|
||||
) {
|
||||
Text(
|
||||
text = translation.format("whitelist_state_subtext", "count" to currentRuleIds.size.toString()),
|
||||
fontWeight = FontWeight.Normal,
|
||||
fontSize = 12.sp,
|
||||
color = PurrfectPalette.textSecondary
|
||||
)
|
||||
Button(
|
||||
onClick = { showAddFriendDialog() },
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = PurrfectPalette.glowPrimary.copy(alpha = 0.34f),
|
||||
contentColor = Color.White
|
||||
item {
|
||||
SelectRuleTypeRadio(
|
||||
checked = ruleState == RuleState.WHITELIST,
|
||||
text = translation["whitelist_state_option"] ?: "Whitelist",
|
||||
onStateChanged = { setRuleState(RuleState.WHITELIST) }
|
||||
) {
|
||||
Text(
|
||||
text = translation.format("whitelist_state_subtext", "count" to currentRuleIds.size.toString()),
|
||||
fontWeight = FontWeight.Normal,
|
||||
fontSize = 12.sp,
|
||||
color = PurrfectPalette.textSecondary
|
||||
)
|
||||
) {
|
||||
Text(text = translation["whitelist_state_button"])
|
||||
}
|
||||
}
|
||||
|
||||
SelectRuleTypeRadio(
|
||||
checked = ruleState == RuleState.BLACKLIST,
|
||||
text = translation["blacklist_state_option"],
|
||||
onStateChanged = { setRuleState(RuleState.BLACKLIST) }
|
||||
) {
|
||||
Text(
|
||||
text = translation.format("blacklist_state_subtext", "count" to currentRuleIds.size.toString()),
|
||||
fontWeight = FontWeight.Normal,
|
||||
fontSize = 12.sp,
|
||||
color = PurrfectPalette.textSecondary
|
||||
)
|
||||
Button(
|
||||
onClick = { showAddFriendDialog() },
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = PurrfectPalette.glowPrimary.copy(alpha = 0.34f),
|
||||
contentColor = Color.White
|
||||
)
|
||||
) {
|
||||
Text(text = translation["blacklist_state_button"])
|
||||
}
|
||||
}
|
||||
|
||||
Surface(
|
||||
shape = RoundedCornerShape(22.dp),
|
||||
color = Color.White.copy(alpha = 0.04f),
|
||||
tonalElevation = 0.dp,
|
||||
shadowElevation = 0.dp,
|
||||
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.1f))
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(PurrfectPalette.cardOverlay, RoundedCornerShape(22.dp))
|
||||
.padding(horizontal = 14.dp, vertical = 12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
Surface(
|
||||
shape = CircleShape,
|
||||
color = Color.White.copy(alpha = 0.08f),
|
||||
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.12f)),
|
||||
modifier = Modifier.size(46.dp)
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.clip(CircleShape)
|
||||
.background(PurrfectPalette.glowSecondary.copy(alpha = 0.22f)),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Icon(Icons.Default.DeleteSweep, contentDescription = null, tint = Color.White)
|
||||
}
|
||||
}
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = translation["clear_list_button"],
|
||||
color = Color.White,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
if (!manageLabel.isNullOrBlank()) {
|
||||
Text(
|
||||
text = manageLabel,
|
||||
color = PurrfectPalette.textSecondary,
|
||||
fontSize = 12.sp,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
}
|
||||
Button(
|
||||
onClick = { confirmationDialog = true },
|
||||
onClick = { showAddFriendDialog() },
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = Color.White.copy(alpha = 0.08f),
|
||||
containerColor = PurrfectPalette.glowPrimary.copy(alpha = 0.34f),
|
||||
contentColor = Color.White
|
||||
)
|
||||
) {
|
||||
Text(text = translation["dialog_clear_confirm_button"])
|
||||
Text(text = translation["whitelist_state_button"] ?: "Manage")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(routes.bottomPadding))
|
||||
item {
|
||||
SelectRuleTypeRadio(
|
||||
checked = ruleState == RuleState.BLACKLIST,
|
||||
text = translation["blacklist_state_option"] ?: "Blacklist",
|
||||
onStateChanged = { setRuleState(RuleState.BLACKLIST) }
|
||||
) {
|
||||
Text(
|
||||
text = translation.format("blacklist_state_subtext", "count" to currentRuleIds.size.toString()),
|
||||
fontWeight = FontWeight.Normal,
|
||||
fontSize = 12.sp,
|
||||
color = PurrfectPalette.textSecondary
|
||||
)
|
||||
Button(
|
||||
onClick = { showAddFriendDialog() },
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = PurrfectPalette.glowPrimary.copy(alpha = 0.34f),
|
||||
contentColor = Color.White
|
||||
)
|
||||
) {
|
||||
Text(text = translation["blacklist_state_button"] ?: "Manage")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
item {
|
||||
Surface(
|
||||
shape = RoundedCornerShape(22.dp),
|
||||
color = Color.White.copy(alpha = 0.04f),
|
||||
tonalElevation = 0.dp,
|
||||
shadowElevation = 0.dp,
|
||||
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.1f))
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(PurrfectPalette.cardOverlay, RoundedCornerShape(22.dp))
|
||||
.padding(horizontal = 14.dp, vertical = 12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
Surface(
|
||||
shape = CircleShape,
|
||||
color = Color.White.copy(alpha = 0.08f),
|
||||
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.12f)),
|
||||
modifier = Modifier.size(46.dp)
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.clip(CircleShape)
|
||||
.background(PurrfectPalette.glowSecondary.copy(alpha = 0.22f)),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Icon(Icons.Default.DeleteSweep, contentDescription = null, tint = Color.White)
|
||||
}
|
||||
}
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = translation["clear_list_button"] ?: "Clear List",
|
||||
color = Color.White,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
if (!manageLabel.isNullOrBlank()) {
|
||||
Text(
|
||||
text = manageLabel,
|
||||
color = PurrfectPalette.textSecondary,
|
||||
fontSize = 12.sp,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
}
|
||||
Button(
|
||||
onClick = { confirmationDialog = true },
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = Color.White.copy(alpha = 0.08f),
|
||||
contentColor = Color.White
|
||||
)
|
||||
) {
|
||||
Text(text = translation["dialog_clear_confirm_button"] ?: "Clear")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
item {
|
||||
Spacer(modifier = Modifier.height(routes.bottomPadding))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,7 +32,6 @@ import me.eternal.purrfectsnap.R
|
||||
import me.eternal.purrfectsnap.ui.manager.Routes
|
||||
import me.eternal.purrfectsnap.ui.manager.ManagerTheme
|
||||
import me.eternal.purrfectsnap.ui.manager.theme.PurrfectPalette
|
||||
import me.eternal.purrfectsnap.ui.util.PurrfectMarqueeText
|
||||
import me.eternal.purrfectsnap.ui.util.scaleOnPress
|
||||
|
||||
class HomeAbout : Routes.Route() {
|
||||
@@ -65,6 +64,7 @@ class HomeAbout : Routes.Route() {
|
||||
name: String,
|
||||
imageRes: Int,
|
||||
avenirNext: FontFamily,
|
||||
subtitle: String? = null,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val tapSource = remember { MutableInteractionSource() }
|
||||
@@ -85,7 +85,9 @@ class HomeAbout : Routes.Route() {
|
||||
routes.retroGame.navigate()
|
||||
}
|
||||
},
|
||||
modifier = modifier.scaleOnPress(tapSource),
|
||||
modifier = modifier
|
||||
.height(150.dp)
|
||||
.scaleOnPress(tapSource),
|
||||
interactionSource = tapSource,
|
||||
shape = RoundedCornerShape(22.dp),
|
||||
color = Color.White.copy(alpha = 0.06f),
|
||||
@@ -94,9 +96,11 @@ class HomeAbout : Routes.Route() {
|
||||
shadowElevation = 0.dp
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(14.dp),
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(14.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp)
|
||||
verticalArrangement = Arrangement.Center
|
||||
) {
|
||||
Surface(
|
||||
modifier = Modifier.size(64.dp),
|
||||
@@ -111,15 +115,28 @@ class HomeAbout : Routes.Route() {
|
||||
modifier = Modifier.fillMaxSize().clip(CircleShape)
|
||||
)
|
||||
}
|
||||
PurrfectMarqueeText(
|
||||
Text(
|
||||
text = name,
|
||||
color = Color.White,
|
||||
style = TextStyle(
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontSize = 16.sp,
|
||||
fontFamily = avenirNext
|
||||
)
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontSize = 16.sp,
|
||||
fontFamily = avenirNext,
|
||||
textAlign = androidx.compose.ui.text.style.TextAlign.Center,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
subtitle?.takeIf { it.isNotBlank() }?.let {
|
||||
Spacer(modifier = Modifier.height(6.dp))
|
||||
Text(
|
||||
text = it,
|
||||
color = PurrfectPalette.textSecondary,
|
||||
fontSize = 12.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,6 +36,7 @@ import androidx.compose.material.icons.filled.KeyboardDoubleArrowDown
|
||||
import androidx.compose.material.icons.filled.KeyboardDoubleArrowUp
|
||||
import androidx.compose.material.icons.filled.MoreVert
|
||||
import androidx.compose.material.icons.filled.Refresh
|
||||
import androidx.compose.material.icons.filled.FilterList
|
||||
import androidx.compose.material.icons.outlined.BugReport
|
||||
import androidx.compose.material.icons.outlined.Info
|
||||
import androidx.compose.material.icons.outlined.Report
|
||||
@@ -170,6 +171,7 @@ class HomeLogs : Routes.Route() {
|
||||
internal fun LogsFloatingBar(
|
||||
isRefreshing: Boolean,
|
||||
onRefresh: () -> Unit,
|
||||
onFilter: () -> Unit,
|
||||
onExport: () -> Unit,
|
||||
onClear: () -> Unit
|
||||
) {
|
||||
@@ -222,6 +224,20 @@ class HomeLogs : Routes.Route() {
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
if (isRefreshing) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(18.dp),
|
||||
strokeWidth = 2.dp,
|
||||
color = Color.White
|
||||
)
|
||||
}
|
||||
IconButton(onClick = onFilter) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.FilterList,
|
||||
contentDescription = "Filter Logs",
|
||||
tint = PurrfectPalette.glowSecondary
|
||||
)
|
||||
}
|
||||
IconButton(onClick = onRefresh, enabled = !isRefreshing) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Refresh,
|
||||
@@ -457,7 +473,31 @@ class HomeLogs : Routes.Route() {
|
||||
LogLevel.WARN -> Icons.Outlined.Warning
|
||||
}
|
||||
|
||||
enum class LogCategory(val translationKey: String, val tags: List<String>) {
|
||||
CORE("log_category_core", listOf("core", "hook", "module", "mappings")),
|
||||
AUTO_OPEN("log_category_auto_open", listOf("autoopenengine", "autoopen")),
|
||||
MEDIA("log_category_media", listOf("downloader", "ffmpeg", "media", "video")),
|
||||
BRIDGE("log_category_bridge", listOf("messagingbridge", "bridge", "ipc")),
|
||||
SYSTEM("log_category_system", listOf("systemguard", "thermal", "battery", "wakelock")),
|
||||
TRACKER("log_category_tracker", listOf("tracker", "friendtracker"))
|
||||
}
|
||||
|
||||
val enabledCategories = mutableStateMapOf<LogCategory, Boolean>().apply {
|
||||
LogCategory.entries.forEach { put(it, true) }
|
||||
}
|
||||
|
||||
internal fun getCategoryForLog(line: LogLine): LogCategory? {
|
||||
val tag = line.tag.lowercase()
|
||||
val message = line.message.lowercase()
|
||||
return LogCategory.entries.find { category ->
|
||||
category.tags.any { tag.contains(it) || message.contains("[$it]") }
|
||||
}
|
||||
}
|
||||
|
||||
internal fun shouldHideLog(line: LogLine): Boolean {
|
||||
val category = getCategoryForLog(line)
|
||||
if (category != null && enabledCategories[category] == false) return true
|
||||
|
||||
val message = line.message.lowercase()
|
||||
val tag = line.tag.lowercase()
|
||||
return message.startsWith("blocked ep") ||
|
||||
|
||||
@@ -127,6 +127,8 @@ import me.eternal.purrfectsnap.storage.getQuickTiles
|
||||
import me.eternal.purrfectsnap.storage.setQuickTiles
|
||||
import me.eternal.purrfectsnap.ui.manager.Routes
|
||||
import me.eternal.purrfectsnap.ui.manager.ManagerTheme
|
||||
import me.eternal.purrfectsnap.ui.manager.ManagerAssistantEntry
|
||||
import me.eternal.purrfectsnap.ui.manager.ManagerAssistantTriggerStyle
|
||||
import me.eternal.purrfectsnap.ui.manager.theme.PurrfectPalette
|
||||
import me.eternal.purrfectsnap.ui.manager.data.UpdateDownloader
|
||||
import me.eternal.purrfectsnap.ui.manager.data.Updater
|
||||
@@ -271,27 +273,31 @@ class HomeRootSection : Routes.Route() {
|
||||
icon: ImageVector,
|
||||
label: String? = null,
|
||||
contentDescription: String? = label,
|
||||
modifier: Modifier = Modifier,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
Surface(
|
||||
modifier = modifier.height(36.dp),
|
||||
shape = RoundedCornerShape(40),
|
||||
color = Color.White.copy(alpha = 0.06f),
|
||||
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.12f))
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(40))
|
||||
.clickable(onClick = onClick)
|
||||
.padding(horizontal = 14.dp, vertical = 8.dp),
|
||||
.padding(horizontal = 10.dp, vertical = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
horizontalArrangement = Arrangement.Center
|
||||
) {
|
||||
Icon(icon, contentDescription = contentDescription, tint = Color.White)
|
||||
Icon(icon, contentDescription = contentDescription, tint = Color.White, modifier = Modifier.size(20.dp))
|
||||
label?.let {
|
||||
Spacer(modifier = Modifier.width(6.dp))
|
||||
Text(
|
||||
text = it,
|
||||
color = Color.White,
|
||||
fontSize = 13.sp,
|
||||
fontSize = 12.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
@@ -323,13 +329,20 @@ class HomeRootSection : Routes.Route() {
|
||||
|
||||
@Composable
|
||||
private fun RowScope.HomeActionChips() {
|
||||
ManagerAssistantEntry(
|
||||
context = context,
|
||||
routes = routes,
|
||||
style = ManagerAssistantTriggerStyle.DEFAULT
|
||||
)
|
||||
TopBarActionChip(
|
||||
icon = Icons.Filled.BugReport,
|
||||
label = context.translation["manager.routes.home_logs"]
|
||||
label = context.translation["manager.routes.home_logs"],
|
||||
modifier = Modifier
|
||||
) { routes.homeLogs.navigate() }
|
||||
TopBarActionChip(
|
||||
icon = Icons.Filled.Info,
|
||||
label = translation["manager.routes.home_about"]
|
||||
label = translation["manager.routes.home_about"],
|
||||
modifier = Modifier
|
||||
) { routes.about.navigate() }
|
||||
}
|
||||
|
||||
@@ -733,9 +746,8 @@ class HomeRootSection : Routes.Route() {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
modifier = Modifier.wrapContentWidth(),
|
||||
) {
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
HomeActionChips()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -75,34 +75,9 @@ class HomeSettings : Routes.Route() {
|
||||
internal fun scheduleUpdateCheck() {
|
||||
val workManager = WorkManager.getInstance(context.androidContext)
|
||||
val updateSettings = context.config.root.global.updateSettings
|
||||
var configDirty = false
|
||||
val autoUpdateCheck = updateSettings.autoUpdateCheck.getNullable() ?: run {
|
||||
configDirty = true
|
||||
updateSettings.autoUpdateCheck.set(true)
|
||||
true
|
||||
}
|
||||
val frequency = updateSettings.updateCheckFrequency.getNullable() ?: run {
|
||||
configDirty = true
|
||||
updateSettings.updateCheckFrequency.set("daily")
|
||||
"daily"
|
||||
}
|
||||
val updateChannel = updateSettings.updateChannel.getNullable() ?: run {
|
||||
configDirty = true
|
||||
updateSettings.updateChannel.set("stable")
|
||||
"stable"
|
||||
}
|
||||
if (configDirty) {
|
||||
context.config.writeConfig()
|
||||
}
|
||||
val autoUpdateCheck = updateSettings.autoUpdateCheck.get()
|
||||
|
||||
if (autoUpdateCheck) {
|
||||
val repeatInterval = when (frequency) {
|
||||
"daily" -> 1L
|
||||
"weekly" -> 7L
|
||||
"monthly" -> 30L
|
||||
else -> 1L
|
||||
}
|
||||
|
||||
val constraints = Constraints.Builder()
|
||||
.setRequiredNetworkType(NetworkType.CONNECTED)
|
||||
.build()
|
||||
@@ -112,10 +87,10 @@ class HomeSettings : Routes.Route() {
|
||||
.putString("channel_description", translation["update_notification_channel_description"])
|
||||
.putString("notification_title", translation["update_notification_title"])
|
||||
.putString("notification_text", translation["update_notification_text"])
|
||||
.putString("update_channel", updateChannel)
|
||||
.putString("update_channel", "stable")
|
||||
.build()
|
||||
|
||||
val workRequest = PeriodicWorkRequestBuilder<UpdateCheckWorker>(repeatInterval, TimeUnit.DAYS)
|
||||
val workRequest = PeriodicWorkRequestBuilder<UpdateCheckWorker>(1, TimeUnit.DAYS)
|
||||
.setConstraints(constraints)
|
||||
.setInputData(inputData)
|
||||
.build()
|
||||
|
||||
@@ -279,8 +279,9 @@ class ManageScriptReposSection : Routes.Route() {
|
||||
}
|
||||
|
||||
override val content: @Composable (androidx.navigation.NavBackStackEntry) -> Unit = {
|
||||
val repositories by remember(refreshTrigger.value) {
|
||||
mutableStateOf<List<String>>(runBlocking { context.database.getRepositories("script") })
|
||||
var repositories by remember { mutableStateOf<List<String>>(emptyList()) }
|
||||
LaunchedEffect(refreshTrigger.value) {
|
||||
repositories = context.database.getRepositories("script")
|
||||
}
|
||||
val density = LocalDensity.current
|
||||
val statusBarTopPadding = WindowInsets.statusBars.asPaddingValues().calculateTopPadding()
|
||||
|
||||
@@ -26,9 +26,11 @@ import kotlinx.coroutines.*
|
||||
import me.eternal.purrfectsnap.RemoteSideContext
|
||||
import me.eternal.purrfectsnap.common.data.MessagingFriendInfo
|
||||
import me.eternal.purrfectsnap.common.data.MessagingGroupInfo
|
||||
import me.eternal.purrfectsnap.common.data.MessagingRuleType
|
||||
import me.eternal.purrfectsnap.common.util.snap.BitmojiSelfie
|
||||
import me.eternal.purrfectsnap.storage.getFriends
|
||||
import me.eternal.purrfectsnap.storage.getGroups
|
||||
import me.eternal.purrfectsnap.storage.getRuleIds
|
||||
import me.eternal.purrfectsnap.ui.manager.theme.PurrfectPalette
|
||||
import me.eternal.purrfectsnap.ui.util.coil.BitmojiImage
|
||||
|
||||
@@ -223,32 +225,34 @@ class AddFriendDialog(
|
||||
friends: List<MessagingFriendInfo>,
|
||||
groups: List<MessagingGroupInfo>
|
||||
) {
|
||||
cachedFriends = friends.run {
|
||||
if (pinnedIds != null) {
|
||||
sortedBy { -pinnedIds.indexOf(it.userId) }
|
||||
} else {
|
||||
this
|
||||
coroutineScope.launch(Dispatchers.IO) {
|
||||
val sortedFriends = context.sortSocialFriends(friends, pinnedIds = pinnedIds)
|
||||
val sortedGroups = groups.run {
|
||||
if (pinnedIds != null) {
|
||||
sortedBy { -pinnedIds.indexOf(it.conversationId) }
|
||||
} else {
|
||||
// Priority sort for whitelisted groups
|
||||
val whitelistedIds = context.database.getRuleIds(MessagingRuleType.STEALTH.key).toSet()
|
||||
sortedWith { a, b ->
|
||||
val aSelected = whitelistedIds.contains(a.conversationId)
|
||||
val bSelected = whitelistedIds.contains(b.conversationId)
|
||||
if (aSelected != bSelected) if (aSelected) -1 else 1
|
||||
else a.name.compareTo(b.name, ignoreCase = true)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
cachedGroups = groups.run {
|
||||
if (pinnedIds != null) {
|
||||
sortedBy { -pinnedIds.indexOf(it.conversationId) }
|
||||
} else {
|
||||
this
|
||||
withContext(Dispatchers.Main) {
|
||||
cachedFriends = sortedFriends
|
||||
cachedGroups = sortedGroups
|
||||
if (friends.isNotEmpty() || groups.isNotEmpty()) {
|
||||
timeoutJob?.cancel()
|
||||
hasFetchError = false
|
||||
}
|
||||
}
|
||||
}
|
||||
if (friends.isNotEmpty() || groups.isNotEmpty()) {
|
||||
timeoutJob?.cancel()
|
||||
hasFetchError = false
|
||||
}
|
||||
}
|
||||
|
||||
val updateSnapshot: (List<MessagingFriendInfo>, List<MessagingGroupInfo>) -> Unit = { friends, groups ->
|
||||
coroutineScope.launch {
|
||||
applySnapshot(friends, groups)
|
||||
}
|
||||
}
|
||||
|
||||
// Initial database load
|
||||
withContext(Dispatchers.IO) {
|
||||
applySnapshot(
|
||||
context.database.getFriends(descOrder = true),
|
||||
@@ -256,24 +260,11 @@ class AddFriendDialog(
|
||||
)
|
||||
}
|
||||
|
||||
if (context.bridgeService != null) {
|
||||
context.bridgeService?.requestEphemeralSocialSnapshot(updateSnapshot)
|
||||
} else {
|
||||
context.database.receiveMessagingDataCallback = updateSnapshot
|
||||
}
|
||||
// Real-time synchronization flow
|
||||
context.requestSocialSnapshotRefresh()
|
||||
|
||||
coroutineScope.launch(Dispatchers.IO) {
|
||||
repeat(25) {
|
||||
delay(1000)
|
||||
val dbFriends = context.database.getFriends(descOrder = true)
|
||||
val dbGroups = context.database.getGroups()
|
||||
if (dbFriends.isNotEmpty() || dbGroups.isNotEmpty()) {
|
||||
withContext(Dispatchers.Main) {
|
||||
applySnapshot(dbFriends, dbGroups)
|
||||
}
|
||||
return@launch
|
||||
}
|
||||
coroutineScope.launch {
|
||||
context.database.messagingDataFlow.collect { (friends, groups) ->
|
||||
applySnapshot(friends, groups)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -290,7 +281,6 @@ class AddFriendDialog(
|
||||
onDispose {
|
||||
timeoutJob?.cancel()
|
||||
context.bridgeService?.clearEphemeralSocialSnapshotRequest()
|
||||
context.database.receiveMessagingDataCallback = { _, _ -> }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -350,6 +340,7 @@ class AddFriendDialog(
|
||||
it.mutableUsername.contains(searchKeyword.value, ignoreCase = true) ||
|
||||
it.displayName?.contains(searchKeyword.value, ignoreCase = true) == true
|
||||
} ?: cachedFriends!!
|
||||
|
||||
val selectedFriendCount by remember(filteredFriends) {
|
||||
derivedStateOf {
|
||||
filteredFriends.count { friend ->
|
||||
@@ -360,6 +351,16 @@ class AddFriendDialog(
|
||||
val hasFriendsSelected = selectedFriendCount > 0
|
||||
val allFriendsSelected = filteredFriends.isNotEmpty() && selectedFriendCount == filteredFriends.size
|
||||
|
||||
val selectedGroupCount by remember(filteredGroups) {
|
||||
derivedStateOf {
|
||||
filteredGroups.count { group ->
|
||||
stateCache[group.conversationId] ?: actionHandler.getGroupState(group)
|
||||
}
|
||||
}
|
||||
}
|
||||
val hasGroupsSelected = selectedGroupCount > 0
|
||||
val allGroupsSelected = filteredGroups.isNotEmpty() && selectedGroupCount == filteredGroups.size
|
||||
|
||||
DialogHeader(searchKeyword)
|
||||
|
||||
LazyColumn(
|
||||
@@ -369,14 +370,54 @@ class AddFriendDialog(
|
||||
) {
|
||||
item {
|
||||
if (filteredGroups.isNotEmpty()) {
|
||||
Text(
|
||||
text = translation["category_groups"],
|
||||
fontSize = 16.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(bottom = 8.dp, top = 8.dp),
|
||||
color = Color.White
|
||||
)
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
text = translation["category_groups"],
|
||||
fontSize = 16.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = Color.White
|
||||
)
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
TextButton(
|
||||
onClick = {
|
||||
coroutineScope.launch(Dispatchers.IO) {
|
||||
filteredGroups.forEach { group ->
|
||||
stateCache[group.conversationId] = true
|
||||
actionHandler.onGroupState(group, true)
|
||||
}
|
||||
}
|
||||
},
|
||||
enabled = !allGroupsSelected
|
||||
) {
|
||||
Text(
|
||||
text = context.translation["manager.dialogs.messaging_action.select_all_button"],
|
||||
color = if (allGroupsSelected) Color.White.copy(alpha = 0.45f) else PurrfectPalette.glowSecondary
|
||||
)
|
||||
}
|
||||
TextButton(
|
||||
onClick = {
|
||||
coroutineScope.launch(Dispatchers.IO) {
|
||||
filteredGroups.forEach { group ->
|
||||
stateCache[group.conversationId] = false
|
||||
actionHandler.onGroupState(group, false)
|
||||
}
|
||||
}
|
||||
},
|
||||
enabled = hasGroupsSelected
|
||||
) {
|
||||
Text(
|
||||
text = translation["unselect_all_button"],
|
||||
color = if (hasGroupsSelected) PurrfectPalette.glowPrimary else Color.White.copy(alpha = 0.45f)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -421,11 +462,7 @@ class AddFriendDialog(
|
||||
) {
|
||||
Text(
|
||||
text = context.translation["manager.dialogs.messaging_action.select_all_button"],
|
||||
color = if (allFriendsSelected) {
|
||||
Color.White.copy(alpha = 0.45f)
|
||||
} else {
|
||||
PurrfectPalette.glowSecondary
|
||||
}
|
||||
color = if (allFriendsSelected) Color.White.copy(alpha = 0.45f) else PurrfectPalette.glowSecondary
|
||||
)
|
||||
}
|
||||
TextButton(
|
||||
@@ -441,11 +478,7 @@ class AddFriendDialog(
|
||||
) {
|
||||
Text(
|
||||
text = translation["unselect_all_button"],
|
||||
color = if (hasFriendsSelected) {
|
||||
PurrfectPalette.glowPrimary
|
||||
} else {
|
||||
Color.White.copy(alpha = 0.45f)
|
||||
}
|
||||
color = if (hasFriendsSelected) PurrfectPalette.glowPrimary else Color.White.copy(alpha = 0.45f)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,6 +35,7 @@ import me.eternal.purrfectsnap.storage.getFriendInfo
|
||||
import me.eternal.purrfectsnap.ui.manager.Routes
|
||||
import me.eternal.purrfectsnap.ui.util.Dialog
|
||||
import me.eternal.purrfectsnap.ui.util.coil.ImageRequestHelper
|
||||
import me.eternal.purrfectsnap.common.ui.rememberAsyncMutableState
|
||||
import java.io.File
|
||||
import java.text.DateFormat
|
||||
import java.util.Date
|
||||
@@ -44,12 +45,11 @@ import kotlin.math.absoluteValue
|
||||
class LoggedStories : Routes.Route() {
|
||||
override val title: @Composable () -> Unit = {
|
||||
val navBackStackEntry by routes.navController.currentBackStackEntryAsState()
|
||||
val text = remember(navBackStackEntry) {
|
||||
navBackStackEntry?.arguments?.getString("id")?.let {
|
||||
context.database.getFriendInfo(it)?.displayName
|
||||
}
|
||||
val userId = navBackStackEntry?.arguments?.getString("id")
|
||||
val displayName by rememberAsyncMutableState(defaultValue = null) {
|
||||
userId?.let { context.database.getFriendInfo(it)?.displayName }
|
||||
}
|
||||
text?.let { Text(it, maxLines = 1, overflow = TextOverflow.Ellipsis) }
|
||||
displayName?.let { Text(it, maxLines = 1, overflow = TextOverflow.Ellipsis) }
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalCoilApi::class, ExperimentalLayoutApi::class)
|
||||
@@ -57,7 +57,9 @@ class LoggedStories : Routes.Route() {
|
||||
val userId = navBackStackEntry.arguments?.getString("id") ?: return@content
|
||||
|
||||
val stories = remember { mutableStateListOf<StoryData>() }
|
||||
val friendInfo = remember { context.database.getFriendInfo(userId) }
|
||||
val friendInfo by rememberAsyncMutableState(defaultValue = null) {
|
||||
context.database.getFriendInfo(userId)
|
||||
}
|
||||
var lastStoryTimestamp by remember { mutableLongStateOf(Long.MAX_VALUE) }
|
||||
|
||||
var selectedStory by remember { mutableStateOf<StoryData?>(null) }
|
||||
|
||||
@@ -37,6 +37,8 @@ import me.eternal.purrfectsnap.common.data.MessagingFriendInfo
|
||||
import me.eternal.purrfectsnap.common.data.MessagingGroupInfo
|
||||
import me.eternal.purrfectsnap.common.data.MessagingRuleType
|
||||
import me.eternal.purrfectsnap.common.data.SocialScope
|
||||
import me.eternal.purrfectsnap.common.data.normalizeStealthRules
|
||||
import me.eternal.purrfectsnap.common.data.withNormalizedRuleToggle
|
||||
import me.eternal.purrfectsnap.common.ui.AutoClearKeyboardFocus
|
||||
import me.eternal.purrfectsnap.common.ui.EditNoteTextField
|
||||
import me.eternal.purrfectsnap.common.ui.rememberAsyncMutableState
|
||||
@@ -222,18 +224,30 @@ class ManageScope: Routes.Route() {
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
val rules = rememberAsyncMutableStateList(listOf()) {
|
||||
context.database.getRules(id)
|
||||
context.database.getRules(id).normalizeStealthRules().toList()
|
||||
}
|
||||
|
||||
fun updateRules(ruleType: MessagingRuleType, enabled: Boolean) {
|
||||
val previousRules = rules.toSet()
|
||||
val updatedRules = previousRules.withNormalizedRuleToggle(ruleType, enabled)
|
||||
val changedRules = (previousRules + updatedRules).filter { rule ->
|
||||
(rule in previousRules) != (rule in updatedRules)
|
||||
}
|
||||
|
||||
rules.clear()
|
||||
rules.addAll(updatedRules)
|
||||
|
||||
changedRules.forEach { changedRule ->
|
||||
context.database.setRule(id, changedRule.key, changedRule in updatedRules)
|
||||
}
|
||||
}
|
||||
|
||||
SectionTitle(translation["rules_title"])
|
||||
|
||||
ContentCard {
|
||||
MessagingRuleType.entries.forEach { ruleType ->
|
||||
var ruleEnabled by remember(rules.size) {
|
||||
mutableStateOf(rules.any { it.key == ruleType.key })
|
||||
}
|
||||
|
||||
val ruleState = context.config.root.rules.getRuleState(ruleType)
|
||||
val ruleEnabled = rules.any { it.key == ruleType.key }
|
||||
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
@@ -252,8 +266,7 @@ class ManageScope: Routes.Route() {
|
||||
checked = ruleEnabled,
|
||||
enabled = if (ruleType.listMode) ruleState != null else true,
|
||||
onCheckedChange = {
|
||||
context.database.setRule(id, ruleType.key, it)
|
||||
ruleEnabled = it
|
||||
updateRules(ruleType, it)
|
||||
},
|
||||
colors = purrfectSwitchColors()
|
||||
)
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
package me.eternal.purrfectsnap.ui.manager.pages.social
|
||||
|
||||
import me.eternal.purrfectsnap.RemoteSideContext
|
||||
import me.eternal.purrfectsnap.common.data.MessagingFriendInfo
|
||||
import me.eternal.purrfectsnap.storage.getFriends
|
||||
|
||||
internal fun RemoteSideContext.sortSocialFriends(
|
||||
friends: List<MessagingFriendInfo>,
|
||||
pinnedIds: List<String>? = null
|
||||
): List<MessagingFriendInfo> {
|
||||
val whitelistedIds = pinnedIds?.toSet() ?: database.getFriends().map { it.userId }.toSet()
|
||||
val sortByStreakLength = config.root.userInterface.sortSocialTabByStreakLength.get()
|
||||
|
||||
return friends.sortedWith { a, b ->
|
||||
val aSelected = whitelistedIds.contains(a.userId)
|
||||
val bSelected = whitelistedIds.contains(b.userId)
|
||||
|
||||
if (aSelected != bSelected) {
|
||||
return@sortedWith if (aSelected) -1 else 1
|
||||
}
|
||||
|
||||
if (sortByStreakLength) {
|
||||
val aStreak = a.streaks?.length ?: 0
|
||||
val bStreak = b.streaks?.length ?: 0
|
||||
if (aStreak != bStreak) return@sortedWith bStreak.compareTo(aStreak)
|
||||
}
|
||||
|
||||
(a.displayName ?: a.mutableUsername).compareTo(b.displayName ?: b.mutableUsername, ignoreCase = true)
|
||||
}
|
||||
}
|
||||
@@ -35,8 +35,7 @@ import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.navigation.NavBackStackEntry
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.*
|
||||
import me.eternal.purrfectsnap.R
|
||||
import me.eternal.purrfectsnap.common.data.MessagingFriendInfo
|
||||
import me.eternal.purrfectsnap.common.data.MessagingGroupInfo
|
||||
@@ -53,10 +52,32 @@ class SocialRootSection : Routes.Route() {
|
||||
internal var friendList: List<MessagingFriendInfo> by mutableStateOf(emptyList())
|
||||
internal var groupList: List<MessagingGroupInfo> by mutableStateOf(emptyList())
|
||||
|
||||
internal fun updateScopeLists() {
|
||||
context.coroutineScope.launch {
|
||||
friendList = context.database.getFriends(descOrder = true)
|
||||
groupList = context.database.getGroups()
|
||||
@Composable
|
||||
fun SocialDataController() {
|
||||
LaunchedEffect(Unit) {
|
||||
// Initial data fetch from the database
|
||||
withContext(Dispatchers.IO) {
|
||||
val dbFriends = context.database.getFriends(descOrder = true)
|
||||
val dbGroups = context.database.getGroups()
|
||||
val sortedFriends = context.sortSocialFriends(dbFriends)
|
||||
withContext(Dispatchers.Main) {
|
||||
friendList = sortedFriends
|
||||
groupList = dbGroups
|
||||
}
|
||||
}
|
||||
|
||||
// Real-time synchronization from the bridge
|
||||
context.database.messagingDataFlow.collect {
|
||||
withContext(Dispatchers.IO) {
|
||||
val dbFriends = context.database.getFriends(descOrder = true)
|
||||
val dbGroups = context.database.getGroups()
|
||||
val sortedFriends = context.sortSocialFriends(dbFriends)
|
||||
withContext(Dispatchers.Main) {
|
||||
friendList = sortedFriends
|
||||
groupList = dbGroups
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -124,11 +145,6 @@ class SocialRootSection : Routes.Route() {
|
||||
addFriendDialog?.Content {
|
||||
addFriendDialog = null
|
||||
}
|
||||
DisposableEffect(Unit) {
|
||||
onDispose {
|
||||
updateScopeLists()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
FloatingActionButton(
|
||||
@@ -158,8 +174,7 @@ class SocialRootSection : Routes.Route() {
|
||||
},
|
||||
getFriendState = { friend -> context.database.getFriendInfo(friend.userId) != null },
|
||||
getGroupState = { group -> context.database.getGroupInfo(group.conversationId) != null }
|
||||
),
|
||||
pinnedIds = (friendList.map { it.userId } + groupList.map { it.conversationId }).reversed(),
|
||||
)
|
||||
)
|
||||
},
|
||||
modifier = Modifier
|
||||
|
||||
@@ -135,13 +135,26 @@ fun HomeAbout.AphelionAboutScreen(nav: NavBackStackEntry) {
|
||||
color = Color.White,
|
||||
modifier = Modifier.padding(bottom = 4.dp)
|
||||
)
|
||||
Row(
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp, Alignment.CenterHorizontally),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
DeveloperCard(name = "ΞTΞRNAL", imageRes = R.drawable.pfp_external, avenirNext = avenirNext, modifier = Modifier.weight(1f))
|
||||
DeveloperCard(name = "<RSR/>", imageRes = R.drawable.pfp_rsr, avenirNext = avenirNext, modifier = Modifier.weight(1f))
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp, Alignment.CenterHorizontally),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
DeveloperCard(name = "Eternal", subtitle = "", imageRes = R.drawable.pfp_external, avenirNext = avenirNext, modifier = Modifier.weight(1f))
|
||||
DeveloperCard(name = "Kaladin", subtitle = "", imageRes = R.drawable.pfp_kaladin, avenirNext = avenirNext, modifier = Modifier.weight(1f))
|
||||
}
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp, Alignment.CenterHorizontally),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
DeveloperCard(name = "schrodingerspet", subtitle = "", imageRes = R.drawable.pfp_schrodingerspet, avenirNext = avenirNext, modifier = Modifier.weight(1f))
|
||||
DeveloperCard(name = "RSR", subtitle = "", imageRes = R.drawable.pfp_rsr, avenirNext = avenirNext, modifier = Modifier.weight(1f))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,6 +44,7 @@ import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.lerp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.zIndex
|
||||
@@ -63,6 +64,8 @@ import me.eternal.purrfectsnap.ui.manager.components.AestheticDialog
|
||||
import me.eternal.purrfectsnap.ui.manager.data.UpdateDownloader
|
||||
import me.eternal.purrfectsnap.ui.manager.data.Updater
|
||||
import me.eternal.purrfectsnap.ui.manager.data.Updater.Channel
|
||||
import me.eternal.purrfectsnap.ui.manager.ManagerAssistantEntry
|
||||
import me.eternal.purrfectsnap.ui.manager.ManagerAssistantTriggerStyle
|
||||
import me.eternal.purrfectsnap.ui.manager.pages.home.HomeRootSection
|
||||
import me.eternal.purrfectsnap.ui.manager.pages.home.QuickActionsDialog
|
||||
import me.eternal.purrfectsnap.ui.manager.theme.PurrfectPalette
|
||||
@@ -148,11 +151,15 @@ fun HomeRootSection.AphelionHomeScreen(nav: NavBackStackEntry) {
|
||||
label: String? = null,
|
||||
contentDescription: String? = label,
|
||||
shrinkFactor: Float = 1f,
|
||||
modifier: Modifier = Modifier,
|
||||
expandedWidth: Dp? = null,
|
||||
collapsedWidth: Dp = 36.dp,
|
||||
haptic: HapticFeedback,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
val targetWidth = expandedWidth?.let { lerp(collapsedWidth, it, shrinkFactor) }
|
||||
Surface(
|
||||
modifier = Modifier.height(36.dp).widthIn(min = 36.dp),
|
||||
modifier = modifier.height(36.dp).then(if (targetWidth != null) Modifier.width(targetWidth) else Modifier),
|
||||
shape = RoundedCornerShape(40),
|
||||
color = Color.White.copy(alpha = 0.06f),
|
||||
border = BorderStroke(
|
||||
@@ -167,9 +174,10 @@ fun HomeRootSection.AphelionHomeScreen(nav: NavBackStackEntry) {
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(40))
|
||||
.clickable { haptic.performHapticFeedback(HapticFeedbackType.LongPress); onClick() }
|
||||
.padding(vertical = 6.dp, horizontal = lerp(10.dp, 12.dp, shrinkFactor)),
|
||||
.padding(vertical = 6.dp, horizontal = lerp(7.dp, 10.dp, shrinkFactor)),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.Center
|
||||
) {
|
||||
@@ -182,17 +190,19 @@ fun HomeRootSection.AphelionHomeScreen(nav: NavBackStackEntry) {
|
||||
}
|
||||
)
|
||||
if (label != null) {
|
||||
val labelAlpha = (shrinkFactor - 0.1f).coerceIn(0f, 1f)
|
||||
Spacer(modifier = Modifier.width((8 * shrinkFactor).dp))
|
||||
Text(
|
||||
text = label,
|
||||
color = Color.White.copy(alpha = labelAlpha),
|
||||
fontSize = 12.sp, fontWeight = FontWeight.Medium,
|
||||
maxLines = 1, overflow = TextOverflow.Clip,
|
||||
modifier = Modifier
|
||||
.graphicsLayer { alpha = labelAlpha; translationX = (-4 * (1f - shrinkFactor)).dp.toPx() }
|
||||
.widthIn(max = (75 * shrinkFactor).dp)
|
||||
)
|
||||
val labelAlpha = ((shrinkFactor - 0.45f) / 0.55f).coerceIn(0f, 1f)
|
||||
if (labelAlpha > 0.02f) {
|
||||
Spacer(modifier = Modifier.width((8 * shrinkFactor).dp))
|
||||
Text(
|
||||
text = label,
|
||||
color = Color.White.copy(alpha = labelAlpha),
|
||||
fontSize = 12.sp, fontWeight = FontWeight.Medium,
|
||||
maxLines = 1, overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier
|
||||
.graphicsLayer { alpha = labelAlpha; translationX = (-4 * (1f - shrinkFactor)).dp.toPx() }
|
||||
.weight(1f, fill = false)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -206,14 +216,23 @@ fun HomeRootSection.AphelionHomeScreen(nav: NavBackStackEntry) {
|
||||
val shrinkFactor by remember(scrollState.value) {
|
||||
derivedStateOf { (1f - (scrollState.value.toFloat() / Motion.HEADER_MORPH_THRESHOLD)).coerceIn(0f, 1f) }
|
||||
}
|
||||
ManagerAssistantEntry(
|
||||
context = context,
|
||||
routes = routes,
|
||||
style = ManagerAssistantTriggerStyle.APHELION,
|
||||
shrinkFactor = shrinkFactor,
|
||||
modifier = Modifier.width(lerp(36.dp, 66.dp, shrinkFactor))
|
||||
)
|
||||
AphelionTopBarActionChip(
|
||||
icon = Icons.Filled.BugReport,
|
||||
label = context.translation["manager.routes.home_logs"],
|
||||
expandedWidth = 88.dp,
|
||||
shrinkFactor = shrinkFactor, haptic = haptic
|
||||
) { routes.homeLogs.navigate() }
|
||||
AphelionTopBarActionChip(
|
||||
icon = Icons.Filled.Settings,
|
||||
label = context.translation["manager.routes.home_settings"],
|
||||
expandedWidth = 96.dp,
|
||||
shrinkFactor = shrinkFactor, haptic = haptic
|
||||
) { routes.settings.navigate() }
|
||||
}
|
||||
@@ -226,7 +245,6 @@ fun HomeRootSection.AphelionHomeScreen(nav: NavBackStackEntry) {
|
||||
downloadState: UpdateDownloader.DownloadState,
|
||||
downloadProgress: Float,
|
||||
onUpdateAction: () -> Unit,
|
||||
channelLabel: String,
|
||||
isPurrAuraActive: Boolean,
|
||||
onAboutClick: () -> Unit,
|
||||
avenirNext: FontFamily,
|
||||
@@ -283,7 +301,7 @@ fun HomeRootSection.AphelionHomeScreen(nav: NavBackStackEntry) {
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp, Alignment.CenterHorizontally),
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp)
|
||||
) {
|
||||
HeroBadge(translation.format("hero_version_label", "version" to versionName, "channel" to channelLabel))
|
||||
HeroBadge(translation.format("hero_version_label", "version" to versionName))
|
||||
gitHashShort.takeIf { it.isNotBlank() && it.lowercase() != "unknown" }?.let {
|
||||
HeroBadge(translation.format("hero_build_label", "build" to it))
|
||||
}
|
||||
@@ -444,17 +462,17 @@ fun HomeRootSection.AphelionHomeScreen(nav: NavBackStackEntry) {
|
||||
}
|
||||
hasInitialized -> storedTiles
|
||||
else -> {
|
||||
context.database.setQuickTiles(allQuickTileNames)
|
||||
prefs.edit().putBoolean(HomeRootSection.QUICK_TILES_INITIALIZED_PREF, true).apply()
|
||||
context.coroutineScope.launch(Dispatchers.IO) {
|
||||
context.database.setQuickTiles(allQuickTileNames)
|
||||
prefs.edit().putBoolean(HomeRootSection.QUICK_TILES_INITIALIZED_PREF, true).apply()
|
||||
}
|
||||
allQuickTileNames
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val updateChannel = context.config.root.global.updateSettings.updateChannel.getNullable() ?: "stable"
|
||||
val channelLabel = if (updateChannel == "prerelease") translation["channel_label_prerelease"] ?: "" else translation["channel_label_stable"] ?: ""
|
||||
val latestUpdate by rememberAsyncMutableState(defaultValue = null, keys = arrayOf(updateChannel)) {
|
||||
Updater.getLatestRelease(if (updateChannel == "prerelease") Channel.PRERELEASE else Channel.STABLE)
|
||||
val latestUpdate by rememberAsyncMutableState(defaultValue = null) {
|
||||
Updater.getLatestRelease(Channel.STABLE)
|
||||
}
|
||||
val downloadState by UpdateDownloader.downloadState.collectAsState()
|
||||
val downloadProgress by UpdateDownloader.downloadProgress.collectAsState()
|
||||
@@ -502,7 +520,7 @@ fun HomeRootSection.AphelionHomeScreen(nav: NavBackStackEntry) {
|
||||
changelogLoading = true
|
||||
changelogError = null
|
||||
coroutineScope.launch(Dispatchers.IO) {
|
||||
val url = if (updateChannel == "prerelease") changelogPrereleaseUrl else changelogStableUrl
|
||||
val url = changelogStableUrl
|
||||
runCatching {
|
||||
OkHttpClient().newCall(Request.Builder().url(url).build()).execute().use { response ->
|
||||
val body = response.body?.string() ?: throw IllegalStateException("Empty body")
|
||||
@@ -540,7 +558,7 @@ fun HomeRootSection.AphelionHomeScreen(nav: NavBackStackEntry) {
|
||||
fullChangelogLoading = true
|
||||
fullChangelogError = null
|
||||
coroutineScope.launch(Dispatchers.IO) {
|
||||
val url = if (updateChannel == "prerelease") changelogPrereleaseUrl else changelogStableUrl
|
||||
val url = changelogStableUrl
|
||||
runCatching {
|
||||
OkHttpClient().newCall(Request.Builder().url(url).build()).execute().use { response ->
|
||||
val body = response.body?.string() ?: throw IllegalStateException("Empty body")
|
||||
@@ -646,37 +664,27 @@ fun HomeRootSection.AphelionHomeScreen(nav: NavBackStackEntry) {
|
||||
.padding(horizontal = 16.dp)
|
||||
.height(headerHeight)
|
||||
) {
|
||||
Text(
|
||||
text = "PurrfectSnap",
|
||||
color = Color.White.copy(alpha = stickyBrandingAlpha),
|
||||
fontSize = 18.sp, fontWeight = FontWeight.Bold, fontFamily = avenirNext,
|
||||
modifier = Modifier.align(Alignment.Center)
|
||||
)
|
||||
val announcementShift by remember(focusFactor) { derivedStateOf { (-6 * focusFactor).dp } }
|
||||
Row(
|
||||
modifier = Modifier.align(Alignment.CenterStart).graphicsLayer { translationX = announcementShift.toPx() },
|
||||
modifier = Modifier
|
||||
.align(Alignment.Center)
|
||||
.wrapContentWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp)
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp)
|
||||
) {
|
||||
AphelionTopBarActionChip(
|
||||
icon = Icons.Filled.Notifications, label = null,
|
||||
expandedWidth = 52.dp,
|
||||
shrinkFactor = (1f - focusFactor).coerceIn(0f, 1f),
|
||||
contentDescription = translation["announcements_button_description"],
|
||||
haptic = haptic
|
||||
) { showAnnouncementsDialog = true; loadAnnouncements() }
|
||||
AphelionTopBarActionChip(
|
||||
icon = Icons.Filled.Description, label = null,
|
||||
expandedWidth = 52.dp,
|
||||
shrinkFactor = (1f - focusFactor).coerceIn(0f, 1f),
|
||||
contentDescription = translation.getOrNull("changelog_button_description") ?: "Open full changelog",
|
||||
haptic = haptic
|
||||
) { showFullChangelogDialog = true; loadFullChangelog() }
|
||||
}
|
||||
val settingsShift by remember(focusFactor) { derivedStateOf { (6 * focusFactor).dp } }
|
||||
Row(
|
||||
modifier = Modifier.align(Alignment.CenterEnd).graphicsLayer { translationX = settingsShift.toPx() },
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
AphelionHomeActionChips(scrollState = scrollState, haptic = haptic)
|
||||
}
|
||||
}
|
||||
@@ -692,7 +700,6 @@ fun HomeRootSection.AphelionHomeScreen(nav: NavBackStackEntry) {
|
||||
downloadState = downloadState,
|
||||
downloadProgress = downloadProgress,
|
||||
onUpdateAction = { latestUpdate?.let { showChangelogDialog = true; loadChangelog() } },
|
||||
channelLabel = channelLabel,
|
||||
isPurrAuraActive = isPurrAuraActive,
|
||||
onAboutClick = { routes.about.navigate() },
|
||||
avenirNext = avenirNext,
|
||||
|
||||
@@ -2,27 +2,31 @@ package me.eternal.purrfectsnap.ui.manager.pages.themes.aphelion
|
||||
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.DeleteSweep
|
||||
import androidx.compose.material.icons.filled.Download
|
||||
import androidx.compose.material.icons.filled.MoreVert
|
||||
import androidx.compose.material.icons.filled.Refresh
|
||||
import androidx.compose.material.icons.filled.*
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.DpOffset
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import androidx.navigation.NavBackStackEntry
|
||||
import me.eternal.purrfectsnap.ui.manager.components.FloatingTopBar
|
||||
import me.eternal.purrfectsnap.ui.manager.pages.home.HomeLogs
|
||||
import me.eternal.purrfectsnap.ui.manager.theme.PurrfectPalette
|
||||
import me.eternal.purrfectsnap.core.ui.PurrfectGlassCard
|
||||
import me.eternal.purrfectsnap.core.ui.PurrfectOverlayTheme
|
||||
import me.eternal.purrfectsnap.ui.util.headerHeightTracker
|
||||
import me.eternal.purrfectsnap.ui.util.Motion
|
||||
import kotlinx.coroutines.launch
|
||||
@@ -37,20 +41,17 @@ fun HomeLogs.AphelionLogsScreen(nav: NavBackStackEntry) {
|
||||
var logReader by remember { mutableStateOf<me.eternal.purrfectsnap.LogReader?>(null) }
|
||||
val visibleLogs = remember { mutableStateListOf<me.eternal.purrfectsnap.LogLine>() }
|
||||
var isRefreshing by remember { mutableStateOf(false) }
|
||||
var showFilterDialog by remember { mutableStateOf(false) }
|
||||
|
||||
fun refreshLogs() {
|
||||
isRefreshing = true
|
||||
coroutineScope.launch(Dispatchers.IO) {
|
||||
val readerResult = runCatching {
|
||||
context.log.newReader { line ->
|
||||
if (shouldHideLog(line)) return@newReader
|
||||
coroutineScope.launch(Dispatchers.Main) {
|
||||
visibleLogs.add(line)
|
||||
}
|
||||
}
|
||||
context.log.newReader { /* items are batch-added from reader logic below */ }
|
||||
}
|
||||
readerResult.onFailure {
|
||||
context.longToast(translation["read_logs_failed_toast"] ?: "Failed to read logs")
|
||||
withContext(Dispatchers.Main) { isRefreshing = false }
|
||||
}
|
||||
readerResult.getOrNull()?.let { reader ->
|
||||
logReader = reader
|
||||
@@ -69,6 +70,78 @@ fun HomeLogs.AphelionLogsScreen(nav: NavBackStackEntry) {
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun LogFilterDialog() {
|
||||
Dialog(onDismissRequest = { showFilterDialog = false }) {
|
||||
PurrfectOverlayTheme {
|
||||
PurrfectGlassCard(
|
||||
title = translation["filter_logs_title"] ?: "Log Filters",
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 4.dp)
|
||||
) {
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
color = Color.White.copy(alpha = 0.08f),
|
||||
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.05f))
|
||||
) {
|
||||
Column(modifier = Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
HomeLogs.LogCategory.entries.forEach { category ->
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(12.dp))
|
||||
.clickable {
|
||||
enabledCategories[category] = !(enabledCategories[category] ?: true)
|
||||
refreshLogs()
|
||||
}
|
||||
.padding(horizontal = 12.dp, vertical = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Checkbox(
|
||||
checked = enabledCategories[category] == true,
|
||||
onCheckedChange = { checked ->
|
||||
enabledCategories[category] = checked
|
||||
refreshLogs()
|
||||
},
|
||||
colors = CheckboxDefaults.colors(
|
||||
checkedColor = PurrfectPalette.glowPrimary,
|
||||
uncheckedColor = Color.White.copy(alpha = 0.3f),
|
||||
checkmarkColor = Color.White
|
||||
)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text(
|
||||
text = translation[category.translationKey] ?: category.name,
|
||||
color = Color.White,
|
||||
style = MaterialTheme.typography.bodyLarge.copy(fontWeight = FontWeight.SemiBold)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Button(
|
||||
onClick = { showFilterDialog = false },
|
||||
modifier = Modifier.fillMaxWidth().height(54.dp),
|
||||
shape = RoundedCornerShape(18.dp),
|
||||
colors = ButtonDefaults.buttonColors(containerColor = PurrfectPalette.glowPrimary)
|
||||
) {
|
||||
Text(translation["filter_logs_done_button"] ?: "Apply Filters", fontWeight = FontWeight.Bold, fontSize = 16.sp)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (showFilterDialog) {
|
||||
LogFilterDialog()
|
||||
}
|
||||
|
||||
LaunchedEffect(externalRefreshTick.value) {
|
||||
if (externalRefreshTick.value > 0) {
|
||||
refreshLogs()
|
||||
@@ -132,6 +205,9 @@ fun HomeLogs.AphelionLogsScreen(nav: NavBackStackEntry) {
|
||||
color = Color.White
|
||||
)
|
||||
}
|
||||
IconButton(onClick = { showFilterDialog = true }) {
|
||||
Icon(Icons.Filled.FilterList, contentDescription = "Filter Logs", tint = PurrfectPalette.glowSecondary)
|
||||
}
|
||||
IconButton(onClick = { refreshLogs() }) {
|
||||
Icon(Icons.Filled.Refresh, contentDescription = "Refresh", tint = Color.White)
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package me.eternal.purrfectsnap.ui.manager.pages.themes.aphelion
|
||||
|
||||
import android.content.SharedPreferences
|
||||
import android.content.Intent
|
||||
import com.google.gson.JsonParser
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
@@ -29,18 +30,31 @@ import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalHapticFeedback
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import androidx.core.content.edit
|
||||
import androidx.core.net.toUri
|
||||
import androidx.navigation.NavBackStackEntry
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import me.eternal.purrfectsnap.R
|
||||
import me.eternal.purrfectsnap.common.action.EnumAction
|
||||
import me.eternal.purrfectsnap.common.bridge.InternalFileHandleType
|
||||
import me.eternal.purrfectsnap.common.bridge.wrapper.LoggerConversationExportTarget
|
||||
import me.eternal.purrfectsnap.common.bridge.wrapper.LoggedMessage
|
||||
import me.eternal.purrfectsnap.common.data.ContentType
|
||||
import me.eternal.purrfectsnap.common.ui.rememberAsyncMutableState
|
||||
import me.eternal.purrfectsnap.common.util.protobuf.ProtoReader
|
||||
import me.eternal.purrfectsnap.core.features.impl.downloader.decoder.DecodedAttachment
|
||||
import me.eternal.purrfectsnap.core.features.impl.downloader.decoder.MessageDecoder
|
||||
import me.eternal.purrfectsnap.core.wrapper.impl.getMessageText
|
||||
import me.eternal.purrfectsnap.storage.findFriend
|
||||
import me.eternal.purrfectsnap.storage.getAllScopeNotes
|
||||
import me.eternal.purrfectsnap.storage.getFriendInfo
|
||||
import me.eternal.purrfectsnap.storage.getGroupInfo
|
||||
import me.eternal.purrfectsnap.storage.setAllScopeNotes
|
||||
import me.eternal.purrfectsnap.ui.manager.Routes
|
||||
import me.eternal.purrfectsnap.ui.manager.components.AestheticDialog
|
||||
@@ -60,6 +74,8 @@ import androidx.compose.ui.platform.LocalView
|
||||
import androidx.core.view.drawToBitmap
|
||||
import java.io.File
|
||||
import java.net.URLEncoder
|
||||
import java.text.DateFormat
|
||||
import java.util.Date
|
||||
|
||||
@OptIn(ExperimentalLayoutApi::class, ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
@@ -222,22 +238,12 @@ fun HomeSettings.AphelionSettingsScreen(nav: NavBackStackEntry) {
|
||||
RowTitle(title = translation["updates_title"])
|
||||
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
var autoUpdateCheck by remember { mutableStateOf(context.config.root.global.updateSettings.autoUpdateCheck.getNullable() ?: true) }
|
||||
var selectedChannel by remember { mutableStateOf(context.config.root.global.updateSettings.updateChannel.getNullable() ?: "stable") }
|
||||
var channelMenuExpanded by remember { mutableStateOf(false) }
|
||||
ShiftedRow {
|
||||
Row(modifier = Modifier.fillMaxWidth().heightIn(min = 55.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween) {
|
||||
Text(text = translation["auto_update_check"], fontSize = 14.sp)
|
||||
Switch(checked = autoUpdateCheck, onCheckedChange = { if (context.config.root.global.uiSettings.hapticFeedback.get()) hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress); autoUpdateCheck = it; context.config.root.global.updateSettings.autoUpdateCheck.set(it); context.config.writeConfig(); scheduleUpdateCheck() }, modifier = Modifier.padding(end = 26.dp), colors = purrfectSwitchColors())
|
||||
}
|
||||
}
|
||||
AnimatedVisibility(visible = autoUpdateCheck) {
|
||||
ExposedDropdownMenuBox(expanded = channelMenuExpanded, onExpandedChange = { channelMenuExpanded = it }, modifier = Modifier.fillMaxWidth().padding(horizontal = 26.dp)) {
|
||||
AestheticDropdownField(value = translation.getOrNull("update_channel_${selectedChannel}") ?: selectedChannel, expanded = channelMenuExpanded, modifier = Modifier.fillMaxWidth().menuAnchor(MenuAnchorType.PrimaryNotEditable), onClick = { channelMenuExpanded = true })
|
||||
ExposedDropdownMenu(expanded = channelMenuExpanded, onDismissRequest = { channelMenuExpanded = false }) {
|
||||
listOf("stable", "prerelease").forEach { channel -> DropdownMenuItem(text = { Text(text = translation.getOrNull("update_channel_${channel}") ?: channel) }, onClick = { selectedChannel = channel; channelMenuExpanded = false; context.config.root.global.updateSettings.updateChannel.set(channel); context.config.writeConfig(); scheduleUpdateCheck() }) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -257,11 +263,494 @@ fun HomeSettings.AphelionSettingsScreen(nav: NavBackStackEntry) {
|
||||
var storedMessagesCount by rememberAsyncMutableState(defaultValue = 0) { context.messageLogger.getStoredMessageCount() }
|
||||
var storedStoriesCount by rememberAsyncMutableState(defaultValue = 0) { context.messageLogger.getStoredStoriesCount() }
|
||||
var showImportDialog by remember { mutableStateOf(false) }
|
||||
var showExportOptionsDialog by remember { mutableStateOf(false) }
|
||||
var showConversationExportDialog by remember { mutableStateOf(false) }
|
||||
var showConversationFormatDialog by remember { mutableStateOf(false) }
|
||||
var conversationSearchQuery by remember { mutableStateOf("") }
|
||||
var selectedConversationForExport by remember { mutableStateOf<LoggerConversationExportTarget?>(null) }
|
||||
var pendingConversationExportTarget by remember { mutableStateOf<LoggerConversationExportTarget?>(null) }
|
||||
val loggerHistoryTranslation = remember { context.translation.getCategory("logger_history") }
|
||||
|
||||
data class ConversationSearchTarget(
|
||||
val target: LoggerConversationExportTarget,
|
||||
val friendDisplayName: String?,
|
||||
val friendUsername: String?,
|
||||
val chatDisplayName: String?,
|
||||
val groupDisplayName: String?,
|
||||
val readableUsernames: List<String>,
|
||||
val readableIdentifiers: List<String>,
|
||||
val isDirectChat: Boolean,
|
||||
val isGroupChat: Boolean,
|
||||
val sortOrder: Int
|
||||
)
|
||||
|
||||
data class ConversationExportFormat(
|
||||
val extension: String,
|
||||
val mimeType: String,
|
||||
val label: String
|
||||
)
|
||||
|
||||
data class ParsedConversationMessage(
|
||||
val senderId: String,
|
||||
val senderUsername: String,
|
||||
val timestamp: Long,
|
||||
val contentType: ContentType,
|
||||
val messageText: String?,
|
||||
val attachments: List<DecodedAttachment>
|
||||
)
|
||||
|
||||
fun String.isUuidLike(): Boolean {
|
||||
val value = trim()
|
||||
if (value.length != 36) return false
|
||||
if (value[8] != '-' || value[13] != '-' || value[18] != '-' || value[23] != '-') return false
|
||||
return value.filterIndexed { index, _ ->
|
||||
index != 8 && index != 13 && index != 18 && index != 23
|
||||
}.all { it.isDigit() || it.lowercaseChar() in 'a'..'f' }
|
||||
}
|
||||
|
||||
fun String.isLikelyInternalId(): Boolean {
|
||||
val value = trim()
|
||||
if (value.isUuidLike()) return true
|
||||
if (value.length >= 10 && value.all(Char::isDigit)) return true
|
||||
if (value.length >= 16 && value.all { it.isDigit() || it.lowercaseChar() in 'a'..'f' || it == '-' }) {
|
||||
val digitCount = value.count(Char::isDigit)
|
||||
val alphaCount = value.count { it.lowercaseChar() in 'a'..'f' }
|
||||
if (digitCount >= 4 && alphaCount >= 4) return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
fun String.toReadableIdentityOrNull(): String? {
|
||||
val value = trim()
|
||||
if (value.isEmpty()) return null
|
||||
if (value.isLikelyInternalId()) return null
|
||||
if (!value.any { it.isLetter() }) return null
|
||||
return value
|
||||
}
|
||||
|
||||
fun String.toSearchIdentityOrNull(): String? {
|
||||
val value = trim()
|
||||
if (value.isEmpty()) return null
|
||||
if (value.equals("myai", ignoreCase = true)) return null
|
||||
return value
|
||||
}
|
||||
|
||||
val exportTargets by rememberAsyncMutableState(defaultValue = emptyList<LoggerConversationExportTarget>()) {
|
||||
context.messageLogger.getConversationExportTargets()
|
||||
}
|
||||
val exportSearchTargets by rememberAsyncMutableState(
|
||||
defaultValue = emptyList<ConversationSearchTarget>(),
|
||||
keys = arrayOf(exportTargets)
|
||||
) {
|
||||
val friendIdentityCache = mutableMapOf<String, Pair<String?, String?>?>()
|
||||
exportTargets.mapIndexedNotNull { index, target ->
|
||||
val friend = context.database.findFriend(target.conversationId)
|
||||
val group = context.database.getGroupInfo(target.conversationId)
|
||||
val chatDisplayName = target.groupTitle
|
||||
?.toReadableIdentityOrNull()
|
||||
?.takeIf { !it.equals(target.conversationId, ignoreCase = true) }
|
||||
val friendDisplayName = friend?.displayName?.toReadableIdentityOrNull()
|
||||
val friendUsername = friend?.mutableUsername?.toReadableIdentityOrNull()
|
||||
val searchableUsernames = target.usernames
|
||||
.mapNotNull { it.toSearchIdentityOrNull() }
|
||||
.distinct()
|
||||
val readableUsernames = searchableUsernames
|
||||
.mapNotNull { it.toReadableIdentityOrNull() }
|
||||
.distinct()
|
||||
val hasManyParticipants = target.userIds.distinct().size > 2 || searchableUsernames.size > 2
|
||||
val fallbackFriendIdentities = if (friend == null && !hasManyParticipants) {
|
||||
target.userIds.mapNotNull { userId ->
|
||||
friendIdentityCache.getOrPut(userId) {
|
||||
context.database.getFriendInfo(userId)?.let {
|
||||
it.displayName?.toReadableIdentityOrNull() to
|
||||
it.mutableUsername.toReadableIdentityOrNull()
|
||||
}
|
||||
}?.takeIf { it.first != null || it.second != null }
|
||||
}
|
||||
} else {
|
||||
emptyList()
|
||||
}
|
||||
val fallbackFriendDisplayName = fallbackFriendIdentities.firstNotNullOfOrNull { it.first }
|
||||
val fallbackFriendUsername = fallbackFriendIdentities.firstNotNullOfOrNull { it.second }
|
||||
val resolvedFriendDisplayName = friendDisplayName ?: fallbackFriendDisplayName
|
||||
val resolvedFriendUsername = friendUsername ?: fallbackFriendUsername
|
||||
val groupDisplayName = group?.name?.toReadableIdentityOrNull()
|
||||
?: chatDisplayName?.takeIf { hasManyParticipants }
|
||||
val isGroupChat = groupDisplayName != null || hasManyParticipants
|
||||
val isDirectChat = !isGroupChat
|
||||
val readableIdentifiers = buildList {
|
||||
add(target.conversationId)
|
||||
addAll(target.userIds)
|
||||
resolvedFriendDisplayName?.let { add(it) }
|
||||
resolvedFriendUsername?.let { add(it) }
|
||||
chatDisplayName?.let { add(it) }
|
||||
groupDisplayName?.let { add(it) }
|
||||
addAll(searchableUsernames)
|
||||
addAll(readableUsernames)
|
||||
}.distinct()
|
||||
ConversationSearchTarget(
|
||||
target = target,
|
||||
friendDisplayName = resolvedFriendDisplayName,
|
||||
friendUsername = resolvedFriendUsername,
|
||||
chatDisplayName = chatDisplayName,
|
||||
groupDisplayName = groupDisplayName,
|
||||
readableUsernames = readableUsernames,
|
||||
readableIdentifiers = readableIdentifiers,
|
||||
isDirectChat = isDirectChat,
|
||||
isGroupChat = isGroupChat,
|
||||
sortOrder = index
|
||||
)
|
||||
}.sortedWith(
|
||||
compareBy<ConversationSearchTarget> {
|
||||
when {
|
||||
it.isDirectChat -> 0
|
||||
it.isGroupChat -> 1
|
||||
else -> 2
|
||||
}
|
||||
}.thenBy { it.sortOrder }
|
||||
)
|
||||
}
|
||||
val filteredExportTargets = remember(exportSearchTargets, conversationSearchQuery) {
|
||||
val query = conversationSearchQuery.trim()
|
||||
if (query.isBlank()) {
|
||||
exportSearchTargets
|
||||
} else {
|
||||
exportSearchTargets.filter { searchTarget ->
|
||||
searchTarget.readableIdentifiers.any {
|
||||
it.contains(query, ignoreCase = true)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val exportFormats = remember {
|
||||
listOf(
|
||||
ConversationExportFormat("db", "application/octet-stream", ".db"),
|
||||
ConversationExportFormat("html", "text/html", "HTML"),
|
||||
ConversationExportFormat("txt", "text/plain", "TXT")
|
||||
)
|
||||
}
|
||||
|
||||
fun formatExportTarget(searchTarget: ConversationSearchTarget): String {
|
||||
searchTarget.friendDisplayName?.let { displayName ->
|
||||
val username = searchTarget.friendUsername
|
||||
val formattedName = if (username != null && !username.equals(displayName, ignoreCase = true)) {
|
||||
"$displayName • @$username"
|
||||
} else {
|
||||
displayName
|
||||
}
|
||||
return loggerHistoryTranslation.format("list_friend_format", "name" to formattedName)
|
||||
}
|
||||
|
||||
searchTarget.friendUsername?.let { username ->
|
||||
return loggerHistoryTranslation.format("list_friend_format", "name" to "@$username")
|
||||
}
|
||||
|
||||
searchTarget.chatDisplayName?.takeIf { searchTarget.isDirectChat }?.let {
|
||||
return loggerHistoryTranslation.format("list_friend_format", "name" to it)
|
||||
}
|
||||
|
||||
if (searchTarget.isDirectChat && searchTarget.readableUsernames.isNotEmpty()) {
|
||||
val friendName = if (searchTarget.readableUsernames.size == 1) {
|
||||
searchTarget.readableUsernames.first()
|
||||
} else {
|
||||
searchTarget.readableUsernames.joinToString(", ")
|
||||
}
|
||||
return loggerHistoryTranslation.format("list_friend_format", "name" to friendName)
|
||||
}
|
||||
|
||||
searchTarget.groupDisplayName?.let {
|
||||
return loggerHistoryTranslation.format("list_group_format", "name" to it)
|
||||
}
|
||||
|
||||
if (searchTarget.readableUsernames.isNotEmpty()) {
|
||||
return loggerHistoryTranslation.format(
|
||||
"list_group_format",
|
||||
"name" to searchTarget.readableUsernames.joinToString(", ")
|
||||
)
|
||||
}
|
||||
|
||||
return if (searchTarget.isGroupChat) {
|
||||
loggerHistoryTranslation.format("list_group_format", "name" to searchTarget.target.conversationId)
|
||||
} else {
|
||||
loggerHistoryTranslation.format("list_friend_format", "name" to searchTarget.target.conversationId)
|
||||
}
|
||||
}
|
||||
|
||||
fun showExportError(throwable: Throwable) {
|
||||
context.log.error("Failed to export message logger", throwable)
|
||||
context.shortToast(
|
||||
translation.format(
|
||||
"message_logger_export_failed_toast",
|
||||
"message" to (throwable.message ?: "Unknown error")
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
fun showImportError(throwable: Throwable) {
|
||||
context.log.error("Failed to import message logger", throwable)
|
||||
context.shortToast(
|
||||
translation.format(
|
||||
"import_failed_toast",
|
||||
"message" to (throwable.message ?: "Unknown error")
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
fun parseConversationMessage(message: LoggedMessage): ParsedConversationMessage {
|
||||
val messageObject = runCatching {
|
||||
JsonParser.parseString(String(message.messageData, Charsets.UTF_8)).asJsonObject
|
||||
}.getOrNull()
|
||||
val messageContent = messageObject?.getAsJsonObject("mMessageContent")
|
||||
val contentBytes = runCatching {
|
||||
messageContent?.getAsJsonArray("mContent")?.map { it.asByte }?.toByteArray()
|
||||
}.getOrNull()
|
||||
val contentType = messageContent?.getAsJsonPrimitive("mContentType")?.asString?.let {
|
||||
runCatching { ContentType.valueOf(it) }.getOrNull()
|
||||
} ?: contentBytes?.let { ContentType.fromMessageContainer(ProtoReader(it)) } ?: ContentType.UNKNOWN
|
||||
val messageText = contentBytes?.getMessageText(contentType)
|
||||
val attachments = runCatching {
|
||||
messageContent?.let { MessageDecoder.decode(it) } ?: emptyList()
|
||||
}.getOrDefault(emptyList())
|
||||
|
||||
return ParsedConversationMessage(
|
||||
senderId = message.userId,
|
||||
senderUsername = message.username,
|
||||
timestamp = message.sendTimestamp,
|
||||
contentType = contentType,
|
||||
messageText = messageText,
|
||||
attachments = attachments
|
||||
)
|
||||
}
|
||||
|
||||
fun htmlEscape(input: String): String {
|
||||
val escaped = StringBuilder(input.length)
|
||||
input.forEach { char ->
|
||||
when (char) {
|
||||
'&' -> escaped.append("&")
|
||||
'<' -> escaped.append("<")
|
||||
'>' -> escaped.append(">")
|
||||
'"' -> escaped.append(""")
|
||||
'\'' -> escaped.append("'")
|
||||
else -> escaped.append(char)
|
||||
}
|
||||
}
|
||||
return escaped.toString()
|
||||
}
|
||||
|
||||
fun writeConversationExportFile(
|
||||
target: LoggerConversationExportTarget,
|
||||
format: ConversationExportFormat,
|
||||
outputFile: File
|
||||
): Int {
|
||||
val conversationId = target.conversationId.trim()
|
||||
if (conversationId.isEmpty()) {
|
||||
throw IllegalArgumentException("Conversation ID cannot be empty")
|
||||
}
|
||||
|
||||
val searchTarget = exportSearchTargets.firstOrNull { it.target.conversationId == conversationId }
|
||||
val conversationTitle = searchTarget?.let { formatExportTarget(it) }
|
||||
?: (translation["message_logger_export_individual_chat"] ?: "Exported Chat")
|
||||
val dateFormatter = DateFormat.getDateTimeInstance()
|
||||
val senderCache = mutableMapOf<String, String>()
|
||||
|
||||
fun formatSenderLabel(senderId: String, senderUsername: String): String {
|
||||
val friendInfo = context.database.getFriendInfo(senderId)
|
||||
val senderDisplayName = friendInfo?.displayName?.toReadableIdentityOrNull()
|
||||
val senderReadableUsername = friendInfo?.mutableUsername?.toReadableIdentityOrNull()
|
||||
?: senderUsername.toReadableIdentityOrNull()
|
||||
return when {
|
||||
senderDisplayName != null &&
|
||||
senderReadableUsername != null &&
|
||||
!senderDisplayName.equals(senderReadableUsername, ignoreCase = true) ->
|
||||
"$senderDisplayName (@$senderReadableUsername)"
|
||||
senderDisplayName != null -> senderDisplayName
|
||||
senderReadableUsername != null -> "@$senderReadableUsername"
|
||||
else -> translation["sender_unknown"] ?: "Unknown sender"
|
||||
}
|
||||
}
|
||||
|
||||
outputFile.parentFile?.mkdirs()
|
||||
if (outputFile.exists() && !outputFile.delete()) {
|
||||
throw IllegalStateException("Failed to prepare export file")
|
||||
}
|
||||
|
||||
return outputFile.bufferedWriter(Charsets.UTF_8).use { writer ->
|
||||
val isHtmlFormat = format.extension == "html"
|
||||
if (isHtmlFormat) {
|
||||
writer.appendLine("<!DOCTYPE html>")
|
||||
writer.appendLine("<html><head><meta charset=\"UTF-8\" />")
|
||||
writer.appendLine("<meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\" />")
|
||||
writer.appendLine("<title>${htmlEscape(conversationTitle)}</title>")
|
||||
writer.appendLine(
|
||||
"<style>body{font-family:Arial,sans-serif;background:#121212;color:#f3f3f3;padding:16px;}h2{margin-top:0;}" +
|
||||
".meta{color:#a5a5a5;font-size:12px;margin-bottom:4px;}" +
|
||||
".message{border:1px solid #2d2d2d;border-radius:10px;padding:10px;margin:10px 0;background:#1b1b1b;}" +
|
||||
".content{white-space:pre-wrap;word-break:break-word;}" +
|
||||
".attachments{margin:8px 0 0 18px;padding:0;}a{color:#8db7ff;}</style>"
|
||||
)
|
||||
writer.appendLine("</head><body>")
|
||||
writer.appendLine("<h2>${htmlEscape(conversationTitle)}</h2>")
|
||||
writer.appendLine("<p>${htmlEscape(translation.format("message_logger_conversation_id", "id" to conversationId))}</p>")
|
||||
} else {
|
||||
writer.appendLine(conversationTitle)
|
||||
writer.appendLine("")
|
||||
}
|
||||
|
||||
val exportedMessageCount = context.messageLogger.forEachConversationMessage(
|
||||
conversationId = conversationId,
|
||||
userIds = target.userIds,
|
||||
orderAscending = true
|
||||
) { loggedMessage ->
|
||||
val parsed = parseConversationMessage(loggedMessage)
|
||||
val senderInfo = senderCache.getOrPut(parsed.senderId) {
|
||||
formatSenderLabel(parsed.senderId, parsed.senderUsername)
|
||||
}
|
||||
val senderLabel = senderInfo
|
||||
val content = parsed.messageText?.takeIf { it.isNotBlank() } ?: if (parsed.contentType == ContentType.CHAT) {
|
||||
loggerHistoryTranslation["empty_message"]
|
||||
} else {
|
||||
parsed.contentType.name.lowercase()
|
||||
}
|
||||
|
||||
if (isHtmlFormat) {
|
||||
writer.appendLine("<div class=\"message\">")
|
||||
writer.appendLine(
|
||||
"<div class=\"meta\">${
|
||||
htmlEscape(
|
||||
"${dateFormatter.format(Date(parsed.timestamp))} • $senderLabel • ${
|
||||
parsed.contentType.name.lowercase()
|
||||
}"
|
||||
)
|
||||
}</div>"
|
||||
)
|
||||
writer.appendLine("<div class=\"content\">${htmlEscape(content).replace("\n", "<br/>")}</div>")
|
||||
if (parsed.attachments.isNotEmpty()) {
|
||||
writer.appendLine("<ul class=\"attachments\">")
|
||||
parsed.attachments.forEachIndexed { index, attachment ->
|
||||
val attachmentLabel = "${loggerHistoryTranslation.format("chat_attachment", "index" to (index + 1).toString())} [${attachment.type.name.lowercase()}]"
|
||||
val directUrl = attachment.directUrl?.takeIf { it.isNotBlank() }
|
||||
if (directUrl != null) {
|
||||
writer.appendLine(
|
||||
"<li><a href=\"${htmlEscape(directUrl)}\" target=\"_blank\" rel=\"noopener noreferrer\">${
|
||||
htmlEscape(attachmentLabel)
|
||||
}</a></li>"
|
||||
)
|
||||
} else {
|
||||
val placeholder = attachment.boltKey?.takeIf { it.isNotBlank() }
|
||||
?: attachment.mediaUniqueId?.takeIf { it.isNotBlank() }
|
||||
?: (translation["message_logger_missing_attachment_placeholder"] ?: "Attachment unavailable")
|
||||
writer.appendLine("<li>${htmlEscape("$attachmentLabel: $placeholder")}</li>")
|
||||
}
|
||||
}
|
||||
writer.appendLine("</ul>")
|
||||
}
|
||||
writer.appendLine("</div>")
|
||||
} else {
|
||||
writer.appendLine("[${dateFormatter.format(Date(parsed.timestamp))}] $senderLabel: $content")
|
||||
parsed.attachments.forEachIndexed { index, attachment ->
|
||||
val attachmentLabel = "${loggerHistoryTranslation.format("chat_attachment", "index" to (index + 1).toString())} [${attachment.type.name.lowercase()}]"
|
||||
val attachmentValue = attachment.directUrl?.takeIf { it.isNotBlank() }
|
||||
?: (translation["message_logger_missing_attachment_placeholder"] ?: "Attachment unavailable")
|
||||
writer.appendLine(" - $attachmentLabel: $attachmentValue")
|
||||
}
|
||||
writer.appendLine("")
|
||||
}
|
||||
}
|
||||
|
||||
if (exportedMessageCount == 0) {
|
||||
if (isHtmlFormat) {
|
||||
writer.appendLine("<p>${htmlEscape(translation["message_logger_no_messages_export_text"] ?: "No messages found in this chat.")}</p>")
|
||||
} else {
|
||||
writer.appendLine(translation["message_logger_no_messages_export_text"] ?: "No messages found in this chat.")
|
||||
}
|
||||
}
|
||||
|
||||
if (isHtmlFormat) {
|
||||
writer.appendLine("</body></html>")
|
||||
}
|
||||
|
||||
exportedMessageCount
|
||||
}
|
||||
}
|
||||
|
||||
fun exportFullDatabase() {
|
||||
runCatching {
|
||||
activityLauncherHelper.saveFile("message_logger.db", "application/octet-stream") { uri ->
|
||||
context.androidContext.contentResolver.openOutputStream(uri.toUri())?.use { out ->
|
||||
context.messageLogger.databaseFile.inputStream().use { input -> input.copyTo(out) }
|
||||
} ?: throw IllegalStateException("Failed to open output stream")
|
||||
}
|
||||
}.onFailure { showExportError(it) }
|
||||
}
|
||||
|
||||
fun exportConversation(target: LoggerConversationExportTarget, format: ConversationExportFormat) {
|
||||
val conversationId = target.conversationId.trim()
|
||||
if (conversationId.isEmpty()) {
|
||||
context.shortToast(translation["message_logger_missing_conversation_toast"])
|
||||
return
|
||||
}
|
||||
|
||||
val fileNameSuffix = conversationId
|
||||
.filter { it.isLetterOrDigit() || it == '-' || it == '_' }
|
||||
.take(24)
|
||||
.ifBlank { "chat" }
|
||||
|
||||
runCatching {
|
||||
activityLauncherHelper.saveFile("message_logger_${fileNameSuffix}.${format.extension}", format.mimeType) { uri ->
|
||||
scope.launch {
|
||||
runCatching {
|
||||
val exportedMessageCount = withContext(Dispatchers.IO) {
|
||||
val tempFile = File(
|
||||
context.androidContext.cacheDir,
|
||||
"message_logger_export_${System.currentTimeMillis()}.${format.extension}"
|
||||
)
|
||||
try {
|
||||
val messageCount = if (format.extension == "db") {
|
||||
context.messageLogger.exportConversationDatabase(
|
||||
outputFile = tempFile,
|
||||
conversationId = conversationId,
|
||||
userIds = target.userIds
|
||||
).messageCount
|
||||
} else {
|
||||
writeConversationExportFile(target, format, tempFile)
|
||||
}
|
||||
context.androidContext.contentResolver.openOutputStream(uri.toUri())?.use { output ->
|
||||
tempFile.inputStream().use { input -> input.copyTo(output) }
|
||||
} ?: throw IllegalStateException("Failed to open output stream")
|
||||
messageCount
|
||||
} finally {
|
||||
tempFile.delete()
|
||||
}
|
||||
}
|
||||
|
||||
if (exportedMessageCount == 0) {
|
||||
context.shortToast(translation["message_logger_empty_chat_toast"])
|
||||
} else {
|
||||
context.shortToast(translation["success_toast"])
|
||||
}
|
||||
}.onFailure { showExportError(it) }
|
||||
}
|
||||
}
|
||||
}.onFailure { showExportError(it) }
|
||||
}
|
||||
|
||||
fun dismissConversationExportDialog() {
|
||||
showConversationExportDialog = false
|
||||
selectedConversationForExport = null
|
||||
conversationSearchQuery = ""
|
||||
}
|
||||
|
||||
fun dismissConversationFormatDialog() {
|
||||
showConversationFormatDialog = false
|
||||
pendingConversationExportTarget = null
|
||||
}
|
||||
|
||||
Column(modifier = Modifier.fillMaxWidth().padding(5.dp), verticalArrangement = Arrangement.spacedBy(10.dp), horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
val summary = translation.format("message_logger_summary", "messageCount" to storedMessagesCount.toString(), "storyCount" to storedStoriesCount.toString()).replace("\n", " | ")
|
||||
Text(summary, maxLines = 2, color = Color.White, textAlign = TextAlign.Center, modifier = Modifier.fillMaxWidth())
|
||||
FlowRow(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(10.dp, Alignment.CenterHorizontally), verticalArrangement = Arrangement.spacedBy(10.dp) ) {
|
||||
Button(onClick = { runCatching { activityLauncherHelper.saveFile("message_logger.db", "application/octet-stream") { uri -> context.androidContext.contentResolver.openOutputStream(uri.toUri())?.use { out -> context.messageLogger.databaseFile.inputStream().use { it.copyTo(out) } } } }.onFailure { context.log.error("Failed to export", it) } }, colors = sharedButtonColors, border = BorderStroke(1.dp, Color.White.copy(alpha = 0.12f))) { Text(text = translation["export_button"]) }
|
||||
Button(onClick = { showExportOptionsDialog = true }, colors = sharedButtonColors, border = BorderStroke(1.dp, Color.White.copy(alpha = 0.12f))) { Text(text = translation["export_button"]) }
|
||||
Button(onClick = { runCatching { activityLauncherHelper.openFile("application/octet-stream") { uri -> val tempFile = File(context.androidContext.cacheDir, "view_logger.db"); context.androidContext.contentResolver.openInputStream(uri.toUri())?.use { it.copyTo(tempFile.outputStream()) }; routes.viewLoggerHistory.navigate { put("uri", URLEncoder.encode(tempFile.toUri().toString(), "UTF-8")) } } } }, colors = sharedButtonColors, border = BorderStroke(1.dp, Color.White.copy(alpha = 0.12f))) { Text(text = translation["view_button"]) }
|
||||
Button(onClick = { runCatching { context.messageLogger.purgeAll(); storedMessagesCount = 0; storedStoriesCount = 0 }.onSuccess { context.shortToast(translation["success_toast"]) } }, colors = sharedButtonColors, border = BorderStroke(1.dp, Color.White.copy(alpha = 0.12f))) { Text(text = translation["clear_button"]) }
|
||||
Button(onClick = { showImportDialog = true }, colors = sharedButtonColors, border = BorderStroke(1.dp, Color.White.copy(alpha = 0.12f))) { Text(text = translation["import_button"]) }
|
||||
@@ -269,7 +758,222 @@ fun HomeSettings.AphelionSettingsScreen(nav: NavBackStackEntry) {
|
||||
}
|
||||
OutlinedButton(modifier = Modifier.fillMaxWidth().padding(5.dp), onClick = { routes.loggerHistory.navigate() }, colors = sharedOutlinedColors, border = BorderStroke(1.dp, Color.White.copy(alpha = 0.2f))) { Text(translation["view_logger_history_button"]) }
|
||||
if (showImportDialog) {
|
||||
AestheticDialog(onDismissRequest = { showImportDialog = false }, title = translation["message_logger_import_title"], text = translation["message_logger_import_text"], icon = Icons.Filled.Info, confirmButtonText = context.translation["button.import"], dismissButtonText = context.translation["button.cancel"], onConfirm = { showImportDialog = false; runCatching { activityLauncherHelper.openFile("application/octet-stream") { uri -> context.androidContext.contentResolver.openInputStream(uri.toUri())?.use { context.messageLogger.databaseFile.outputStream().use { out -> it.copyTo(out) } }; storedMessagesCount = context.messageLogger.getStoredMessageCount(); storedStoriesCount = context.messageLogger.getStoredStoriesCount(); context.shortToast(translation["success_toast"]) } } }, onDismiss = { showImportDialog = false }, showCloseButton = false)
|
||||
AestheticDialog(
|
||||
onDismissRequest = { showImportDialog = false },
|
||||
title = translation["message_logger_import_title"],
|
||||
text = translation["message_logger_import_text"],
|
||||
icon = Icons.Filled.Info,
|
||||
confirmButtonText = context.translation["button.import"],
|
||||
dismissButtonText = context.translation["button.cancel"],
|
||||
onConfirm = {
|
||||
showImportDialog = false
|
||||
runCatching {
|
||||
activityLauncherHelper.openFile("application/octet-stream") { uri ->
|
||||
scope.launch {
|
||||
runCatching {
|
||||
val importResult = withContext(Dispatchers.IO) {
|
||||
context.androidContext.contentResolver.openInputStream(uri.toUri())?.use { input ->
|
||||
context.messageLogger.importDatabase(input)
|
||||
} ?: throw IllegalStateException("Failed to open selected backup file")
|
||||
}
|
||||
storedMessagesCount = importResult.messageCount
|
||||
storedStoriesCount = importResult.storyCount
|
||||
context.shortToast(translation["success_toast"])
|
||||
}.onFailure { showImportError(it) }
|
||||
}
|
||||
}
|
||||
}.onFailure { showImportError(it) }
|
||||
},
|
||||
onDismiss = { showImportDialog = false },
|
||||
showCloseButton = false
|
||||
)
|
||||
}
|
||||
if (showExportOptionsDialog) {
|
||||
AestheticDialog(
|
||||
onDismissRequest = { showExportOptionsDialog = false },
|
||||
title = translation["message_logger_export_title"] ?: "Export Message Logger",
|
||||
text = translation["message_logger_export_text"] ?: "Choose what to export.",
|
||||
icon = Icons.Filled.SaveAlt,
|
||||
confirmButtonText = context.translation["button.cancel"],
|
||||
onConfirm = { showExportOptionsDialog = false },
|
||||
showCloseButton = false,
|
||||
customContent = {
|
||||
Button(
|
||||
onClick = {
|
||||
showExportOptionsDialog = false
|
||||
pendingConversationExportTarget = null
|
||||
showConversationExportDialog = true
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = sharedButtonColors,
|
||||
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.12f))
|
||||
) {
|
||||
Text(translation["message_logger_export_individual_chat"] ?: "Export Individual Chat")
|
||||
}
|
||||
Button(
|
||||
onClick = {
|
||||
showExportOptionsDialog = false
|
||||
exportFullDatabase()
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = sharedButtonColors,
|
||||
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.12f))
|
||||
) {
|
||||
Text(translation["message_logger_export_full_database"] ?: "Export Full Database")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
if (showConversationExportDialog) {
|
||||
AestheticDialog(
|
||||
onDismissRequest = { dismissConversationExportDialog() },
|
||||
title = translation["message_logger_select_chat_title"] ?: "Export Individual Chat",
|
||||
text = translation["message_logger_select_chat_text"] ?: "Search by username, display name, or chat name.",
|
||||
icon = Icons.Filled.Search,
|
||||
confirmButtonText = translation["message_logger_continue_button"] ?: "Continue",
|
||||
dismissButtonText = context.translation["button.cancel"],
|
||||
onConfirm = {
|
||||
val selectedTarget = selectedConversationForExport ?: return@AestheticDialog
|
||||
pendingConversationExportTarget = selectedTarget
|
||||
dismissConversationExportDialog()
|
||||
showConversationFormatDialog = true
|
||||
},
|
||||
onDismiss = { dismissConversationExportDialog() },
|
||||
showCloseButton = false,
|
||||
confirmEnabled = selectedConversationForExport != null,
|
||||
customContent = {
|
||||
OutlinedTextField(
|
||||
value = conversationSearchQuery,
|
||||
onValueChange = { conversationSearchQuery = it },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
placeholder = {
|
||||
Text(context.translation["manager.dialogs.add_friend.search_hint"] ?: "Search")
|
||||
},
|
||||
leadingIcon = {
|
||||
Icon(Icons.Filled.Search, contentDescription = null)
|
||||
},
|
||||
trailingIcon = if (conversationSearchQuery.isNotBlank()) {
|
||||
{
|
||||
IconButton(onClick = { conversationSearchQuery = "" }) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Close,
|
||||
contentDescription = null
|
||||
)
|
||||
}
|
||||
}
|
||||
} else null,
|
||||
colors = TextFieldDefaults.colors(
|
||||
focusedIndicatorColor = Color.Transparent,
|
||||
unfocusedIndicatorColor = Color.Transparent,
|
||||
focusedContainerColor = Color.White.copy(alpha = 0.08f),
|
||||
unfocusedContainerColor = Color.White.copy(alpha = 0.06f),
|
||||
focusedTextColor = Color.White,
|
||||
unfocusedTextColor = Color.White
|
||||
)
|
||||
)
|
||||
|
||||
if (filteredExportTargets.isEmpty()) {
|
||||
Text(
|
||||
text = translation["message_logger_no_chats_found"] ?: "No chats found",
|
||||
color = PurrfectPalette.textSecondary,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
} else {
|
||||
LazyColumn(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(max = 280.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
items(filteredExportTargets.size) { index ->
|
||||
val searchTarget = filteredExportTargets[index]
|
||||
val target = searchTarget.target
|
||||
val isSelected = selectedConversationForExport?.conversationId == searchTarget.target.conversationId
|
||||
OutlinedButton(
|
||||
onClick = { selectedConversationForExport = searchTarget.target },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = ButtonDefaults.outlinedButtonColors(contentColor = Color.White),
|
||||
border = BorderStroke(
|
||||
1.dp,
|
||||
if (isSelected) {
|
||||
PurrfectPalette.glowPrimary.copy(alpha = 0.55f)
|
||||
} else {
|
||||
Color.White.copy(alpha = 0.18f)
|
||||
}
|
||||
)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalArrangement = Arrangement.spacedBy(2.dp)
|
||||
) {
|
||||
Text(
|
||||
text = formatExportTarget(searchTarget),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
val secondaryLabel = when {
|
||||
searchTarget.friendDisplayName != null && searchTarget.friendUsername != null -> "@${searchTarget.friendUsername}"
|
||||
searchTarget.friendDisplayName != null -> searchTarget.friendDisplayName
|
||||
searchTarget.chatDisplayName != null -> searchTarget.chatDisplayName
|
||||
searchTarget.groupDisplayName != null -> searchTarget.groupDisplayName
|
||||
searchTarget.readableUsernames.isNotEmpty() -> searchTarget.readableUsernames.joinToString(", ")
|
||||
else -> null
|
||||
}
|
||||
if (secondaryLabel != null) {
|
||||
Text(
|
||||
text = secondaryLabel,
|
||||
color = PurrfectPalette.textSecondary,
|
||||
fontSize = 12.sp,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
Text(
|
||||
text = translation.format("message_logger_message_count", "count" to target.messageCount.toString()),
|
||||
color = PurrfectPalette.textSecondary,
|
||||
fontSize = 12.sp
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
if (showConversationFormatDialog && pendingConversationExportTarget != null) {
|
||||
AestheticDialog(
|
||||
onDismissRequest = { dismissConversationFormatDialog() },
|
||||
title = translation["message_logger_select_export_format_title"] ?: "Select Export Format",
|
||||
text = translation["message_logger_select_export_format_text"] ?: "Choose how to export the selected chat.",
|
||||
icon = Icons.Filled.Description,
|
||||
confirmButtonText = context.translation["button.cancel"],
|
||||
onConfirm = { dismissConversationFormatDialog() },
|
||||
showCloseButton = false,
|
||||
customContent = {
|
||||
exportFormats.forEach { format ->
|
||||
val formatLabel = when (format.extension) {
|
||||
"db" -> translation["message_logger_export_format_db"] ?: ".db"
|
||||
"html" -> translation["message_logger_export_format_html"] ?: "HTML"
|
||||
else -> translation["message_logger_export_format_txt"] ?: "TXT"
|
||||
}
|
||||
Button(
|
||||
onClick = {
|
||||
val exportTarget = pendingConversationExportTarget ?: return@Button
|
||||
dismissConversationFormatDialog()
|
||||
exportConversation(exportTarget, format)
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = sharedButtonColors,
|
||||
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.12f))
|
||||
) {
|
||||
Text(formatLabel)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,15 +39,20 @@ import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.navigation.NavBackStackEntry
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import me.eternal.purrfectsnap.R
|
||||
import me.eternal.purrfectsnap.common.data.SocialScope
|
||||
import me.eternal.purrfectsnap.ui.manager.pages.social.SocialRootSection
|
||||
import me.eternal.purrfectsnap.ui.manager.pages.social.sortSocialFriends
|
||||
import me.eternal.purrfectsnap.ui.manager.theme.PurrfectPalette
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
fun SocialRootSection.AphelionSocialScreen(nav: NavBackStackEntry) {
|
||||
// Controller handles data loading and synchronization
|
||||
SocialDataController()
|
||||
|
||||
val titles = remember {
|
||||
listOf(translation["friends_tab"], translation["groups_tab"])
|
||||
}
|
||||
@@ -56,19 +61,9 @@ fun SocialRootSection.AphelionSocialScreen(nav: NavBackStackEntry) {
|
||||
var searchQuery by rememberSaveable { mutableStateOf("") }
|
||||
var searchActive by rememberSaveable { mutableStateOf(false) }
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
context.database.receiveMessagingDataCallback = { friends, groups ->
|
||||
friendList = friends
|
||||
groupList = groups
|
||||
}
|
||||
updateScopeLists()
|
||||
}
|
||||
DisposableEffect(Unit) {
|
||||
onDispose {
|
||||
context.database.receiveMessagingDataCallback = { _, _ -> }
|
||||
}
|
||||
}
|
||||
val normalizedQuery = remember(searchQuery) { searchQuery.trim() }
|
||||
|
||||
// Filter logic based on the parent's synchronized data lists
|
||||
val filteredFriends = remember(friendList, normalizedQuery) {
|
||||
if (normalizedQuery.isBlank()) {
|
||||
friendList
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -269,8 +269,9 @@ class ManageFriendTrackerReposSection: Routes.Route() {
|
||||
}
|
||||
|
||||
override val content: @Composable (androidx.navigation.NavBackStackEntry) -> Unit = {
|
||||
val repositories by remember(refreshTrigger.value) {
|
||||
mutableStateOf<List<String>>(runBlocking { context.database.getRepositories("friend_tracker") })
|
||||
var repositories by remember { mutableStateOf<List<String>>(emptyList()) }
|
||||
LaunchedEffect(refreshTrigger.value) {
|
||||
repositories = context.database.getRepositories("friend_tracker")
|
||||
}
|
||||
val density = LocalDensity.current
|
||||
val statusBarTopPadding = WindowInsets.statusBars.asPaddingValues().calculateTopPadding()
|
||||
|
||||
@@ -56,6 +56,7 @@ import androidx.compose.material.icons.filled.Download
|
||||
import androidx.compose.material.icons.filled.Flag
|
||||
import androidx.compose.material.icons.filled.Folder
|
||||
import androidx.compose.material.icons.filled.Language
|
||||
import androidx.compose.material.icons.filled.SmartToy
|
||||
import androidx.compose.material.icons.filled.VerifiedUser
|
||||
import androidx.compose.material.icons.filled.Warning
|
||||
import androidx.compose.material3.Icon
|
||||
@@ -93,6 +94,8 @@ import androidx.navigation.compose.rememberNavController
|
||||
import me.eternal.purrfectsnap.RemoteSideContext
|
||||
import me.eternal.purrfectsnap.SharedContextHolder
|
||||
import me.eternal.purrfectsnap.common.ui.AppMaterialTheme
|
||||
import me.eternal.purrfectsnap.ui.manager.ManagerAssistantDialog
|
||||
import me.eternal.purrfectsnap.ui.manager.Routes
|
||||
import me.eternal.purrfectsnap.ui.manager.components.AestheticDialog
|
||||
import me.eternal.purrfectsnap.ui.manager.theme.PurrfectPalette
|
||||
import me.eternal.purrfectsnap.ui.setup.screens.SetupScreen
|
||||
@@ -104,6 +107,8 @@ import me.eternal.purrfectsnap.ui.setup.screens.impl.PickLanguageScreen
|
||||
import me.eternal.purrfectsnap.ui.setup.screens.impl.PatchSnapchatScreen
|
||||
import me.eternal.purrfectsnap.ui.setup.screens.impl.RootInstallSnapchatScreen
|
||||
import me.eternal.purrfectsnap.ui.setup.screens.impl.SaveFolderScreen
|
||||
import me.eternal.purrfectsnap.ui.setup.screens.impl.IntroShowcaseScreen
|
||||
import me.eternal.purrfectsnap.ui.util.ActivityLauncherHelper
|
||||
import me.eternal.purrfectsnap.ui.util.scaleOnPress
|
||||
import kotlinx.coroutines.delay
|
||||
|
||||
@@ -127,6 +132,9 @@ class SetupActivity : ComponentActivity() {
|
||||
}
|
||||
val requirements = intent.getIntExtra("requirements", Requirements.FIRST_RUN)
|
||||
val setupPrefs = setupContext.sharedPreferences
|
||||
val setupRoutes = Routes(setupContext).apply {
|
||||
activityLauncher = ActivityLauncherHelper(this@SetupActivity)
|
||||
}
|
||||
fun hasRequirement(requirement: Int) = requirements and requirement == requirement
|
||||
val wasInProgress = setupPrefs.getBoolean("setup_in_progress", false)
|
||||
val isFirstRunFlow = hasRequirement(Requirements.FIRST_RUN) || wasInProgress
|
||||
@@ -159,6 +167,7 @@ class SetupActivity : ComponentActivity() {
|
||||
|
||||
val requiredScreens = mutableListOf<SetupScreen>().apply {
|
||||
if (isFirstRunFlow || hasRequirement(Requirements.LANGUAGE)) {
|
||||
add(IntroShowcaseScreen().apply { route = "introShowcase" })
|
||||
add(PickLanguageScreen().apply { route = "language" })
|
||||
if (isFirstRunFlow) {
|
||||
add(InstallModeScreen(
|
||||
@@ -315,19 +324,7 @@ class SetupActivity : ComponentActivity() {
|
||||
AppMaterialTheme {
|
||||
val view = LocalView.current
|
||||
val navBarPadding = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding()
|
||||
var showImportantDialog by rememberSaveable {
|
||||
mutableStateOf(!setupPrefs.getBoolean("setup_important_notice_shown", false))
|
||||
}
|
||||
var importantTimeout by remember { mutableIntStateOf(5) }
|
||||
LaunchedEffect(showImportantDialog) {
|
||||
if (showImportantDialog) {
|
||||
importantTimeout = 5
|
||||
while (importantTimeout > 0) {
|
||||
delay(1000)
|
||||
importantTimeout--
|
||||
}
|
||||
}
|
||||
}
|
||||
var setupAiPrompt by rememberSaveable { mutableStateOf<String?>(null) }
|
||||
SideEffect {
|
||||
val window = (view.context as Activity).window
|
||||
WindowCompat.setDecorFitsSystemWindows(window, false)
|
||||
@@ -344,46 +341,8 @@ class SetupActivity : ComponentActivity() {
|
||||
.fillMaxSize()
|
||||
.background(Color.Transparent)
|
||||
) {
|
||||
if (showImportantDialog) {
|
||||
val confirmLabel = if (importantTimeout > 0) {
|
||||
translation.format(
|
||||
"setup.activity.important_confirm_timeout",
|
||||
"seconds" to importantTimeout.toString()
|
||||
)
|
||||
} else {
|
||||
translation["setup.activity.important_confirm"]
|
||||
}
|
||||
AestheticDialog(
|
||||
onDismissRequest = {
|
||||
if (importantTimeout == 0) {
|
||||
showImportantDialog = false
|
||||
setupPrefs.edit().putBoolean("setup_important_notice_shown", true).apply()
|
||||
}
|
||||
},
|
||||
title = translation["setup.activity.important_title"],
|
||||
text = "",
|
||||
icon = Icons.Filled.Warning,
|
||||
confirmButtonText = confirmLabel,
|
||||
onConfirm = {
|
||||
if (importantTimeout == 0) {
|
||||
showImportantDialog = false
|
||||
setupPrefs.edit().putBoolean("setup_important_notice_shown", true).apply()
|
||||
}
|
||||
},
|
||||
confirmEnabled = importantTimeout == 0,
|
||||
showCloseButton = false,
|
||||
customContent = {
|
||||
Text(
|
||||
text = translation["setup.activity.important_message"],
|
||||
color = PurrfectPalette.textSecondary,
|
||||
lineHeight = 18.sp
|
||||
)
|
||||
},
|
||||
opaque = true
|
||||
)
|
||||
}
|
||||
SetupAuroraBackground()
|
||||
SetupTopBar()
|
||||
SetupTopBar(onAskAi = { setupAiPrompt = "hi" })
|
||||
val bottomPadding = 118.dp + navBarPadding
|
||||
Column(
|
||||
modifier = Modifier
|
||||
@@ -468,6 +427,14 @@ class SetupActivity : ComponentActivity() {
|
||||
.navigationBarsPadding()
|
||||
.padding(bottom = 32.dp)
|
||||
)
|
||||
setupAiPrompt?.let { prompt ->
|
||||
ManagerAssistantDialog(
|
||||
context = setupContext,
|
||||
routes = setupRoutes,
|
||||
initialUserMessage = prompt,
|
||||
onDismiss = { setupAiPrompt = null }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -483,6 +450,12 @@ private fun SetupScreen.meta(context: RemoteSideContext): SetupStepMeta {
|
||||
subtitle = translation["setup.activity.language_subtitle"],
|
||||
icon = Icons.Filled.Language
|
||||
)
|
||||
is IntroShowcaseScreen -> SetupStepMeta(
|
||||
route = route,
|
||||
title = "Welcome",
|
||||
subtitle = "Preview what PurrfectSnap can do",
|
||||
icon = Icons.Filled.AutoAwesome
|
||||
)
|
||||
|
||||
is InstallModeScreen -> SetupStepMeta(
|
||||
route = route,
|
||||
@@ -587,7 +560,7 @@ private fun SetupAuroraBackground() {
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SetupTopBar() {
|
||||
private fun SetupTopBar(onAskAi: () -> Unit) {
|
||||
val topPadding = WindowInsets.statusBars.asPaddingValues().calculateTopPadding()
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
@@ -613,14 +586,37 @@ private fun SetupTopBar() {
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 18.dp, vertical = 16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.Center
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
Text(
|
||||
text = "PurrfectSnap",
|
||||
color = PurrfectPalette.textPrimary,
|
||||
fontWeight = FontWeight.ExtraBold,
|
||||
fontSize = 18.sp
|
||||
fontSize = 18.sp,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
Surface(
|
||||
shape = RoundedCornerShape(40),
|
||||
color = Color.White.copy(alpha = 0.08f),
|
||||
border = androidx.compose.foundation.BorderStroke(1.dp, Color.White.copy(alpha = 0.14f))
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.clip(RoundedCornerShape(40))
|
||||
.clickable(onClick = onAskAi)
|
||||
.padding(horizontal = 12.dp, vertical = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
Icon(Icons.Filled.SmartToy, contentDescription = null, tint = Color.White)
|
||||
Text(
|
||||
text = "Ask AI",
|
||||
color = Color.White,
|
||||
fontWeight = FontWeight.Medium,
|
||||
fontSize = 13.sp
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
package me.eternal.purrfectsnap.ui.setup.screens.impl
|
||||
|
||||
import androidx.compose.animation.AnimatedContent
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import kotlinx.coroutines.delay
|
||||
import me.eternal.purrfectsnap.R
|
||||
import me.eternal.purrfectsnap.ui.manager.theme.PurrfectPalette
|
||||
import me.eternal.purrfectsnap.ui.setup.screens.SetupScreen
|
||||
|
||||
class IntroShowcaseScreen : SetupScreen() {
|
||||
private val slides = listOf(
|
||||
R.drawable.setup_slide_plus to "Unlock Snapchat Plus for free!",
|
||||
R.drawable.setup_slide_upload_tag to "Bypass the Media Upload tag!",
|
||||
R.drawable.setup_slide_downloads to "Download Snaps, & Spotlights!"
|
||||
)
|
||||
|
||||
@Composable
|
||||
override fun Content() {
|
||||
LaunchedEffect(Unit) { allowNext(true) }
|
||||
var currentIndex by remember { mutableIntStateOf(0) }
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
while (true) {
|
||||
delay(5000)
|
||||
currentIndex = (currentIndex + 1) % slides.size
|
||||
}
|
||||
}
|
||||
|
||||
SetupCard {
|
||||
StepTitle(
|
||||
title = "Welcome to PurrfectSnap",
|
||||
subtitle = "A quick look before setup begins",
|
||||
modifier = Modifier.align(Alignment.CenterHorizontally),
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
AnimatedContent(targetState = currentIndex, label = "setupShowcase") { index ->
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(14.dp)
|
||||
) {
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(260.dp),
|
||||
shape = RoundedCornerShape(24.dp),
|
||||
color = PurrfectPalette.cardOverlayColor,
|
||||
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.12f))
|
||||
) {
|
||||
Image(
|
||||
painter = painterResource(slides[index].first),
|
||||
contentDescription = slides[index].second,
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = Modifier.fillMaxWidth().clip(RoundedCornerShape(24.dp))
|
||||
)
|
||||
}
|
||||
Text(
|
||||
text = slides[index].second,
|
||||
color = Color.White,
|
||||
fontSize = 18.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
}
|
||||
}
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
slides.forEachIndexed { index, _ ->
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(if (index == currentIndex) 10.dp else 8.dp)
|
||||
.clip(CircleShape)
|
||||
.background(if (index == currentIndex) PurrfectPalette.glowPrimary else Color.White.copy(alpha = 0.3f))
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -74,6 +74,8 @@ import kotlinx.coroutines.withContext
|
||||
import me.eternal.purrfectsnap.common.bridge.wrapper.LocaleWrapper
|
||||
import me.eternal.purrfectsnap.setup.patch.AutoPatchServer
|
||||
import me.eternal.purrfectsnap.setup.patch.LSPatch
|
||||
import me.eternal.purrfectsnap.ui.manager.ManagerAssistantDialog
|
||||
import me.eternal.purrfectsnap.ui.manager.Routes
|
||||
import me.eternal.purrfectsnap.ui.manager.components.AestheticDialog
|
||||
import me.eternal.purrfectsnap.ui.manager.theme.PurrfectPalette
|
||||
import me.eternal.purrfectsnap.ui.setup.screens.SetupScreen
|
||||
@@ -111,6 +113,10 @@ class PatchSnapchatScreen : SetupScreen() {
|
||||
var installWatcher by remember { mutableStateOf<Job?>(null) }
|
||||
var downloadFinished by rememberSaveable { mutableStateOf(false) }
|
||||
var showIssuesDialog by remember { mutableStateOf(false) }
|
||||
val assistantRoutes = remember {
|
||||
Routes(context).apply {
|
||||
}
|
||||
}
|
||||
val logPulse by rememberInfiniteTransition(label = "logPulse").animateFloat(
|
||||
initialValue = 0f,
|
||||
targetValue = 1f,
|
||||
@@ -313,62 +319,12 @@ class PatchSnapchatScreen : SetupScreen() {
|
||||
}
|
||||
|
||||
if (showIssuesDialog) {
|
||||
AestheticDialog(
|
||||
onDismissRequest = { showIssuesDialog = false },
|
||||
title = translation["setup.patch.issues_title"],
|
||||
text = "",
|
||||
icon = Icons.Filled.Info,
|
||||
confirmButtonText = translation["setup.patch.issues_confirm"],
|
||||
onConfirm = { showIssuesDialog = false },
|
||||
showCloseButton = false,
|
||||
customContent = {
|
||||
val bodyStyle = MaterialTheme.typography.bodyMedium.copy(
|
||||
color = PurrfectPalette.textSecondary,
|
||||
lineHeight = 18.sp
|
||||
)
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(max = 360.dp)
|
||||
.verticalScroll(rememberScrollState()),
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp)
|
||||
) {
|
||||
Text(
|
||||
text = translation["setup.patch.issues_heading"],
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = Color.White,
|
||||
textAlign = TextAlign.Start,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
Text(
|
||||
text = translation["setup.patch.issues_conflict_issue"],
|
||||
style = bodyStyle,
|
||||
textAlign = TextAlign.Start
|
||||
)
|
||||
Text(
|
||||
text = translation["setup.patch.issues_conflict_fix"],
|
||||
style = bodyStyle,
|
||||
textAlign = TextAlign.Start
|
||||
)
|
||||
Text(
|
||||
text = translation["setup.patch.issues_adb_command"],
|
||||
style = bodyStyle,
|
||||
textAlign = TextAlign.Start,
|
||||
softWrap = false,
|
||||
modifier = Modifier.horizontalScroll(rememberScrollState())
|
||||
)
|
||||
Text(
|
||||
text = translation["setup.patch.issues_invalid_issue"],
|
||||
style = bodyStyle,
|
||||
textAlign = TextAlign.Start
|
||||
)
|
||||
Text(
|
||||
text = translation["setup.patch.issues_invalid_fix"],
|
||||
style = bodyStyle,
|
||||
textAlign = TextAlign.Start
|
||||
)
|
||||
}
|
||||
}
|
||||
ManagerAssistantDialog(
|
||||
context = context,
|
||||
routes = assistantRoutes,
|
||||
initialUserMessage = "I am facing an App not installed issue or Package appears to be invalid issue while installing Snapchat. How do I fix it?",
|
||||
showImprovementLogging = false,
|
||||
onDismiss = { showIssuesDialog = false }
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -75,6 +75,10 @@ import org.osmdroid.views.overlay.Marker
|
||||
import org.osmdroid.views.overlay.MapEventsOverlay
|
||||
import org.osmdroid.views.overlay.Overlay
|
||||
import java.io.File
|
||||
import java.time.Instant
|
||||
import java.time.LocalDate
|
||||
import java.time.ZoneId
|
||||
import java.time.format.DateTimeFormatter
|
||||
import me.eternal.purrfectsnap.ui.util.purrfectSwitchColors
|
||||
import me.eternal.purrfectsnap.ui.util.Dialog as StandardDialog
|
||||
|
||||
@@ -427,9 +431,10 @@ class AlertDialogs(
|
||||
DefaultDialogCard {
|
||||
var fieldValue by remember {
|
||||
mutableStateOf(property.value.get().toString().let {
|
||||
val t = if (property.key.params.digitsOnlyInput) it.filter { ch -> ch.isDigit() } else it
|
||||
TextFieldValue(
|
||||
text = it,
|
||||
selection = TextRange(it.length)
|
||||
text = t,
|
||||
selection = TextRange(t.length)
|
||||
)
|
||||
})
|
||||
}
|
||||
@@ -443,10 +448,21 @@ class AlertDialogs(
|
||||
}
|
||||
.focusRequester(focusRequester),
|
||||
value = fieldValue,
|
||||
onValueChange = { fieldValue = it },
|
||||
keyboardOptions = when (property.key.dataType.type) {
|
||||
DataProcessors.Type.INTEGER -> KeyboardOptions(keyboardType = KeyboardType.Number)
|
||||
DataProcessors.Type.FLOAT -> KeyboardOptions(keyboardType = KeyboardType.Decimal)
|
||||
onValueChange = { newVal ->
|
||||
fieldValue = if (property.key.params.digitsOnlyInput) {
|
||||
val filtered = newVal.text.filter { ch -> ch.isDigit() }
|
||||
if (newVal.text != filtered) {
|
||||
Toast.makeText(context, translation["manager.sections.features.digits_only_toast"], Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
newVal.copy(text = filtered)
|
||||
} else {
|
||||
newVal
|
||||
}
|
||||
},
|
||||
keyboardOptions = when {
|
||||
property.key.params.digitsOnlyInput -> KeyboardOptions(keyboardType = KeyboardType.Number)
|
||||
property.key.dataType.type == DataProcessors.Type.INTEGER -> KeyboardOptions(keyboardType = KeyboardType.Number)
|
||||
property.key.dataType.type == DataProcessors.Type.FLOAT -> KeyboardOptions(keyboardType = KeyboardType.Decimal)
|
||||
else -> KeyboardOptions(keyboardType = KeyboardType.Text)
|
||||
},
|
||||
singleLine = true,
|
||||
@@ -512,6 +528,68 @@ class AlertDialogs(
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun DatePickerPropertyDialog(property: PropertyPair<*>, dismiss: () -> Unit = {}) {
|
||||
val context = LocalContext.current
|
||||
val zoneId = remember { ZoneId.systemDefault() }
|
||||
val initialSelectedDateMillis = remember(property.value.get()) {
|
||||
runCatching {
|
||||
LocalDate
|
||||
.parse(property.value.get().toString(), DateTimeFormatter.ISO_LOCAL_DATE)
|
||||
.atStartOfDay(zoneId)
|
||||
.toInstant()
|
||||
.toEpochMilli()
|
||||
}.getOrNull()
|
||||
}
|
||||
val datePickerState = rememberDatePickerState(initialSelectedDateMillis = initialSelectedDateMillis)
|
||||
|
||||
DefaultDialogCard {
|
||||
DatePicker(
|
||||
state = datePickerState,
|
||||
showModeToggle = true
|
||||
)
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.padding(top = 10.dp)
|
||||
.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp, Alignment.End),
|
||||
) {
|
||||
Button(
|
||||
onClick = { dismiss() },
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = Color.White.copy(alpha = 0.08f),
|
||||
contentColor = Color.White
|
||||
)
|
||||
) {
|
||||
Text(text = translation["button.cancel"])
|
||||
}
|
||||
Button(
|
||||
onClick = {
|
||||
val selectedDate = datePickerState.selectedDateMillis?.let {
|
||||
Instant.ofEpochMilli(it).atZone(zoneId).toLocalDate()
|
||||
}
|
||||
|
||||
if (selectedDate == null) {
|
||||
Toast.makeText(context, translation["invalid_input_toast"], Toast.LENGTH_SHORT).show()
|
||||
return@Button
|
||||
}
|
||||
|
||||
property.value.setAny(selectedDate.format(DateTimeFormatter.ISO_LOCAL_DATE))
|
||||
dismiss()
|
||||
},
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = PurrfectPalette.glowPrimary.copy(alpha = 0.32f),
|
||||
contentColor = Color.White
|
||||
)
|
||||
) {
|
||||
Text(text = translation["button.ok"])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun RawInputDialog(onDismiss: () -> Unit, onConfirm: (value: String) -> Unit) {
|
||||
val focusRequester = remember { FocusRequester() }
|
||||
@@ -1615,4 +1693,3 @@ class AlertDialogs(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -466,22 +466,26 @@ private class DialogWrapper(
|
||||
this.onDismissRequest = onDismissRequest
|
||||
this.properties = properties
|
||||
setLayoutDirection(layoutDirection)
|
||||
if (properties.usePlatformDefaultWidth && !dialogLayout.usePlatformDefaultWidth) {
|
||||
val dialogWindow = window
|
||||
val canUpdateWindowLayout = dialogWindow?.decorView?.let { decorView ->
|
||||
isShowing && decorView.isAttachedToWindow && decorView.windowToken != null
|
||||
} == true
|
||||
if (canUpdateWindowLayout && properties.usePlatformDefaultWidth && !dialogLayout.usePlatformDefaultWidth) {
|
||||
// Undo fixed size in internalOnLayout, which would suppress size changes when
|
||||
// usePlatformDefaultWidth is true.
|
||||
window?.setLayout(
|
||||
dialogWindow.setLayout(
|
||||
WindowManager.LayoutParams.WRAP_CONTENT,
|
||||
WindowManager.LayoutParams.WRAP_CONTENT
|
||||
)
|
||||
}
|
||||
dialogLayout.usePlatformDefaultWidth = properties.usePlatformDefaultWidth
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) {
|
||||
if (canUpdateWindowLayout && Build.VERSION.SDK_INT < Build.VERSION_CODES.S) {
|
||||
@OptIn(ExperimentalComposeUiApi::class)
|
||||
if (properties.decorFitsSystemWindows) {
|
||||
window?.setSoftInputMode(defaultSoftInputMode)
|
||||
dialogWindow?.setSoftInputMode(defaultSoftInputMode)
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
window?.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE)
|
||||
dialogWindow?.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
BIN
app/src/main/res/drawable/pfp_kaladin.jpg
Normal file
BIN
app/src/main/res/drawable/pfp_kaladin.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 108 KiB |
BIN
app/src/main/res/drawable/pfp_schrodingerspet.jpg
Normal file
BIN
app/src/main/res/drawable/pfp_schrodingerspet.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 101 KiB |
BIN
app/src/main/res/drawable/setup_slide_downloads.jpg
Normal file
BIN
app/src/main/res/drawable/setup_slide_downloads.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 56 KiB |
BIN
app/src/main/res/drawable/setup_slide_plus.jpg
Normal file
BIN
app/src/main/res/drawable/setup_slide_plus.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 96 KiB |
BIN
app/src/main/res/drawable/setup_slide_upload_tag.jpg
Normal file
BIN
app/src/main/res/drawable/setup_slide_upload_tag.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 354 KiB |
@@ -33,8 +33,8 @@ tasks.register<GetVersionTask>("getVersion") {
|
||||
}
|
||||
|
||||
// You can still set these for legacy use by submodules or scripts:
|
||||
rootProject.ext.set("appVersionName", providers.gradleProperty("APP_VERSION_NAME").orElse("1.6.2").get())
|
||||
rootProject.ext.set("appVersionCode", providers.gradleProperty("APP_VERSION_CODE").orElse("314").get().toInt())
|
||||
rootProject.ext.set("appVersionName", providers.gradleProperty("APP_VERSION_NAME").orElse("1.7.1").get())
|
||||
rootProject.ext.set("appVersionCode", providers.gradleProperty("APP_VERSION_CODE").orElse("327").get().toInt())
|
||||
rootProject.ext.set("applicationId", "me.eternal.purrfectsnap")
|
||||
// buildHash: when PurrfectSnap or Snapchat is updated, mappings become outdated and auto-regenerate.
|
||||
// Include version code so each release has a different hash; use random for uniqueness within same version.
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
## v1.1.0
|
||||
- Fix: Download Context Menu now resolves Message Logger deleted media when available.
|
||||
- Fix: message preview overlap in French.
|
||||
- Fix: transparent gallery media send override dialog.
|
||||
- Fix: message logger autopurge "Never" no longer resets to 3 days.
|
||||
|
||||
@@ -1,3 +1,98 @@
|
||||
## v1.7.1
|
||||
- Auto-Open Engine ghost notification fix.
|
||||
- Continous Send notifiction bug fix.
|
||||
- Disappering chats fix.
|
||||
- Social page automatic selection bug fix.
|
||||
|
||||
## v1.7.0
|
||||
- Features:
|
||||
- Implemented "PurrfectSnap AI" (tq ΞTΞRNAL)
|
||||
- Implemented app intro showcase (tq ΞTΞRNAL)
|
||||
- Implemented "Spoof follower count" (tq RSR)
|
||||
- Implemented Social Tab sorting by Streak Length (tq Javalsta)
|
||||
- Implemented "Chat Hold Kill" (tq SUJΛL)
|
||||
- Implemented "Snapchat plus purchase date spoof" (tq SUJΛL)
|
||||
- Implemented Message log export for individual chat (tq 𝚜𝚌𝚑𝚛𝚘𝚍𝚒𝚗𝚐𝚎𝚛𝚜𝚙𝚎𝚝)
|
||||
- Implemented Memory message icon indicator(tq RSR)
|
||||
- Implemented two new message indicator toggles, for self snaps and group.
|
||||
- Implemented new toggles for chat and snap stealth mode in friend feed menu.
|
||||
- Implemented new notification card for Continous send feature.
|
||||
|
||||
- Fixes:
|
||||
- Improved message icon indicator reliability and redesigned all chat status indicators. (tq RSR)
|
||||
- Media resend flow bug fixes. (tq 𝚜𝚌𝚑𝚛𝚘𝚍𝚒𝚗𝚐𝚎𝚛𝚜𝚙𝚎𝚝)
|
||||
- Message Logger backup import bug fixes and implemented logging to report success or failure. (tq 𝚜𝚌𝚑𝚛𝚘𝚍𝚒𝚗𝚐𝚎𝚛𝚜𝚙𝚎𝚝)
|
||||
- Media download support through message logger. (tq 𝚜𝚌𝚑𝚛𝚘𝚍𝚒𝚗𝚐𝚎𝚛𝚜𝚙𝚎𝚝)
|
||||
- Snapchat Plus bug fixes to improve stability.
|
||||
- Spoof Device profile Backup/Restore bug fixes.
|
||||
- Redesgined manager app Logs filtering UI.
|
||||
- Media Downloader stability and bug fixes.
|
||||
- Performance Mode: Fixed Max Performance mode persistence after app restart.
|
||||
- Chat Preview: Realignment of the chat preview layout.
|
||||
- Update Checker: Refactored the app update checker to follow a single, stable release track with automated daily checks.
|
||||
- Auto-Open engine: Expanded Notification UI has been refactored and redundant stats has been removed.
|
||||
- Social tab: Converted the rules configuration page to a Lazy Column architecture. This eliminates UI freezes when managing large friend lists.
|
||||
- Chat feed scroll stutter: Implemented a thread-safe memory cache for chat suppression logic. This instant memory check allows Snapchat chat feed to scroll at 100% native speed.
|
||||
- GPU Offloading: Forced hardware-layer acceleration for all camera and video preview views to reduce CPU thermal load and power consumption.
|
||||
- Refactored all runBlocking calls and main-thread database queries across 12 major pages to eliminate the UI freezes/stutters and the scrolling, navigation, and page transitions are now locked at high frame rates.
|
||||
- Migrated heavy bridge data processing (JSON parsing and DB writes) to background IO coroutines, preventing ANR (App Not Responding) crashes during large data synchronizations.
|
||||
|
||||
|
||||
## v1.6.9
|
||||
- New: Updated the Stealth mode for better visibility with the chat stealth mode (keeps chats from being read), and snap stealth-mode and full stealth mode toggle (normal stealth-mode). (tq Javalsta)
|
||||
- Fix: Fixed performance mode profile save/load so Disabled persists correctly and no longer falls back to Max mode on app restart. (tq schrodingerspet)
|
||||
- Fix: Fixed the resume/reopen UI break when max performance mode is turned on, and other bug fixes. (tq schrodingerspet)
|
||||
- New: Implemented an Auto Open Stop Button directly within the notification card.
|
||||
- New: Added a log filter menu in logs page to isolate Auto-Open, Media downloads, friend tracker, and Core logs.
|
||||
- Fix: Completely rewritten Auto Open Engine to optimize the auto open engine.
|
||||
- Fix: Fixed Batch Story Download feature not working.
|
||||
- FIx: Minor bug fixes for media downloader in stories and spotlight.
|
||||
- Fix: Bug fixes to improve custom emojis stability.
|
||||
|
||||
## v1.6.8
|
||||
- Fix: Many improvements to the Performance Mode feature(Max, turned on by Default), changes are pretty noticeable: faster loading of chats, long group messages optimizations, many snapmap optimizations
|
||||
- Fix: Crash issues for some devices
|
||||
- Fix: Block Ads
|
||||
- Fix: Snapchat automatically restarting if kept idle
|
||||
- Fix: Feed Flickering issue for some devices
|
||||
- Fix: Implemented Dual-Thread Sync Architecture with a 1,000 depth that proactively scans the database for missed history, thus resolving the "failed to open..." and "Tap to load" snaps before marking them, clearing historical gaps automatically(tq to Kaladin)
|
||||
- New: Implemented Screen Guard where Auto Open Engine now detects screen state and stops all notification work while the device is locked, thus reducing excessive battery drain and heat spike during background processing(tq to Kaladin)
|
||||
- Fix: Disk read/write frequency increased from 1s to 5 minutes, to eliminate constant background disk friction, thus reducing overheating of the device while processing(tq to Kaladin)
|
||||
- Fix: Slid chat switching delays from 2.0s down to 40ms, thus achieving near-instant processing of 1000+ snap bursts(tq to Kaladin)
|
||||
- Fix: Removed "Auto Open pause while Gaming" toggle(tq to Kaladin)
|
||||
- Fix: Reworked "Snap Pre-Fetch" toggle to the global Messaging settings page(tq to Kaladin)
|
||||
- Fix: Refactored Auto Open engine notification cards to declutter it and removed redundant stats(tq to Kaladin)
|
||||
- Fix: Corrected speed labels in the Auto Open notification to show "Idle" when monitoring and accurate notions (Full Speed/Throttled) when active(tq to Kaladin)
|
||||
|
||||
## v1.6.6
|
||||
- Fix: Persistent auto open snap disabled notification(tq to Kaladin)
|
||||
- Fix: Crash issues for some devices
|
||||
- Fix: upload quality for snaps sent through send override
|
||||
- Fix: Refactored the notification card for Auto Open to remove the looping progress bar while the engine is in Monitoring stage(tq to Kaladin)
|
||||
- New: Implemented a "Pre Fetch snaps" toggle, when enabled this will pre-fetch the snaps in the auto open queue and load them for the engine to open them efficiently and thus reducing the "Failed to Open..." status(tq to Kaladin)
|
||||
- Fix: The Auto Open engine now only fires the status update if the status text, processed count, or queue structural state actually changes. This reduces system wake-ups during idle periods which helps in battery drain management(tq to Kaladin)
|
||||
- Fix: Disk Optimization: Previous versions wrote the auto open queue to the disk every 25 snaps, this update modified this to a 3-minute windowed save. This reduces Disk I/O by ~75%, keeping the device from overheating excessively during Auto Open processes(tq to Kaladin)
|
||||
|
||||
## v1.6.5
|
||||
- New: Performance mode will now be set to max by default!
|
||||
- Fix: Several optimizations to the performance mode feature which will make your snapchat experience more smooth!
|
||||
- Fix: Crash issues for some devices after enabling spoof
|
||||
- Fix: Notification Icon for the Announcements(tq to Kaladin)
|
||||
- Fix: Auto Open Engine not processing, stuck on monitor/retry/failed loop causing overheating of the devices(tq to Kaladin)
|
||||
- Fix: Refactor the Notification card for the Auto Open to remove dynamic progress bar to add the native progress bar(tq to Kaladin)
|
||||
- Fix: Auto Open Statistics have been refactored to now show the dynamic queue status(tq to Kaladin)
|
||||
- New: Auto Open Thermal Protection/Throttle toggle, when turned on the processing will be doubled down to reduce the temps of the device to maintain a study temp of 40 C and below(tq to Kaladin)
|
||||
|
||||
## v1.6.4
|
||||
- New: Performance mode feature!(Smooth & Max)
|
||||
- Fix: Failed to init feature Device Spoofer for some devices
|
||||
- Fix: Increase the default queue of auto open snaps
|
||||
- Fix: user_conversation error spam
|
||||
|
||||
## v1.6.3
|
||||
- Fix: Blank Global Settings page
|
||||
- New: Backup & Restore option for Randomized Device Profile feature!
|
||||
|
||||
## v1.6.2
|
||||
- New: Randomized Device Profile Feature!
|
||||
- Show Activation Overlay
|
||||
|
||||
@@ -71,7 +71,7 @@ interface BridgeInterface {
|
||||
* @param groups list of groups (MessagingGroupInfo as parcelable)
|
||||
* @param friends list of friends (MessagingFriendInfo as parcelable)
|
||||
*/
|
||||
oneway void passGroupsAndFriends(in List<String> groups, in List<String> friends);
|
||||
oneway void passGroupsAndFriends(in List<String> groups, in List<String> friends, int chunkIndex, int totalChunks);
|
||||
|
||||
@nullable String getScopeNotes(String id);
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -270,6 +270,26 @@
|
||||
"success_toast": "Done!",
|
||||
"message_logger_summary": "{messageCount} messages\n{storyCount} stories",
|
||||
"export_button": "Export",
|
||||
"message_logger_export_title": "Export Message Logger",
|
||||
"message_logger_export_text": "Choose what to export.",
|
||||
"message_logger_export_individual_chat": "Export Individual Chat",
|
||||
"message_logger_export_full_database": "Export Full Database",
|
||||
"message_logger_select_chat_title": "Export Individual Chat",
|
||||
"message_logger_select_chat_text": "Search by username, display name, or chat name.",
|
||||
"message_logger_continue_button": "Continue",
|
||||
"message_logger_select_export_format_title": "Select Export Format",
|
||||
"message_logger_select_export_format_text": "Choose how to export the selected chat.",
|
||||
"message_logger_export_format_db": ".db",
|
||||
"message_logger_export_format_html": "HTML",
|
||||
"message_logger_export_format_txt": "TXT",
|
||||
"message_logger_no_chats_found": "No chats found",
|
||||
"message_logger_no_messages_export_text": "No messages found in this chat.",
|
||||
"message_logger_conversation_id": "Conversation ID: {id}",
|
||||
"message_logger_message_count": "{count} messages",
|
||||
"message_logger_missing_attachment_placeholder": "Attachment unavailable",
|
||||
"message_logger_export_failed_toast": "Export failed: {message}",
|
||||
"message_logger_missing_conversation_toast": "Missing conversation ID",
|
||||
"message_logger_empty_chat_toast": "Selected chat has no messages to export",
|
||||
"import_button": "Import",
|
||||
"clear_button": "Clear",
|
||||
"view_logger_history_button": "View Logger History",
|
||||
@@ -779,11 +799,27 @@
|
||||
}
|
||||
},
|
||||
"stealth": {
|
||||
"name": "Stealth Mode",
|
||||
"description": "Prevents anyone from knowing you've opened their Snaps/Chats and conversations",
|
||||
"name": "Full Stealth Mode",
|
||||
"description": "Applies both chat stealth and snap stealth for this conversation",
|
||||
"options": {
|
||||
"blacklist": "Exclude from Stealth Mode",
|
||||
"whitelist": "Stealth mode"
|
||||
"blacklist": "Exclude from Full Stealth Mode",
|
||||
"whitelist": "Full Stealth Mode"
|
||||
}
|
||||
},
|
||||
"snap_stealth": {
|
||||
"name": "Snap Stealth Mode",
|
||||
"description": "Prevents anyone from knowing you've opened their snaps",
|
||||
"options": {
|
||||
"blacklist": "Exclude from Snap Stealth Mode",
|
||||
"whitelist": "Snap Stealth Mode"
|
||||
}
|
||||
},
|
||||
"chat_stealth": {
|
||||
"name": "Chat Stealth Mode",
|
||||
"description": "Prevents anyone from knowing you've opened their chats or viewed their chat presence",
|
||||
"options": {
|
||||
"blacklist": "Exclude from Chat Stealth Mode",
|
||||
"whitelist": "Chat Stealth Mode"
|
||||
}
|
||||
},
|
||||
"auto_save": {
|
||||
@@ -1089,6 +1125,10 @@
|
||||
"name": "Show Streak Expiration Info",
|
||||
"description": "Shows a Streak Expiration timer next to the Streaks counter"
|
||||
},
|
||||
"sort_social_tab_by_streak_length": {
|
||||
"name": "Sort Social Tab by Streak Length",
|
||||
"description": "Shows friends with streaks first, ordered from the longest streak to the shortest in the social tab and friend picker"
|
||||
},
|
||||
"hide_friend_feed_entry": {
|
||||
"name": "Hide Friend Feed Entry",
|
||||
"description": "Hides a specific friend from the Friend Feed\nUse the social tab to manage this feature"
|
||||
@@ -1885,7 +1925,13 @@
|
||||
"name": "Auto Download"
|
||||
},
|
||||
"stealth": {
|
||||
"name": "Stealth Mode"
|
||||
"name": "Full Stealth Mode"
|
||||
},
|
||||
"snap_stealth": {
|
||||
"name": "Snap Stealth Mode"
|
||||
},
|
||||
"chat_stealth": {
|
||||
"name": "Chat Stealth Mode"
|
||||
},
|
||||
"auto_save": {
|
||||
"name": "Auto Save"
|
||||
@@ -2057,6 +2103,14 @@
|
||||
"view_current_profile_action": {
|
||||
"name": "View Current Profile",
|
||||
"description": "Inspect the latest randomized profile snapshot"
|
||||
},
|
||||
"backup_profile_action": {
|
||||
"name": "Backup Profile",
|
||||
"description": "Export the full randomized profile as a backup file"
|
||||
},
|
||||
"restore_profile_action": {
|
||||
"name": "Restore Profile",
|
||||
"description": "Import and restore a previously backed up randomized profile"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -2283,7 +2337,9 @@
|
||||
"auto_save": "\ud83d\udcac Auto Save Messages",
|
||||
"unsaveable_messages": "\u2b07\ufe0f Unsaveable Messages",
|
||||
"auto_open_snaps": "\ud83d\udcf7 Auto Open Snaps",
|
||||
"stealth": "\ud83d\udc7b Stealth Mode",
|
||||
"stealth": "\ud83d\udc7b Full Stealth Mode",
|
||||
"snap_stealth": "\ud83d\udcf7 Snap Stealth Mode",
|
||||
"chat_stealth": "\ud83d\udcac Chat Stealth Mode",
|
||||
"auto_reply": "\ud83d\udce8 Auto Reply",
|
||||
"auto_delete_sent_messages": "\ud83d\uddd1\ufe0f Auto Delete Sent Messages",
|
||||
"mark_snaps_as_seen": "\ud83d\udc40 Mark Snaps as seen",
|
||||
@@ -2377,6 +2433,16 @@
|
||||
"whitelist": "Whitelist",
|
||||
"disabled": "Disabled"
|
||||
},
|
||||
"snap_stealth": {
|
||||
"blacklist": "Blacklist",
|
||||
"whitelist": "Whitelist",
|
||||
"disabled": "Disabled"
|
||||
},
|
||||
"chat_stealth": {
|
||||
"blacklist": "Blacklist",
|
||||
"whitelist": "Whitelist",
|
||||
"disabled": "Disabled"
|
||||
},
|
||||
"auto_save": {
|
||||
"blacklist": "Blacklist",
|
||||
"whitelist": "Whitelist",
|
||||
@@ -2601,7 +2667,10 @@
|
||||
"platform_indicator": "Adds the platform icon from which a media was sent (e.g. Android, iOS, Web)",
|
||||
"location_indicator": "Adds a \ud83d\udccd icon to snaps when they have been sent with location enabled",
|
||||
"ovf_editor_indicator": "Indicates if a snap has been sent using OVF Editor",
|
||||
"director_mode_indicator": "Adds a \u270f\ufe0f icon to snaps when they have been sent using Director Mode, which can be used to send gallery images as snaps"
|
||||
"director_mode_indicator": "Adds a \u270f\ufe0f icon to snaps when they have been sent using Director Mode, which can be used to send gallery images as snaps",
|
||||
"memories_indicator": "Adds a \ud83d\udcd6 icon to snaps that were re-sent from Memories instead of being captured with the live camera",
|
||||
"skip_own_indicators": "Hides indicator icons on your own sent snaps (Self-Snaps) \ud83d\udc64",
|
||||
"disable_indicators_in_groups": "Disables all indicator icons in group conversations to reduce UI clutter \ud83d\udc65"
|
||||
},
|
||||
"auto_mark_as_read": {
|
||||
"conversation_read": "Mark conversation as read when sending a message",
|
||||
|
||||
@@ -197,12 +197,12 @@
|
||||
},
|
||||
"sections": {
|
||||
"home": {
|
||||
"version_title": "v{versionName} \u00b7 by ΞTΞRNAL",
|
||||
"version_title": "v{versionName} \u00b7 by \u039eT\u039eRNAL",
|
||||
"update_title": "PurrfectSnap Update",
|
||||
"update_content": "Version {version} is available!",
|
||||
"update_button": "Download",
|
||||
"hero_tagline": "An Xposed Module meant to enhance your Snapchat experience",
|
||||
"hero_version_label": "Version: {version} - {channel}",
|
||||
"hero_version_label": "Version: {version}",
|
||||
"hero_build_label": "Build: {build}",
|
||||
"update_ready_label": "Ready to install",
|
||||
"purr_aura_active_label": "PurrAura Active!",
|
||||
@@ -247,15 +247,24 @@
|
||||
"about_tagline": "An Xposed Module meant to enhance your Snapchat experience!",
|
||||
"about_lead_developers_title": "Lead Developers",
|
||||
"about_story_title": "Our Story",
|
||||
"about_story": "PurrfectSnap was founded on 2nd of October, 2025, as a fork of SnapEnhance by ΞTΞRNAL with a vision to provide users the quality Snapchat experience they deserve. This app was just meant to be a minor update in the SnapEnhance repository, but it soon became a separate app wherein the contributors kept adding features. Then the developer <RSR/> joined the team, and this app soon became a huge success.\n\nWe would also like to convey our huge thanks to rhunk, the lead developer of SnapEnhance, as without him, this app wouldn't even exist. We are immensely grateful to him.\n\nWe received much love and support and gained 1K+ downloads in just two days! We thank all users and contributors; without your support, we wouldn't have reached this place.\n\nLastly, we would like to thank all our admins, notably: CLASSIC GENIUS, Harry, Sujal, Zain & schrodingerspet, who were right there with us from the very beginning. We would also like to thank all testers, notably Leo & Toxic, who tested and reported bugs continuously. We are immensely grateful for your contribution.",
|
||||
"about_story": "PurrfectSnap was founded on 2nd of October, 2025, as a fork of SnapEnhance by \u039eT\u039eRNAL with a vision to provide users the quality Snapchat experience they deserve. This app was just meant to be a minor update in the SnapEnhance repository, but it soon became a separate app wherein the contributors kept adding features. Then the developer <RSR/> joined the team, and this app soon became a huge success.\n\nWe would also like to convey our huge thanks to rhunk, the lead developer of SnapEnhance, as without him, this app wouldn't even exist. We are immensely grateful to him.\n\nWe received much love and support and gained 1K+ downloads in just two days! We thank all users and contributors; without your support, we wouldn't have reached this place.\n\nLastly, we would like to thank all our admins, notably: CLASSIC GENIUS, Harry, Sujal, Zain & schrodingerspet, who were right there with us from the very beginning. We would also like to thank all testers, notably Leo & Toxic, who tested and reported bugs continuously. We are immensely grateful for your contribution.",
|
||||
"about_thanks_title": "With love, PurrfectSnap Team",
|
||||
"about_magic_toast": "Tap 5 times in this screen to see some magic 😉!",
|
||||
"about_magic_toast": "Tap 5 times in this screen to see some magic \ud83d\ude09!",
|
||||
"github_button": "GitHub",
|
||||
"telegram_button": "Telegram"
|
||||
},
|
||||
"home_logs": {
|
||||
"no_logs_hint": "No logs available",
|
||||
"refresh_hint": "Pull to refresh or trigger an action to see new entries.",
|
||||
"filter_logs_title": "Filter Log Categories",
|
||||
"filter_logs_menu_item": "Filter Logs",
|
||||
"filter_logs_done_button": "Done",
|
||||
"log_category_core": "Core",
|
||||
"log_category_auto_open": "Auto-Open",
|
||||
"log_category_media": "Media",
|
||||
"log_category_bridge": "Bridge",
|
||||
"log_category_system": "System",
|
||||
"log_category_tracker": "Tracker",
|
||||
"clear_logs_button": "Clear Logs",
|
||||
"export_logs_button": "Export Logs",
|
||||
"saving_logs_toast": "Saving logs, this may take a while ...",
|
||||
@@ -272,6 +281,26 @@
|
||||
"success_toast": "Done!",
|
||||
"message_logger_summary": "{messageCount} messages\n{storyCount} stories",
|
||||
"export_button": "Export",
|
||||
"message_logger_export_title": "Export Message Logger",
|
||||
"message_logger_export_text": "Choose what to export.",
|
||||
"message_logger_export_individual_chat": "Export Individual Chat",
|
||||
"message_logger_export_full_database": "Export Full Database",
|
||||
"message_logger_select_chat_title": "Export Individual Chat",
|
||||
"message_logger_select_chat_text": "Search by username, display name, or chat name.",
|
||||
"message_logger_continue_button": "Continue",
|
||||
"message_logger_select_export_format_title": "Select Export Format",
|
||||
"message_logger_select_export_format_text": "Choose how to export the selected chat.",
|
||||
"message_logger_export_format_db": ".db",
|
||||
"message_logger_export_format_html": "HTML",
|
||||
"message_logger_export_format_txt": "TXT",
|
||||
"message_logger_no_chats_found": "No chats found",
|
||||
"message_logger_no_messages_export_text": "No messages found in this chat.",
|
||||
"message_logger_conversation_id": "Conversation ID: {id}",
|
||||
"message_logger_message_count": "{count} messages",
|
||||
"message_logger_missing_attachment_placeholder": "Attachment unavailable",
|
||||
"message_logger_export_failed_toast": "Export failed: {message}",
|
||||
"message_logger_missing_conversation_toast": "Missing conversation ID",
|
||||
"message_logger_empty_chat_toast": "Selected chat has no messages to export",
|
||||
"import_button": "Import",
|
||||
"clear_button": "Clear",
|
||||
"view_logger_history_button": "View Logger History",
|
||||
@@ -348,21 +377,22 @@
|
||||
"remove_all_tasks_confirm": "Remove all tasks?"
|
||||
},
|
||||
"features": {
|
||||
"disabled": "Disabled",
|
||||
"export_option": "Export",
|
||||
"import_option": "Import",
|
||||
"reset_option": "Reset",
|
||||
"config_export_success_toast": "Config exported successfully",
|
||||
"config_import_success_toast": "Config imported successfully",
|
||||
"config_import_failure_toast": "Failed to import config {error}",
|
||||
"config_export_failure_toast": "Failed to export config {error}",
|
||||
"saved_config_snackbar": "Config saved",
|
||||
"older_required": "This feature requires Snapchat v{version} or older to work correctly",
|
||||
"newer_required": "This feature requires Snapchat v{version} or newer to work correctly",
|
||||
"disabled": "Disabled",
|
||||
"export_option": "Export",
|
||||
"import_option": "Import",
|
||||
"reset_option": "Reset",
|
||||
"config_export_success_toast": "Config exported successfully",
|
||||
"config_import_success_toast": "Config imported successfully",
|
||||
"config_import_failure_toast": "Failed to import config {error}",
|
||||
"config_export_failure_toast": "Failed to export config {error}",
|
||||
"saved_config_snackbar": "Config saved",
|
||||
"older_required": "This feature requires Snapchat v{version} or older to work correctly",
|
||||
"newer_required": "This feature requires Snapchat v{version} or newer to work correctly",
|
||||
"search_button": "Search",
|
||||
"search_results_count": "{count} messages",
|
||||
"clear_history": "Clear search history",
|
||||
"subtitle": "Explore and manage premium features"
|
||||
"subtitle": "Explore and manage premium features",
|
||||
"digits_only_toast": "Only numbers are allowed."
|
||||
},
|
||||
"bypass_status": {
|
||||
"active": "PurrAura Active",
|
||||
@@ -818,11 +848,27 @@
|
||||
}
|
||||
},
|
||||
"stealth": {
|
||||
"name": "Stealth Mode",
|
||||
"description": "Prevents anyone from knowing you've opened their Snaps/Chats and conversations",
|
||||
"name": "Full Stealth Mode",
|
||||
"description": "Applies both chat stealth and snap stealth for this conversation",
|
||||
"options": {
|
||||
"blacklist": "Exclude from Stealth Mode",
|
||||
"whitelist": "Stealth mode"
|
||||
"blacklist": "Exclude from Full Stealth Mode",
|
||||
"whitelist": "Full Stealth Mode"
|
||||
}
|
||||
},
|
||||
"snap_stealth": {
|
||||
"name": "Snap Stealth Mode",
|
||||
"description": "Prevents anyone from knowing you've opened their snaps",
|
||||
"options": {
|
||||
"blacklist": "Exclude from Snap Stealth Mode",
|
||||
"whitelist": "Snap Stealth Mode"
|
||||
}
|
||||
},
|
||||
"chat_stealth": {
|
||||
"name": "Chat Stealth Mode",
|
||||
"description": "Prevents anyone from knowing you've opened their chats or viewed their chat presence",
|
||||
"options": {
|
||||
"blacklist": "Exclude from Chat Stealth Mode",
|
||||
"whitelist": "Chat Stealth Mode"
|
||||
}
|
||||
},
|
||||
"auto_save": {
|
||||
@@ -1131,6 +1177,10 @@
|
||||
"name": "Show Streak Expiration Info",
|
||||
"description": "Shows a Streak Expiration timer next to the Streaks counter"
|
||||
},
|
||||
"sort_social_tab_by_streak_length": {
|
||||
"name": "Sort Social Tab by Streak Length",
|
||||
"description": "Shows friends with streaks first, ordered from the longest streak to the shortest in the social tab and friend picker"
|
||||
},
|
||||
"hide_friend_feed_entry": {
|
||||
"name": "Hide Friend Feed Entry",
|
||||
"description": "Hides a specific friend from the Friend Feed\nUse the social tab to manage this feature"
|
||||
@@ -1215,6 +1265,10 @@
|
||||
"name": "Settings Menu",
|
||||
"description": "Choose between the new and legacy settings menu layouts"
|
||||
},
|
||||
"chat_hold_kill_actions": {
|
||||
"name": "PurrfectSnap Chat Hold Kill",
|
||||
"description": "Hold the chat/settings header button to kill selected app(s). Leave all options off to disable."
|
||||
},
|
||||
"spoof_snap_score": {
|
||||
"name": "Spoof Snap Score",
|
||||
"description": "Spoof your Snap Score (local only)",
|
||||
@@ -1224,6 +1278,16 @@
|
||||
"description": "The custom Snap Score you want to display (max 9,999,999)"
|
||||
}
|
||||
}
|
||||
},
|
||||
"spoof_followers_count": {
|
||||
"name": "Spoof Followers Count",
|
||||
"description": "Spoof your follower count on your profile (local only).",
|
||||
"properties": {
|
||||
"custom_followers_count": {
|
||||
"name": "Custom Followers Count",
|
||||
"description": "Number to show (digits only)."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -1481,6 +1545,10 @@
|
||||
"name": "Bypass Message Action Restrictions",
|
||||
"description": "Allows you to react to a snap without having opened it or to save an unsaveable message"
|
||||
},
|
||||
"pre_fetch_snaps": {
|
||||
"name": "Snap Pre-Fetch",
|
||||
"description": "Fetches snap media into Snapchat's internal cache once the snap arrives. Note: Turning it on increases background data usage."
|
||||
},
|
||||
"remove_groups_locked_status": {
|
||||
"name": "Remove Groups Locked Status",
|
||||
"description": "Allows you to view group information after being kicked"
|
||||
@@ -1648,6 +1716,7 @@
|
||||
"status_monitoring": "Monitoring",
|
||||
"status_active": "Active",
|
||||
"status_paused": "Paused",
|
||||
"thermal_status_title": "Thermal Cooling (Throttled)",
|
||||
"processed_count": "Opened",
|
||||
"queue_size": "Queue",
|
||||
"action_reset": "Reset Statistics",
|
||||
@@ -1658,6 +1727,14 @@
|
||||
"name": "Allow Running in Background",
|
||||
"description": "Allows Auto Open Snaps to run in the background. Note: This will significantly drain your battery"
|
||||
},
|
||||
"delay_between_snaps": {
|
||||
"name": "Delay Between Snaps",
|
||||
"description": "The delay in milliseconds between opening each individual Snap"
|
||||
},
|
||||
"delay_between_conversations": {
|
||||
"name": "Delay Between Conversations",
|
||||
"description": "The delay in milliseconds when switching to open Snaps from a different conversation"
|
||||
},
|
||||
"min_delay": {
|
||||
"name": "Min Delay (ms)",
|
||||
"description": "Minimum delay in milliseconds before opening a snap"
|
||||
@@ -1682,10 +1759,6 @@
|
||||
"name": "Auto Open Compact Notification",
|
||||
"description": "Use a smaller, single-line notification for status updates"
|
||||
},
|
||||
"show_progress_bar": {
|
||||
"name": "Show Progress Bar",
|
||||
"description": "Display a visual progress bar in the status notification"
|
||||
},
|
||||
"show_lifetime_stats": {
|
||||
"name": "Show Lifetime Statistics",
|
||||
"description": "Include the total number of snaps opened since installation in the notification"
|
||||
@@ -1694,7 +1767,15 @@
|
||||
"name": "Show Queue Preview",
|
||||
"description": "Show a list of the most recent snaps waiting in the queue (Expanded only)"
|
||||
},
|
||||
"only_on_wifi": { "name": "Auto Open only on Wi-Fi", "description": "Only process queue when connected to a Wi-Fi network to save mobile data" },
|
||||
"thermal_protection": {
|
||||
"name": "Thermal Protection",
|
||||
"description": "Automatically throttles the engine and increases delays if the device temperature exceeds 40\u00b0C to prevent overheating"
|
||||
},
|
||||
"only_on_wifi": {
|
||||
"name": "Auto Open only on Wi-Fi",
|
||||
"description": "Only process queue when connected to a Wi-Fi network to save mobile data"
|
||||
},
|
||||
"content_type_snap": "Snap",
|
||||
"only_when_idle": {
|
||||
"name": "Auto Open Schedule",
|
||||
"description": "Configure a specific time window where the engine will throttle its speed."
|
||||
@@ -1703,37 +1784,9 @@
|
||||
"name": "Auto Open Scheduler",
|
||||
"description": "Define the start and end times for scheduled throttled processing."
|
||||
},
|
||||
"pause_during_gaming": { "name": "Pause Auto Open During Gaming", "description": "Automatically slow down processing when a resource intensive app or a game is in the foreground" },
|
||||
"safe_processing": { "name": "Auto Open with stealth pace", "description": "Adds variable delays and natural breaks to remain undetected. Turn off for maximum speed." }
|
||||
}
|
||||
},
|
||||
"auto_delete_sent_messages": {
|
||||
"name": "Auto Delete Sent Messages",
|
||||
"description": "Automatically deletes sent messages after a specified time period",
|
||||
"properties": {
|
||||
"allow_running_in_background": {
|
||||
"name": "Allow Running in Background",
|
||||
"description": "Allows Auto Delete Sent Messages to run in the background. Note: This will significantly drain your battery"
|
||||
},
|
||||
"delete_after_value": {
|
||||
"name": "Delete After (value)",
|
||||
"description": "Time value before deleting the sent message"
|
||||
},
|
||||
"delete_after_unit": {
|
||||
"name": "Time Unit",
|
||||
"description": "Select the time unit for deletion delay"
|
||||
},
|
||||
"message_types": {
|
||||
"name": "Message Types",
|
||||
"description": "Select which message types should be auto-deleted"
|
||||
},
|
||||
"show_countdown": {
|
||||
"name": "Show Countdown",
|
||||
"description": "Show countdown before deleting the message"
|
||||
},
|
||||
"show_notification": {
|
||||
"name": "Show Notification",
|
||||
"description": "Show notification during countdown"
|
||||
"safe_processing": {
|
||||
"name": "Auto Open with stealth pace",
|
||||
"description": "Adds variable delays and natural breaks to remain undetected. Turn off for maximum speed."
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -1791,6 +1844,36 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"auto_delete_sent_messages": {
|
||||
"name": "Auto Delete Sent Messages",
|
||||
"description": "Automatically deletes sent messages after a specified time period",
|
||||
"properties": {
|
||||
"allow_running_in_background": {
|
||||
"name": "Allow Running in Background",
|
||||
"description": "Allows Auto Delete Sent Messages to run in the background. Note: This will significantly drain your battery"
|
||||
},
|
||||
"delete_after_value": {
|
||||
"name": "Delete After (value)",
|
||||
"description": "Time value before deleting the sent message"
|
||||
},
|
||||
"delete_after_unit": {
|
||||
"name": "Time Unit",
|
||||
"description": "Select the time unit for deletion delay"
|
||||
},
|
||||
"message_types": {
|
||||
"name": "Message Types",
|
||||
"description": "Select which message types should be auto-deleted"
|
||||
},
|
||||
"show_countdown": {
|
||||
"name": "Show Countdown",
|
||||
"description": "Show countdown before deleting the message"
|
||||
},
|
||||
"show_notification": {
|
||||
"name": "Show Notification",
|
||||
"description": "Show notification during countdown"
|
||||
}
|
||||
}
|
||||
},
|
||||
"scheduled_send_allow_running_in_background": {
|
||||
"name": "Allow Scheduled Send to Run in Background",
|
||||
"description": "Keep scheduled messages processing while Snapchat is in the background"
|
||||
@@ -1879,6 +1962,10 @@
|
||||
"name": "Snapchat Plus",
|
||||
"description": "Enables Snapchat Plus features\nSome Server-sided features may not work"
|
||||
},
|
||||
"snapchat_plus_purchase_date": {
|
||||
"name": "Snapchat Plus Purchase Date",
|
||||
"description": "Tap Save to choose a date from calendar (leave empty to use default)"
|
||||
},
|
||||
"media_upload_quality": {
|
||||
"name": "Media Upload Quality",
|
||||
"description": "Overrides the media upload quality",
|
||||
@@ -1909,6 +1996,16 @@
|
||||
"name": "Disable Metrics",
|
||||
"description": "Blocks sending specific analytic data to Snapchat"
|
||||
},
|
||||
"performance_mode": {
|
||||
"name": "Performance Mode",
|
||||
"description": "Applies an app-wide speed profile for navigation, preview loading, background work, and camera responsiveness",
|
||||
"properties": {
|
||||
"performance_profile": {
|
||||
"name": "Performance Profile",
|
||||
"description": "Select how aggressively PurrfectSnap pushes app-wide performance tuning"
|
||||
}
|
||||
}
|
||||
},
|
||||
"disable_story_sections": {
|
||||
"name": "Disable Story Sections",
|
||||
"description": "Removes sections from the Stories page\nMay require a refresh to work properly"
|
||||
@@ -1995,7 +2092,13 @@
|
||||
"name": "Auto Download"
|
||||
},
|
||||
"stealth": {
|
||||
"name": "Stealth Mode"
|
||||
"name": "Full Stealth Mode"
|
||||
},
|
||||
"snap_stealth": {
|
||||
"name": "Snap Stealth Mode"
|
||||
},
|
||||
"chat_stealth": {
|
||||
"name": "Chat Stealth Mode"
|
||||
},
|
||||
"auto_save": {
|
||||
"name": "Auto Save"
|
||||
@@ -2056,7 +2159,15 @@
|
||||
"name": "HEVC Recording",
|
||||
"description": "Uses HEVC (H.265) codec for video recording"
|
||||
},
|
||||
"camera_tweaks": { "name": "Upgraded Camera Engine", "description": "Enables professional hardware ISP processing modes for better dynamic range" }, "audio_video": { "name": "Upgraded Audio and Video", "description": "Increases Video bitrate to 30Mbps and Audio to 320kbps/48kHz" }, "video_record_timer": {
|
||||
"camera_tweaks": {
|
||||
"name": "Upgraded Camera Engine",
|
||||
"description": "Enables professional hardware ISP processing modes for better dynamic range"
|
||||
},
|
||||
"audio_video": {
|
||||
"name": "Upgraded Audio and Video",
|
||||
"description": "Increases Video bitrate to 30Mbps and Audio to 320kbps/48kHz"
|
||||
},
|
||||
"video_record_timer": {
|
||||
"name": "Video Recording Timer",
|
||||
"description": "Shows a recording timer overlay when recording video"
|
||||
},
|
||||
@@ -2126,6 +2237,10 @@
|
||||
"name": "Disable Bitmoji",
|
||||
"description": "Disables Friends Profile Bitmoji"
|
||||
},
|
||||
"debug_font_redirect": {
|
||||
"name": "Debug Native Font Redirect",
|
||||
"description": "Logs native font interception. For developer use only."
|
||||
},
|
||||
"custom_emoji_font": {
|
||||
"name": "Custom Emoji Font",
|
||||
"description": "Allows you to use a custom emoji font. Only works with .ttf fonts"
|
||||
@@ -2410,7 +2525,7 @@
|
||||
"name": "Settings Details",
|
||||
"description": "Enable settings spoofing and fine-tune its namespaces",
|
||||
"properties": {
|
||||
"secure": {
|
||||
"secure_settings": {
|
||||
"name": "Secure Settings",
|
||||
"description": "Enable secure settings spoofing and fine-tune secure values",
|
||||
"properties": {
|
||||
@@ -2424,7 +2539,7 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"system": {
|
||||
"system_settings": {
|
||||
"name": "System Settings",
|
||||
"description": "Enable system settings spoofing and fine-tune system values",
|
||||
"properties": {
|
||||
@@ -2438,7 +2553,7 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"global": {
|
||||
"global_settings": {
|
||||
"name": "Global Settings",
|
||||
"description": "Enable global settings spoofing and fine-tune global values",
|
||||
"properties": {
|
||||
@@ -2565,6 +2680,14 @@
|
||||
"view_current_profile_action": {
|
||||
"name": "View Current Profile",
|
||||
"description": "Inspect the latest randomized profile snapshot"
|
||||
},
|
||||
"backup_profile_action": {
|
||||
"name": "Backup Profile",
|
||||
"description": "Export the full randomized profile as a backup file"
|
||||
},
|
||||
"restore_profile_action": {
|
||||
"name": "Restore Profile",
|
||||
"description": "Import and restore a previously backed up randomized profile"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -2614,7 +2737,11 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"network_optimization": { "name": "Improved Network Connectivity", "description": "Optimizes network socket buffers for maximum stability and high-speed upload/download performance" }, "better_transcript": {
|
||||
"network_optimization": {
|
||||
"name": "Improved Network Connectivity",
|
||||
"description": "Optimizes network socket buffers for maximum stability and high-speed upload/download performance"
|
||||
},
|
||||
"better_transcript": {
|
||||
"name": "Better Transcript",
|
||||
"description": "Improves the voice note transcript",
|
||||
"properties": {
|
||||
@@ -2761,6 +2888,24 @@
|
||||
"description": "Automatically deletes cached events that are older than the specified amount of time"
|
||||
}
|
||||
}
|
||||
},
|
||||
"companion_server": {
|
||||
"name": "Companion Server",
|
||||
"description": "Local web server that streams live logs and network calls to a browser dashboard",
|
||||
"properties": {
|
||||
"enabled": {
|
||||
"name": "Enabled",
|
||||
"description": "Start the companion web server inside Snapchat's process. Requires a restart."
|
||||
},
|
||||
"port": {
|
||||
"name": "Port",
|
||||
"description": "Port the server listens on (1024–65535). Default is 8484."
|
||||
},
|
||||
"token": {
|
||||
"name": "Auth Token",
|
||||
"description": "Secret token required to access the dashboard. Must not be empty. Requires a restart."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"options": {
|
||||
@@ -2795,7 +2940,9 @@
|
||||
"auto_save": "\ud83d\udcac Auto Save Messages",
|
||||
"unsaveable_messages": "\u2b07\ufe0f Unsaveable Messages",
|
||||
"auto_open_snaps": "\ud83d\udcf7 Auto Open Snaps",
|
||||
"stealth": "\ud83d\udc7b Stealth Mode",
|
||||
"stealth": "\ud83d\udc7b Full Stealth Mode",
|
||||
"snap_stealth": "\ud83d\udcf7 Snap Stealth Mode",
|
||||
"chat_stealth": "\ud83d\udcac Chat Stealth Mode",
|
||||
"auto_reply": "\ud83d\udce8 Auto Reply",
|
||||
"auto_delete_sent_messages": "\ud83d\uddd1\ufe0f Auto Delete Sent Messages",
|
||||
"mark_chat_as_read": "\ud83d\udcd6 Mark Chat as Read",
|
||||
@@ -2837,6 +2984,10 @@
|
||||
"default": "Default",
|
||||
"legacy": "Legacy"
|
||||
},
|
||||
"chat_hold_kill_actions": {
|
||||
"kill_snapchat": "Kill Snapchat",
|
||||
"kill_purrfectsnap": "Kill PurrfectSnap"
|
||||
},
|
||||
"path_format": {
|
||||
"create_author_folder": "Create folder for each author",
|
||||
"create_source_folder": "Create folder for each media source type",
|
||||
@@ -2902,6 +3053,16 @@
|
||||
"whitelist": "Whitelist",
|
||||
"disabled": "Disabled"
|
||||
},
|
||||
"snap_stealth": {
|
||||
"blacklist": "Blacklist",
|
||||
"whitelist": "Whitelist",
|
||||
"disabled": "Disabled"
|
||||
},
|
||||
"chat_stealth": {
|
||||
"blacklist": "Blacklist",
|
||||
"whitelist": "Whitelist",
|
||||
"disabled": "Disabled"
|
||||
},
|
||||
"auto_save": {
|
||||
"blacklist": "Blacklist",
|
||||
"whitelist": "Whitelist",
|
||||
@@ -3010,7 +3171,8 @@
|
||||
"NOTE": "Audio Note",
|
||||
"SNAP": "Snap",
|
||||
"SAVEABLE_SNAP": "Saveable Snap",
|
||||
"null": "Snapchat Default"
|
||||
"null": "Snapchat Default",
|
||||
"multiple_media_toast": "You can only send one media at a time"
|
||||
},
|
||||
"strip_media_metadata": {
|
||||
"hide_caption_text": "Hide Caption Text",
|
||||
@@ -3057,6 +3219,11 @@
|
||||
"custom_image_upload_format": {
|
||||
"null": "Automatic"
|
||||
},
|
||||
"performance_profile": {
|
||||
"smooth": "Smooth",
|
||||
"max": "Max",
|
||||
"null": "Disabled"
|
||||
},
|
||||
"update_check_frequency": {
|
||||
"daily": "Daily",
|
||||
"weekly": "Weekly",
|
||||
@@ -3137,7 +3304,10 @@
|
||||
"platform_indicator": "Adds the platform icon from which a media was sent (e.g. Android, iOS, Web)",
|
||||
"location_indicator": "Adds a \ud83d\udccd icon to snaps when they have been sent with location enabled",
|
||||
"ovf_editor_indicator": "Indicates if a snap has been sent using OVF Editor",
|
||||
"director_mode_indicator": "Adds a \u270f\ufe0f icon to snaps when they have been sent using Director Mode, which can be used to send gallery images as snaps"
|
||||
"director_mode_indicator": "Adds a \u270f\ufe0f icon to snaps when they have been sent using Director Mode, which can be used to send gallery images as snaps",
|
||||
"memories_indicator": "Adds a \ud83d\udcd6 icon to snaps that were re-sent from Memories instead of being captured with the live camera",
|
||||
"skip_own_indicators": "Hides indicator icons on your own sent snaps (Self-Snaps) \ud83d\udc64",
|
||||
"disable_indicators_in_groups": "Disables all indicator icons in group conversations to reduce UI clutter \ud83d\udc65"
|
||||
},
|
||||
"auto_mark_as_read": {
|
||||
"conversation_read": "Mark conversation as read when sending a message",
|
||||
@@ -3539,6 +3709,7 @@
|
||||
"cancel": "Cancel",
|
||||
"copy": "Copy",
|
||||
"save": "Save",
|
||||
"set": "Set",
|
||||
"open": "Open",
|
||||
"download": "Download",
|
||||
"import": "Import",
|
||||
@@ -3650,17 +3821,19 @@
|
||||
"failed_gallery_toast": "Failed saving to gallery {error}",
|
||||
"dash_no_chapter": "No chapter found",
|
||||
"dash_dialog": {
|
||||
"title": "Download dash media",
|
||||
"title": "DASH Download",
|
||||
"download_all": "Download All",
|
||||
"segment_text": "Segment {from} - {to}"
|
||||
"snap_text": "Snap {from} - {to}"
|
||||
},
|
||||
"story_snap_dialog": {
|
||||
"title": "Download story snaps",
|
||||
"download_all": "Download All",
|
||||
"select_all": "Select All",
|
||||
"deselect_all": "Deselect All",
|
||||
"snap_item": "Snap {index} of {total}"
|
||||
},
|
||||
"batch_download_complete_toast": "All snaps downloaded",
|
||||
"batch_progress_toast": "Downloading {current}/{total}",
|
||||
"batch_download_jump_failed_toast": "Could not navigate to next snap. Ensure Story Snap Jump is enabled and the story view is visible."
|
||||
},
|
||||
"streaks_reminder": {
|
||||
@@ -3715,7 +3888,12 @@
|
||||
"export_failed_toast": "Failed to export account. Check logs for more info.",
|
||||
"forced_logout_toast": "Removed account due to forced logout"
|
||||
},
|
||||
"auto_open_snaps": { "title": "Auto Open Snaps", "processed_count": "Opened", "queue_size": "Queue", "action_reset": "Reset Statistics", "priority_title": "Auto Open Snaps (Priority)",
|
||||
"auto_open_snaps": {
|
||||
"title": "Auto Open Snaps",
|
||||
"processed_count": "Opened",
|
||||
"queue_size": "Queue",
|
||||
"action_reset": "Reset Count",
|
||||
"priority_title": "Auto Open Snaps (Priority)",
|
||||
"auto_open_schedule": {
|
||||
"title": "Auto Open Scheduler",
|
||||
"start": "Start",
|
||||
@@ -3732,7 +3910,6 @@
|
||||
"action_pause": "Pause",
|
||||
"action_resume": "Resume",
|
||||
"action_clear": "Clear Queue",
|
||||
"action_reset": "Reset Count",
|
||||
"error_content": "Failed to open snap from {sender}: {error}",
|
||||
"resumed_feedback": "Auto Open Resumed",
|
||||
"paused_feedback": "Auto Open Paused",
|
||||
@@ -3740,6 +3917,7 @@
|
||||
"paused_message": "Processing paused. Queue preserved ({count} snaps)",
|
||||
"status_paused": "Paused",
|
||||
"status_monitoring": "Monitoring",
|
||||
"thermal_status_title": "Thermal Cooling (Throttled)",
|
||||
"status_active": "Active",
|
||||
"status_failed": "Failed to open {sender}",
|
||||
"status_retrying": "Retrying in background...",
|
||||
@@ -3748,9 +3926,9 @@
|
||||
"speed_throttled": "Throttled",
|
||||
"estimated_time": "Estimated Time",
|
||||
"notification_statistics": "STATISTICS",
|
||||
"notification_total_opened": "Lifetime Opened",
|
||||
"notification_total_opened": "Total Snaps Opened",
|
||||
"notification_queue_preview": "QUEUE PREVIEW",
|
||||
"notification_no_snaps_queue": "Monitoring snaps in background...",
|
||||
"notification_no_snaps_queue": "No snaps in queue.",
|
||||
"queue_cleared": "Queue cleared and statistics reset",
|
||||
"queue_cleared_title": "Queue cleared",
|
||||
"queue_cleared_reset": "Queue Cleared & Reset",
|
||||
@@ -3765,12 +3943,8 @@
|
||||
"conversation_type_group_chat": "Group Chat",
|
||||
"conversation_type_chat": "Chat",
|
||||
"notification_status": "Status",
|
||||
"notification_statistics": "STATISTICS",
|
||||
"notification_queue_size": "Queue Size",
|
||||
"notification_total_opened": "Total Snaps Opened",
|
||||
"notification_queue_preview": "QUEUE PREVIEW",
|
||||
"notification_processing_continue": "Processing will continue automatically...",
|
||||
"notification_no_snaps_queue": "No snaps in queue.",
|
||||
"notification_queue_cleared_opened": "Queue cleared ({opened} opened)",
|
||||
"content_type_photo_video_snap": "Photo/Video Snap",
|
||||
"conversation_type_group_with_name": "Group: {name}",
|
||||
@@ -3858,7 +4032,6 @@
|
||||
"material3_strings": {
|
||||
"date_range_picker_start_headline": "From",
|
||||
"date_range_picker_end_headline": "To",
|
||||
"date_range_picker_title": "Select date range",
|
||||
"date_picker_switch_to_calendar_mode": "Calendar",
|
||||
"date_picker_switch_to_input_mode": "Input",
|
||||
"date_range_picker_scroll_to_previous_month": "Previous month",
|
||||
@@ -3899,7 +4072,7 @@
|
||||
"username": "Username",
|
||||
"user_id": "User ID",
|
||||
"posted_on": "Posted",
|
||||
"loading_username": "Loading…",
|
||||
"loading_username": "Loading\u2026",
|
||||
"username_copied": "Username copied",
|
||||
"user_id_copied": "User ID copied",
|
||||
"friend_status": "Friend status",
|
||||
@@ -3984,10 +4157,10 @@
|
||||
"search": {
|
||||
"placeholder": "Search"
|
||||
},
|
||||
"filters": {
|
||||
"newest_first": "Newest first",
|
||||
"pick_a_date": "Pick a date",
|
||||
"title": "Filters",
|
||||
"filters": {
|
||||
"newest_first": "Newest first",
|
||||
"pick_a_date": "Pick a date",
|
||||
"title": "Filters",
|
||||
"search_by": "Search by",
|
||||
"since": "Since",
|
||||
"until": "Until",
|
||||
@@ -4194,8 +4367,6 @@
|
||||
"added": "Added",
|
||||
"no_friends_found": "No friends found",
|
||||
"no_messages": "No messages",
|
||||
"message": "Message",
|
||||
"type_message": "Type message...",
|
||||
"exporting_memories": "Exporting memories... ({failed} failed)"
|
||||
},
|
||||
"clear_friend_feed": "Clear Friend Feed",
|
||||
@@ -4321,8 +4492,7 @@
|
||||
"deepseek": "DeepSeek",
|
||||
"openai": "OpenAI",
|
||||
"openrouter": "OpenRouter"
|
||||
}
|
||||
,
|
||||
},
|
||||
"tasks_no_tasks": "No tasks",
|
||||
"tasks_no_active_tasks": "No active tasks",
|
||||
"tasks_no_scheduled_tasks": "No scheduled snaps",
|
||||
@@ -4331,8 +4501,8 @@
|
||||
"tasks_clear_button_description": "Clear tasks",
|
||||
"tasks_delete_button": "Delete",
|
||||
"tasks_merge_button": "Merge",
|
||||
"tasks_summary_active": "{active} active · {recent} recent",
|
||||
"tasks_summary_idle": "Idle · {recent} recent",
|
||||
"tasks_summary_active": "{active} active \u2022 {recent} recent",
|
||||
"tasks_summary_idle": "Idle \u2022 {recent} recent",
|
||||
"tasks_running_count": "{count} running",
|
||||
"tasks_tagline": "Monitor and manage background actions",
|
||||
"tasks_failed_to_open_file": "Failed to open file",
|
||||
@@ -4341,11 +4511,4 @@
|
||||
"tasks_remove_all_tasks_title": "Are you sure you want to remove all tasks?",
|
||||
"tasks_remove_selected_tasks_confirm": "Remove {count} selected tasks?",
|
||||
"tasks_remove_all_tasks_confirm": "This will stop all running tasks and clear the history."
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
}
|
||||
@@ -2544,7 +2544,10 @@
|
||||
"platform_indicator": "वह प्लेटफ़ॉर्म आइकन जोड़ता है जहाँ से मीडिया भेजा गया था (उदा. Android, iOS, Web)",
|
||||
"location_indicator": "Snaps में \ud83d\udccd आइकन जोड़ता है जब उन्हें लोकेशन सक्षम के साथ भेजा गया हो",
|
||||
"ovf_editor_indicator": "इंगित करता है कि क्या कोई Snap OVF एडिटर का उपयोग करके भेजा गया है",
|
||||
"director_mode_indicator": "Snaps में \u270f\ufe0f आइकन जोड़ता है जब उन्हें डायरेक्टर मोड का उपयोग करके भेजा गया हो, जिसका उपयोग गैलरी छवियों को Snaps के रूप में भेजने के लिए किया जा सकता है"
|
||||
"director_mode_indicator": "Snaps में \u270f\ufe0f आइकन जोड़ता है जब उन्हें डायरेक्टर मोड का उपयोग करके भेजा गया हो, जिसका उपयोग गैलरी छवियों को Snaps के रूप में भेजने के लिए किया जा सकता है",
|
||||
"memories_indicator": "मेमोरीज़ से फिर से भेजे गए Snaps में \ud83d\udcd6 आइकन जोड़ता है",
|
||||
"skip_own_indicators": "अपने स्वयं के भेजे गए Snaps पर संकेतक आइकन छुपाता है \ud83d\udc64",
|
||||
"disable_indicators_in_groups": "UI अव्यवस्था को कम करने के लिए समूह वार्तालापों में सभी संकेतकों को अक्षम करता है \ud83d\udc65"
|
||||
},
|
||||
"auto_mark_as_read": {
|
||||
"conversation_read": "संदेश भेजते समय वार्तालाप को पढ़े गए के रूप में चिह्नित करें",
|
||||
|
||||
@@ -2,6 +2,7 @@ package me.eternal.purrfectsnap.common.bridge.wrapper
|
||||
|
||||
import android.content.ContentValues
|
||||
import android.content.Context
|
||||
import android.database.Cursor
|
||||
import android.database.sqlite.SQLiteDatabase
|
||||
import android.net.Uri
|
||||
import com.google.gson.GsonBuilder
|
||||
@@ -20,6 +21,7 @@ import me.eternal.purrfectsnap.common.util.ktx.getLongOrNull
|
||||
import me.eternal.purrfectsnap.common.util.ktx.getStringOrNull
|
||||
import me.eternal.purrfectsnap.common.util.protobuf.ProtoReader
|
||||
import java.io.File
|
||||
import java.io.InputStream
|
||||
import java.util.UUID
|
||||
|
||||
class LoggedMessage(
|
||||
@@ -70,10 +72,74 @@ data class TrackerLog(
|
||||
}
|
||||
}
|
||||
|
||||
data class LoggerConversationExportTarget(
|
||||
val conversationId: String,
|
||||
val groupTitle: String?,
|
||||
val usernames: List<String>,
|
||||
val userIds: List<String>,
|
||||
val messageCount: Int
|
||||
)
|
||||
|
||||
data class ConversationExportResult(
|
||||
val messageCount: Int,
|
||||
val chatEditCount: Int,
|
||||
val trackerEventCount: Int
|
||||
)
|
||||
|
||||
data class DatabaseImportResult(
|
||||
val messageCount: Int,
|
||||
val storyCount: Int
|
||||
)
|
||||
|
||||
class LoggerWrapper(
|
||||
val databaseFile: File,
|
||||
private val readOnly: Boolean = false
|
||||
): LoggerInterface.Stub() {
|
||||
companion object {
|
||||
private val MESSAGE_LOGGER_SCHEMA = mapOf(
|
||||
"messages" to listOf(
|
||||
"id INTEGER PRIMARY KEY",
|
||||
"message_id BIGINT",
|
||||
"conversation_id VARCHAR",
|
||||
"user_id CHAR(36)",
|
||||
"username VARCHAR",
|
||||
"send_timestamp BIGINT",
|
||||
"added_timestamp BIGINT",
|
||||
"group_title VARCHAR",
|
||||
"message_data BLOB"
|
||||
),
|
||||
"chat_edits" to listOf(
|
||||
"id INTEGER PRIMARY KEY",
|
||||
"edit_number INTEGER",
|
||||
"added_timestamp BIGINT",
|
||||
"conversation_id VARCHAR",
|
||||
"message_id BIGINT",
|
||||
"message_text BLOB"
|
||||
),
|
||||
"stories" to listOf(
|
||||
"id INTEGER PRIMARY KEY",
|
||||
"added_timestamp BIGINT",
|
||||
"user_id VARCHAR",
|
||||
"posted_timestamp BIGINT",
|
||||
"created_timestamp BIGINT",
|
||||
"url VARCHAR",
|
||||
"encryption_key BLOB",
|
||||
"encryption_iv BLOB"
|
||||
),
|
||||
"tracker_events" to listOf(
|
||||
"id INTEGER PRIMARY KEY",
|
||||
"timestamp BIGINT",
|
||||
"conversation_id CHAR(36)",
|
||||
"conversation_title VARCHAR",
|
||||
"is_group BOOLEAN",
|
||||
"username VARCHAR",
|
||||
"user_id VARCHAR",
|
||||
"event_type VARCHAR",
|
||||
"data VARCHAR"
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
constructor(context: Context, uri: Uri? = null): this(
|
||||
uri?.path?.let { File(it) } ?: File(context.getDatabasePath(InternalFileHandleType.MESSAGE_LOGGER.fileName).absolutePath),
|
||||
uri != null
|
||||
@@ -84,68 +150,190 @@ class LoggerWrapper(
|
||||
private val coroutineScope = CoroutineScope(Dispatchers.IO.limitedParallelism(1))
|
||||
private val gson by lazy { GsonBuilder().create() }
|
||||
|
||||
private fun openDatabase(file: File, readOnly: Boolean): SQLiteDatabase {
|
||||
val dbFlags = if (readOnly) {
|
||||
SQLiteDatabase.OPEN_READONLY
|
||||
} else {
|
||||
SQLiteDatabase.CREATE_IF_NECESSARY or SQLiteDatabase.OPEN_READWRITE
|
||||
}
|
||||
return SQLiteDatabase.openDatabase(file.absolutePath, null, dbFlags).also { openedDatabase ->
|
||||
if (!readOnly) {
|
||||
SQLiteDatabaseHelper.createTablesFromSchema(openedDatabase, MESSAGE_LOGGER_SCHEMA)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun closeDatabaseLocked() {
|
||||
_database?.takeIf { it.isOpen }?.close()
|
||||
_database = null
|
||||
}
|
||||
|
||||
private val database get() = synchronized(this) {
|
||||
_database?.takeIf { it.isOpen } ?: run {
|
||||
_database?.close()
|
||||
val dbFlags = if (readOnly) SQLiteDatabase.OPEN_READONLY else SQLiteDatabase.CREATE_IF_NECESSARY or SQLiteDatabase.OPEN_READWRITE
|
||||
val openedDatabase = SQLiteDatabase.openDatabase(databaseFile.absolutePath, null, dbFlags)
|
||||
if (!readOnly) {
|
||||
SQLiteDatabaseHelper.createTablesFromSchema(openedDatabase, mapOf(
|
||||
"messages" to listOf(
|
||||
"id INTEGER PRIMARY KEY",
|
||||
"message_id BIGINT",
|
||||
"conversation_id VARCHAR",
|
||||
"user_id CHAR(36)",
|
||||
"username VARCHAR",
|
||||
"send_timestamp BIGINT",
|
||||
"added_timestamp BIGINT",
|
||||
"group_title VARCHAR",
|
||||
"message_data BLOB"
|
||||
),
|
||||
"chat_edits" to listOf(
|
||||
"id INTEGER PRIMARY KEY",
|
||||
"edit_number INTEGER",
|
||||
"added_timestamp BIGINT",
|
||||
"conversation_id VARCHAR",
|
||||
"message_id BIGINT",
|
||||
"message_text BLOB"
|
||||
),
|
||||
"stories" to listOf(
|
||||
"id INTEGER PRIMARY KEY",
|
||||
"added_timestamp BIGINT",
|
||||
"user_id VARCHAR",
|
||||
"posted_timestamp BIGINT",
|
||||
"created_timestamp BIGINT",
|
||||
"url VARCHAR",
|
||||
"encryption_key BLOB",
|
||||
"encryption_iv BLOB"
|
||||
),
|
||||
"tracker_events" to listOf(
|
||||
"id INTEGER PRIMARY KEY",
|
||||
"timestamp BIGINT",
|
||||
"conversation_id CHAR(36)",
|
||||
"conversation_title VARCHAR",
|
||||
"is_group BOOLEAN",
|
||||
"username VARCHAR",
|
||||
"user_id VARCHAR",
|
||||
"event_type VARCHAR",
|
||||
"data VARCHAR"
|
||||
)
|
||||
))
|
||||
}
|
||||
closeDatabaseLocked()
|
||||
val openedDatabase = openDatabase(databaseFile, readOnly)
|
||||
_database = openedDatabase
|
||||
openedDatabase
|
||||
}
|
||||
}
|
||||
|
||||
protected fun finalize() {
|
||||
_database?.close()
|
||||
synchronized(this) {
|
||||
closeDatabaseLocked()
|
||||
}
|
||||
}
|
||||
|
||||
fun init() {
|
||||
|
||||
}
|
||||
|
||||
private fun resolveDatabaseSidecars(file: File): List<File> {
|
||||
return listOf(
|
||||
file,
|
||||
File("${file.absolutePath}-wal"),
|
||||
File("${file.absolutePath}-shm"),
|
||||
File("${file.absolutePath}-journal")
|
||||
)
|
||||
}
|
||||
|
||||
private fun deleteIfExists(file: File) {
|
||||
if (file.exists() && !file.delete()) {
|
||||
throw IllegalStateException("Failed to delete ${file.name}")
|
||||
}
|
||||
}
|
||||
|
||||
private fun replaceFile(source: File, target: File) {
|
||||
if (!source.exists()) {
|
||||
throw IllegalStateException("Missing source file ${source.name}")
|
||||
}
|
||||
if (source.renameTo(target)) return
|
||||
source.inputStream().use { input ->
|
||||
target.outputStream().use { output ->
|
||||
input.copyTo(output)
|
||||
}
|
||||
}
|
||||
if (!source.delete()) {
|
||||
throw IllegalStateException("Failed to delete temporary file ${source.name}")
|
||||
}
|
||||
}
|
||||
|
||||
private fun requireCompatibleSchema(db: SQLiteDatabase) {
|
||||
MESSAGE_LOGGER_SCHEMA.forEach { (tableName, columns) ->
|
||||
val existingColumns = mutableListOf<String>()
|
||||
db.rawQuery("PRAGMA table_info($tableName)", null).use { cursor ->
|
||||
while (cursor.moveToNext()) {
|
||||
val columnName = cursor.getStringOrNull("name") ?: continue
|
||||
val columnType = cursor.getStringOrNull("type") ?: ""
|
||||
existingColumns.add("$columnName $columnType".trim())
|
||||
}
|
||||
}
|
||||
if (existingColumns.isEmpty()) {
|
||||
throw IllegalStateException("Selected database is missing required table $tableName")
|
||||
}
|
||||
|
||||
val missingColumns = columns.filter { expectedColumn ->
|
||||
!expectedColumn.uppercase().startsWith("PRIMARY KEY") &&
|
||||
existingColumns.none { existingColumn -> expectedColumn.startsWith(existingColumn) }
|
||||
}
|
||||
if (missingColumns.isNotEmpty()) {
|
||||
throw IllegalStateException(
|
||||
"Selected database has incompatible schema for $tableName: ${missingColumns.joinToString()}"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun requireIntegrity(db: SQLiteDatabase) {
|
||||
val integrityResult = db.rawQuery("PRAGMA integrity_check(1)", null).use { cursor ->
|
||||
if (!cursor.moveToFirst()) null else cursor.getString(0)
|
||||
}
|
||||
if (integrityResult == null || !integrityResult.equals("ok", ignoreCase = true)) {
|
||||
throw IllegalStateException("Selected database failed integrity check: ${integrityResult ?: "unknown"}")
|
||||
}
|
||||
}
|
||||
|
||||
private fun getTableCount(db: SQLiteDatabase, tableName: String): Int {
|
||||
return db.rawQuery("SELECT COUNT(*) FROM $tableName", null).use { cursor ->
|
||||
if (!cursor.moveToFirst()) 0 else cursor.getInt(0)
|
||||
}
|
||||
}
|
||||
|
||||
fun importDatabase(inputStream: InputStream): DatabaseImportResult {
|
||||
if (readOnly) {
|
||||
throw IllegalStateException("Cannot import into read-only logger")
|
||||
}
|
||||
|
||||
val databaseDir = databaseFile.parentFile
|
||||
?: throw IllegalStateException("Cannot resolve message logger database directory")
|
||||
if (!databaseDir.exists() && !databaseDir.mkdirs()) {
|
||||
throw IllegalStateException("Failed to create message logger directory")
|
||||
}
|
||||
|
||||
val tempFile = File(
|
||||
databaseDir,
|
||||
"${databaseFile.name}.import-${System.currentTimeMillis()}-${UUID.randomUUID()}"
|
||||
)
|
||||
val backupFile = File(
|
||||
databaseDir,
|
||||
"${databaseFile.name}.backup-${System.currentTimeMillis()}-${UUID.randomUUID()}"
|
||||
)
|
||||
|
||||
try {
|
||||
inputStream.use { input ->
|
||||
tempFile.outputStream().use { output ->
|
||||
val copiedBytes = input.copyTo(output)
|
||||
if (copiedBytes <= 0L) {
|
||||
throw IllegalStateException("Selected backup is empty")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
openDatabase(tempFile, readOnly = true).use { importedDatabase ->
|
||||
requireIntegrity(importedDatabase)
|
||||
requireCompatibleSchema(importedDatabase)
|
||||
}
|
||||
|
||||
synchronized(this) {
|
||||
closeDatabaseLocked()
|
||||
val hadExistingDatabase = databaseFile.exists()
|
||||
if (hadExistingDatabase) {
|
||||
databaseFile.inputStream().use { input ->
|
||||
backupFile.outputStream().use { output ->
|
||||
input.copyTo(output)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
resolveDatabaseSidecars(databaseFile).forEach(::deleteIfExists)
|
||||
replaceFile(tempFile, databaseFile)
|
||||
resolveDatabaseSidecars(databaseFile).filter { it != databaseFile }.forEach(::deleteIfExists)
|
||||
|
||||
val reopenedDatabase = openDatabase(databaseFile, readOnly = false)
|
||||
val importResult = DatabaseImportResult(
|
||||
messageCount = getTableCount(reopenedDatabase, "messages"),
|
||||
storyCount = getTableCount(reopenedDatabase, "stories")
|
||||
)
|
||||
_database = reopenedDatabase
|
||||
deleteIfExists(backupFile)
|
||||
return importResult
|
||||
} catch (throwable: Throwable) {
|
||||
runCatching {
|
||||
resolveDatabaseSidecars(databaseFile).forEach(::deleteIfExists)
|
||||
if (backupFile.exists()) {
|
||||
replaceFile(backupFile, databaseFile)
|
||||
}
|
||||
}
|
||||
closeDatabaseLocked()
|
||||
throw throwable
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
runCatching { deleteIfExists(tempFile) }
|
||||
runCatching { deleteIfExists(backupFile) }
|
||||
}
|
||||
}
|
||||
|
||||
override fun getLoggedIds(conversationId: Array<String>, limit: Int): LongArray {
|
||||
if (conversationId.any {
|
||||
runCatching { UUID.fromString(it) }.isFailure
|
||||
@@ -425,6 +613,223 @@ class LoggerWrapper(
|
||||
return ConversationInfo(conversationId, usernames.size, groupTitle, usernames)
|
||||
}
|
||||
|
||||
fun getConversationExportTargets(): List<LoggerConversationExportTarget> {
|
||||
val groupedConversations = mutableListOf<Triple<String, String?, Int>>()
|
||||
database.rawQuery(
|
||||
"SELECT conversation_id, MAX(group_title) AS group_title, COUNT(*) AS message_count, MAX(send_timestamp) AS last_timestamp " +
|
||||
"FROM messages WHERE conversation_id IS NOT NULL AND TRIM(conversation_id) != '' " +
|
||||
"GROUP BY conversation_id ORDER BY last_timestamp DESC",
|
||||
null
|
||||
).use { cursor ->
|
||||
while (cursor.moveToNext()) {
|
||||
val conversationId = cursor.getStringOrNull("conversation_id")?.takeIf { it.isNotBlank() } ?: continue
|
||||
groupedConversations.add(
|
||||
Triple(
|
||||
conversationId,
|
||||
cursor.getStringOrNull("group_title"),
|
||||
cursor.getIntOrNull("message_count") ?: 0
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return groupedConversations.map { (conversationId, groupTitle, messageCount) ->
|
||||
val userIds = linkedSetOf<String>()
|
||||
val usernames = linkedSetOf<String>()
|
||||
database.rawQuery(
|
||||
"SELECT DISTINCT user_id, username FROM messages WHERE conversation_id = ?",
|
||||
arrayOf(conversationId)
|
||||
).use { cursor ->
|
||||
while (cursor.moveToNext()) {
|
||||
cursor.getStringOrNull("user_id")?.takeIf { it.isNotBlank() }?.let { userIds.add(it) }
|
||||
cursor.getStringOrNull("username")?.takeIf { it.isNotBlank() }?.let { usernames.add(it) }
|
||||
}
|
||||
}
|
||||
LoggerConversationExportTarget(
|
||||
conversationId = conversationId,
|
||||
groupTitle = groupTitle,
|
||||
usernames = usernames.toList(),
|
||||
userIds = userIds.toList(),
|
||||
messageCount = messageCount
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun exportConversationDatabase(
|
||||
outputFile: File,
|
||||
conversationId: String,
|
||||
userIds: Collection<String> = emptyList()
|
||||
): ConversationExportResult {
|
||||
val normalizedConversationId = conversationId.trim().takeIf { it.isNotEmpty() }
|
||||
?: throw IllegalArgumentException("Conversation ID cannot be empty")
|
||||
val normalizedUserIds = userIds
|
||||
.mapNotNull { it.trim().takeIf(String::isNotEmpty) }
|
||||
.toSet()
|
||||
.toMutableSet()
|
||||
.also { ids ->
|
||||
if (ids.isEmpty()) {
|
||||
database.rawQuery(
|
||||
"SELECT DISTINCT user_id FROM messages WHERE conversation_id = ? AND user_id IS NOT NULL AND TRIM(user_id) != ''",
|
||||
arrayOf(normalizedConversationId)
|
||||
).use { cursor ->
|
||||
while (cursor.moveToNext()) {
|
||||
cursor.getStringOrNull("user_id")?.takeIf { it.isNotBlank() }?.let { ids.add(it) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
outputFile.parentFile?.mkdirs()
|
||||
if (outputFile.exists() && !outputFile.delete()) {
|
||||
throw IllegalStateException("Failed to prepare export file")
|
||||
}
|
||||
|
||||
val outputDatabase = SQLiteDatabase.openDatabase(
|
||||
outputFile.absolutePath,
|
||||
null,
|
||||
SQLiteDatabase.CREATE_IF_NECESSARY or SQLiteDatabase.OPEN_READWRITE
|
||||
)
|
||||
var transactionStarted = false
|
||||
try {
|
||||
SQLiteDatabaseHelper.createTablesFromSchema(outputDatabase, MESSAGE_LOGGER_SCHEMA)
|
||||
outputDatabase.beginTransaction()
|
||||
transactionStarted = true
|
||||
|
||||
val messageWhereClause = buildString {
|
||||
append("conversation_id = ?")
|
||||
if (normalizedUserIds.isNotEmpty()) {
|
||||
append(" AND user_id IN (${normalizedUserIds.joinToString(",") { "?" }})")
|
||||
}
|
||||
}
|
||||
val messageWhereArgs = mutableListOf(normalizedConversationId).apply {
|
||||
addAll(normalizedUserIds)
|
||||
}.toTypedArray()
|
||||
|
||||
val messageCount = copyQueryRows(
|
||||
sourceQuery = "SELECT * FROM messages WHERE $messageWhereClause ORDER BY send_timestamp ASC",
|
||||
sourceArgs = messageWhereArgs,
|
||||
targetDatabase = outputDatabase,
|
||||
targetTable = "messages"
|
||||
)
|
||||
|
||||
val chatEditCount = copyQueryRows(
|
||||
sourceQuery = "SELECT * FROM chat_edits WHERE conversation_id = ? AND message_id IN (SELECT message_id FROM messages WHERE $messageWhereClause) ORDER BY added_timestamp ASC",
|
||||
sourceArgs = arrayOf(normalizedConversationId, *messageWhereArgs),
|
||||
targetDatabase = outputDatabase,
|
||||
targetTable = "chat_edits"
|
||||
)
|
||||
|
||||
val trackerWhereClause = buildString {
|
||||
append("conversation_id = ?")
|
||||
if (normalizedUserIds.isNotEmpty()) {
|
||||
append(" AND user_id IN (${normalizedUserIds.joinToString(",") { "?" }})")
|
||||
}
|
||||
}
|
||||
val trackerArgs = mutableListOf(normalizedConversationId).apply {
|
||||
addAll(normalizedUserIds)
|
||||
}.toTypedArray()
|
||||
val trackerEventCount = copyQueryRows(
|
||||
sourceQuery = "SELECT * FROM tracker_events WHERE $trackerWhereClause ORDER BY timestamp ASC",
|
||||
sourceArgs = trackerArgs,
|
||||
targetDatabase = outputDatabase,
|
||||
targetTable = "tracker_events"
|
||||
)
|
||||
|
||||
outputDatabase.setTransactionSuccessful()
|
||||
return ConversationExportResult(
|
||||
messageCount = messageCount,
|
||||
chatEditCount = chatEditCount,
|
||||
trackerEventCount = trackerEventCount
|
||||
)
|
||||
} finally {
|
||||
if (transactionStarted) {
|
||||
outputDatabase.endTransaction()
|
||||
}
|
||||
outputDatabase.close()
|
||||
}
|
||||
}
|
||||
|
||||
private fun cursorToContentValues(cursor: Cursor): ContentValues {
|
||||
return ContentValues(cursor.columnCount).apply {
|
||||
for (columnIndex in 0 until cursor.columnCount) {
|
||||
val columnName = cursor.getColumnName(columnIndex)
|
||||
when (cursor.getType(columnIndex)) {
|
||||
Cursor.FIELD_TYPE_NULL -> putNull(columnName)
|
||||
Cursor.FIELD_TYPE_INTEGER -> put(columnName, cursor.getLong(columnIndex))
|
||||
Cursor.FIELD_TYPE_FLOAT -> put(columnName, cursor.getDouble(columnIndex))
|
||||
Cursor.FIELD_TYPE_STRING -> put(columnName, cursor.getString(columnIndex))
|
||||
Cursor.FIELD_TYPE_BLOB -> put(columnName, cursor.getBlob(columnIndex))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun copyQueryRows(
|
||||
sourceQuery: String,
|
||||
sourceArgs: Array<String>? = null,
|
||||
targetDatabase: SQLiteDatabase,
|
||||
targetTable: String
|
||||
): Int {
|
||||
var rowCount = 0
|
||||
database.rawQuery(sourceQuery, sourceArgs).use { cursor ->
|
||||
while (cursor.moveToNext()) {
|
||||
targetDatabase.insert(targetTable, null, cursorToContentValues(cursor))
|
||||
rowCount++
|
||||
}
|
||||
}
|
||||
return rowCount
|
||||
}
|
||||
|
||||
private fun cursorToLoggedMessage(cursor: Cursor): LoggedMessage? {
|
||||
return LoggedMessage(
|
||||
messageId = cursor.getLongOrNull("message_id") ?: return null,
|
||||
conversationId = cursor.getStringOrNull("conversation_id") ?: return null,
|
||||
userId = cursor.getStringOrNull("user_id") ?: return null,
|
||||
username = cursor.getStringOrNull("username") ?: return null,
|
||||
sendTimestamp = cursor.getLongOrNull("send_timestamp") ?: return null,
|
||||
addedTimestamp = cursor.getLongOrNull("added_timestamp") ?: return null,
|
||||
groupTitle = cursor.getStringOrNull("group_title"),
|
||||
messageData = cursor.getBlobOrNull("message_data") ?: return null
|
||||
)
|
||||
}
|
||||
|
||||
fun forEachConversationMessage(
|
||||
conversationId: String,
|
||||
userIds: Collection<String> = emptyList(),
|
||||
orderAscending: Boolean = true,
|
||||
block: (LoggedMessage) -> Unit
|
||||
): Int {
|
||||
val normalizedConversationId = conversationId.trim().takeIf { it.isNotEmpty() }
|
||||
?: throw IllegalArgumentException("Conversation ID cannot be empty")
|
||||
val normalizedUserIds = userIds
|
||||
.mapNotNull { it.trim().takeIf(String::isNotEmpty) }
|
||||
.toSet()
|
||||
|
||||
val whereClause = buildString {
|
||||
append("conversation_id = ?")
|
||||
if (normalizedUserIds.isNotEmpty()) {
|
||||
append(" AND user_id IN (${normalizedUserIds.joinToString(",") { "?" }})")
|
||||
}
|
||||
}
|
||||
val whereArgs = mutableListOf(normalizedConversationId).apply {
|
||||
addAll(normalizedUserIds)
|
||||
}.toTypedArray()
|
||||
|
||||
var total = 0
|
||||
database.rawQuery(
|
||||
"SELECT * FROM messages WHERE $whereClause ORDER BY send_timestamp ${if (orderAscending) "ASC" else "DESC"}",
|
||||
whereArgs
|
||||
).use { cursor ->
|
||||
while (cursor.moveToNext()) {
|
||||
cursorToLoggedMessage(cursor)?.let { loggedMessage ->
|
||||
block(loggedMessage)
|
||||
total++
|
||||
}
|
||||
}
|
||||
}
|
||||
return total
|
||||
}
|
||||
|
||||
override fun getChatEdits(conversationId: String, messageId: Long): List<LoggedChatEdit> {
|
||||
val edits = mutableListOf<LoggedChatEdit>()
|
||||
database.rawQuery(
|
||||
@@ -483,16 +888,7 @@ class LoggerWrapper(
|
||||
arrayOf(conversationId, fromTimestamp.toString())
|
||||
).use {
|
||||
while (it.moveToNext() && messages.size < limit) {
|
||||
val message = LoggedMessage(
|
||||
messageId = it.getLongOrNull("message_id") ?: continue,
|
||||
conversationId = it.getStringOrNull("conversation_id") ?: continue,
|
||||
userId = it.getStringOrNull("user_id") ?: continue,
|
||||
username = it.getStringOrNull("username") ?: continue,
|
||||
sendTimestamp = it.getLongOrNull("send_timestamp") ?: continue,
|
||||
addedTimestamp = it.getLongOrNull("added_timestamp") ?: continue,
|
||||
groupTitle = it.getStringOrNull("group_title"),
|
||||
messageData = it.getBlobOrNull("message_data") ?: continue
|
||||
)
|
||||
val message = cursorToLoggedMessage(it) ?: continue
|
||||
if (filter != null && !filter(message)) continue
|
||||
messages.add(message)
|
||||
}
|
||||
|
||||
@@ -81,6 +81,7 @@ class ConfigParams(
|
||||
var inputCheck: ((String) -> Boolean)? = { true },
|
||||
var filenameFilter: ((String) -> Boolean)? = null,
|
||||
var versionCheck: VersionCheck? = null,
|
||||
var digitsOnlyInput: Boolean = false,
|
||||
) {
|
||||
val notices get() = _notices?.let { FeatureNotice.entries.filter { flag -> it and flag.id != 0 } } ?: emptyList()
|
||||
val flags get() = _flags?.let { ConfigFlag.entries.filter { flag -> it and flag.id != 0 } } ?: emptyList()
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
package me.eternal.purrfectsnap.common.config.impl
|
||||
|
||||
import me.eternal.purrfectsnap.common.config.ConfigContainer
|
||||
import me.eternal.purrfectsnap.common.config.ConfigFlag
|
||||
|
||||
class CompanionServerConfig : ConfigContainer() {
|
||||
val enabled = boolean("enabled", false) { requireRestart() }
|
||||
val port = integer("port", 8484)
|
||||
val token = string("token", "") { addFlags(ConfigFlag.SENSITIVE); requireRestart() }
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package me.eternal.purrfectsnap.common.config.impl
|
||||
|
||||
import me.eternal.purrfectsnap.common.config.ConfigContainer
|
||||
import me.eternal.purrfectsnap.common.config.ConfigFlag
|
||||
|
||||
class DeveloperConfig : ConfigContainer() {
|
||||
inner class DebugStoriesConfig : ConfigContainer(hasGlobalState = true) {
|
||||
val enabled = boolean("enabled", false)
|
||||
val saveFolder = string("save_folder") { addFlags(ConfigFlag.FOLDER, ConfigFlag.SENSITIVE) }
|
||||
}
|
||||
|
||||
val debugStories = container("debug_stories", DebugStoriesConfig())
|
||||
}
|
||||
@@ -9,9 +9,9 @@ class DownloaderConfig : ConfigContainer() {
|
||||
val threads = integer("threads", 4) // Bump Default Value to 4 Tested on Pixel 5 (Qualcomm Snapdragon 765G) Had no lag
|
||||
val preset = unique("preset", "ultrafast", "superfast", "veryfast", "faster", "fast", "medium", "slow", "slower", "veryslow") {
|
||||
addFlags(ConfigFlag.NO_TRANSLATE)
|
||||
}
|
||||
val constantRateFactor = integer("constant_rate_factor", 30)
|
||||
val videoBitrate = integer("video_bitrate", 5000)
|
||||
}.apply { set("veryfast") }
|
||||
val constantRateFactor = integer("constant_rate_factor", 22)
|
||||
val videoBitrate = integer("video_bitrate", 8000)
|
||||
val audioBitrate = integer("audio_bitrate", 128)
|
||||
val customVideoCodec = string("custom_video_codec") { addFlags(ConfigFlag.NO_TRANSLATE) }
|
||||
val customAudioCodec = string("custom_audio_codec") { addFlags(ConfigFlag.NO_TRANSLATE) }
|
||||
|
||||
@@ -35,6 +35,7 @@ class Experimental : ConfigContainer() {
|
||||
class NativeHooks : ConfigContainer() {
|
||||
val valdiHooks = container("composer_hooks", ValdiHooksConfig()) { requireRestart() }
|
||||
val disableBitmoji = boolean("disable_bitmoji")
|
||||
val debugFontRedirect = boolean("debug_font_redirect") { addFlags(ConfigFlag.HIDDEN) }
|
||||
val customEmojiFont = string("custom_emoji_font") {
|
||||
requireRestart()
|
||||
addFlags(ConfigFlag.USER_IMPORT)
|
||||
|
||||
@@ -3,6 +3,8 @@ package me.eternal.purrfectsnap.common.config.impl
|
||||
import me.eternal.purrfectsnap.common.config.ConfigContainer
|
||||
import me.eternal.purrfectsnap.common.config.ConfigFlag
|
||||
import me.eternal.purrfectsnap.common.config.FeatureNotice
|
||||
import java.time.LocalDate
|
||||
import java.time.format.DateTimeFormatter
|
||||
|
||||
class Global : ConfigContainer() {
|
||||
companion object {
|
||||
@@ -38,9 +40,22 @@ class Global : ConfigContainer() {
|
||||
val customUploadImageFormat = unique("custom_image_upload_format", "jpeg", "png", "webp") { requireRestart(); addFlags(ConfigFlag.NO_TRANSLATE) }
|
||||
}
|
||||
|
||||
inner class PerformanceModeConfig : ConfigContainer() {
|
||||
val profile = unique("performance_profile", "smooth", "max") {
|
||||
requireRestart()
|
||||
}
|
||||
}
|
||||
|
||||
val betterLocation = container("better_location", BetterLocationConfig())
|
||||
val snapchatPlus = unique("snapchat_plus", "not_subscribed", "basic", "ad_free") { requireRestart() }
|
||||
val snapchatPlusPurchaseDate = string("snapchat_plus_purchase_date", "") {
|
||||
requireRestart()
|
||||
inputCheck = {
|
||||
it.isBlank() || runCatching { LocalDate.parse(it, DateTimeFormatter.ISO_LOCAL_DATE) }.isSuccess
|
||||
}
|
||||
}
|
||||
val mediaUploadQualityConfig = container("media_upload_quality", MediaUploadQualityConfig())
|
||||
val performanceMode = container("performance_mode", PerformanceModeConfig()) { requireRestart() }
|
||||
val disableConfirmationDialogs = multiple("disable_confirmation_dialogs", "erase_message", "remove_friend", "block_friend", "ignore_friend", "hide_friend", "hide_conversation", "clear_conversation") { requireRestart() }
|
||||
val disableMetrics = boolean("disable_metrics") { requireRestart() }
|
||||
val disableStorySections = multiple("disable_story_sections", "friends", "suggested_stories", "following", "discover") { requireRestart(); requireCleanCache() }
|
||||
@@ -63,8 +78,6 @@ class Global : ConfigContainer() {
|
||||
|
||||
inner class UpdateSettings : ConfigContainer() {
|
||||
val autoUpdateCheck = boolean("auto_update_check", true)
|
||||
val updateCheckFrequency = unique("update_check_frequency", "daily", "weekly", "monthly")
|
||||
val updateChannel = unique("update_channel", "stable", "prerelease")
|
||||
}
|
||||
|
||||
inner class UISettings : ConfigContainer() {
|
||||
|
||||
@@ -166,23 +166,27 @@ class MessagingTweaks : ConfigContainer() {
|
||||
val maxDelayMs = integer("max_delay_ms", defaultValue = 100) {
|
||||
inputCheck = { it.toIntOrNull()?.coerceAtLeast(0) != null && it.toInt() > minDelay.get() }
|
||||
}
|
||||
val queueSize = integer("queue_size", defaultValue = 10) {
|
||||
val queueSize = integer("queue_size", defaultValue = 700) {
|
||||
inputCheck = { it.toIntOrNull()?.coerceAtLeast(1) != null }
|
||||
}
|
||||
val retryAttempts = integer("retry_attempts", defaultValue = 5) {
|
||||
inputCheck = { it.toIntOrNull()?.coerceAtLeast(1) != null }
|
||||
}
|
||||
val delayBetweenSnaps = integer("delay_between_snaps", defaultValue = 100) {
|
||||
inputCheck = { it.toIntOrNull()?.coerceAtLeast(0) != null }
|
||||
}
|
||||
val delayBetweenConversations = integer("delay_between_conversations", defaultValue = 500) {
|
||||
inputCheck = { it.toIntOrNull()?.coerceAtLeast(0) != null }
|
||||
}
|
||||
val retryDelay = integer("retry_delay", defaultValue = 3000) {
|
||||
inputCheck = { it.toIntOrNull()?.coerceAtLeast(1000) != null }
|
||||
}
|
||||
val compactNotification = boolean("compact_notification", false)
|
||||
val showProgressBar = boolean("show_progress_bar", true)
|
||||
val showLifetimeStats = boolean("show_lifetime_stats", false)
|
||||
val showQueuePreview = boolean("show_queue_preview", true)
|
||||
val thermalProtection = boolean("thermal_protection", false)
|
||||
|
||||
// Resource Intelligence: Smart triggers for battery and data safety
|
||||
val onlyOnWifi = boolean("only_on_wifi", false)
|
||||
val pauseDuringGaming = boolean("pause_during_gaming", false)
|
||||
val safeProcessing = boolean("safe_processing", true)
|
||||
val onlyWhenIdle = boolean("only_when_idle", false)
|
||||
val sleepWindow = string("sleep_window", defaultValue = "23:00-07:00") {
|
||||
@@ -209,6 +213,38 @@ class MessagingTweaks : ConfigContainer() {
|
||||
val showNotification = boolean("show_notification", defaultValue = true)
|
||||
}
|
||||
|
||||
class InstantTranslationConfig : ConfigContainer(hasGlobalState = true) {
|
||||
val enabled = boolean("enabled", false)
|
||||
val sourceLanguage = string("source_language", defaultValue = "auto") {
|
||||
inputCheck = { it.isNotBlank() }
|
||||
}
|
||||
val targetLanguage = string("target_language", defaultValue = "en") {
|
||||
inputCheck = { it.isNotBlank() }
|
||||
}
|
||||
val showOriginal = boolean("show_original", defaultValue = true)
|
||||
val showTranslation = boolean("show_translation", defaultValue = true)
|
||||
val translationPosition = unique("translation_position", "above", "below", "inline") {
|
||||
customOptionTranslationPath = "translation_position"
|
||||
}.apply { set("below") }
|
||||
val autoTranslate = boolean("auto_translate", defaultValue = true)
|
||||
val translateOnTap = boolean("translate_on_tap", defaultValue = false)
|
||||
val pauseOnError = boolean("pause_on_error", defaultValue = true)
|
||||
val maxRetries = integer("max_retries", defaultValue = 3) {
|
||||
inputCheck = { it.toIntOrNull()?.coerceIn(1, 10) != null }
|
||||
}
|
||||
val retryDelay = integer("retry_delay", defaultValue = 1000) {
|
||||
inputCheck = { it.toIntOrNull()?.coerceAtLeast(500) != null }
|
||||
}
|
||||
|
||||
val supportedLanguages = multiple("supported_languages",
|
||||
"en", "es", "fr", "de", "it", "pt", "ru", "ja", "ko", "zh", "ar", "hi", "tr", "nl", "pl", "sv", "da", "no", "fi", "cs", "hu", "ro", "bg", "hr", "sk", "sl", "et", "lv", "lt", "mt", "ga", "cy"
|
||||
) {
|
||||
customOptionTranslationPath = "language_codes"
|
||||
}.apply {
|
||||
set(mutableListOf("en", "es", "fr", "de", "it", "pt", "ru", "ja", "ko", "zh", "ar", "hi", "tr"))
|
||||
}
|
||||
}
|
||||
|
||||
val bypassScreenshotDetection = boolean("bypass_screenshot_detection") { requireRestart() }
|
||||
val anonymousStoryViewing = boolean("anonymous_story_viewing")
|
||||
val preventStoryRewatchIndicator = boolean("prevent_story_rewatch_indicator") { requireRestart() }
|
||||
@@ -296,40 +332,9 @@ class MessagingTweaks : ConfigContainer() {
|
||||
val doubleTapChatActionCustomEmoji = string("double_tap_chat_action_custom_emoji") {
|
||||
inputCheck = { it.length == 2 && it.toByteArray(Charsets.UTF_8).size >= 4 } }
|
||||
val autoReply = container("auto_reply", AutoReplyConfig()) { requireRestart() }
|
||||
val autoOpenSnaps = container("auto_open_snaps", AutoOpenSnapsConfig()) { requireRestart(); addNotices(FeatureNotice.BAN_RISK, FeatureNotice.UNSTABLE) }
|
||||
val autoDeleteSentMessages = container("auto_delete_sent_messages", AutoDeleteSentMessagesConfig()) { requireRestart() }
|
||||
|
||||
class InstantTranslationConfig : ConfigContainer(hasGlobalState = true) {
|
||||
val enabled = boolean("enabled", false)
|
||||
val sourceLanguage = string("source_language", defaultValue = "auto") {
|
||||
inputCheck = { it.isNotBlank() }
|
||||
}
|
||||
val targetLanguage = string("target_language", defaultValue = "en") {
|
||||
inputCheck = { it.isNotBlank() }
|
||||
}
|
||||
val showOriginal = boolean("show_original", defaultValue = true)
|
||||
val showTranslation = boolean("show_translation", defaultValue = true)
|
||||
val translationPosition = unique("translation_position", "above", "below", "inline") {
|
||||
customOptionTranslationPath = "translation_position"
|
||||
}.apply { set("below") }
|
||||
val autoTranslate = boolean("auto_translate", defaultValue = true)
|
||||
val translateOnTap = boolean("translate_on_tap", defaultValue = false)
|
||||
val pauseOnError = boolean("pause_on_error", defaultValue = true)
|
||||
val maxRetries = integer("max_retries", defaultValue = 3) {
|
||||
inputCheck = { it.toIntOrNull()?.coerceIn(1, 10) != null }
|
||||
}
|
||||
val retryDelay = integer("retry_delay", defaultValue = 1000) {
|
||||
inputCheck = { it.toIntOrNull()?.coerceAtLeast(500) != null }
|
||||
}
|
||||
|
||||
val supportedLanguages = multiple("supported_languages",
|
||||
"en", "es", "fr", "de", "it", "pt", "ru", "ja", "ko", "zh", "ar", "hi", "tr", "nl", "pl", "sv", "da", "no", "fi", "cs", "hu", "ro", "bg", "hr", "sk", "sl", "et", "lv", "lt", "mt", "ga", "cy"
|
||||
) {
|
||||
customOptionTranslationPath = "language_codes"
|
||||
}.apply {
|
||||
set(mutableListOf("en", "es", "fr", "de", "it", "pt", "ru", "ja", "ko", "zh", "ar", "hi", "tr"))
|
||||
}
|
||||
}
|
||||
|
||||
val autoOpenSnaps = container("auto_open_snaps", AutoOpenSnapsConfig()) { requireRestart(); addNotices(FeatureNotice.BAN_RISK, FeatureNotice.UNSTABLE) }
|
||||
val preFetchSnaps = boolean("pre_fetch_snaps", false)
|
||||
val instantTranslation = container("instant_translation", InstantTranslationConfig()) { requireRestart() }
|
||||
}
|
||||
|
||||
@@ -19,4 +19,6 @@ class RootConfig : ConfigContainer() {
|
||||
FeatureNotice.UNSTABLE) }
|
||||
val scripting = container("scripting", Scripting()) { icon = Icons.Default.DataObject }
|
||||
val friendTracker = container("friend_tracker", FriendTrackerConfig()) { icon = Icons.Default.PersonSearch }
|
||||
val companionServer = container("companion_server", CompanionServerConfig()) { icon = Icons.Default.Monitor }
|
||||
val developer = container("developer", DeveloperConfig()) { icon = Icons.Default.Code }
|
||||
}
|
||||
@@ -115,9 +115,9 @@ class Spoof : ConfigContainer(hasGlobalState = true) {
|
||||
}
|
||||
|
||||
inner class RandomizedSettingsConfig : ConfigContainer(hasGlobalState = true) {
|
||||
val secure = container("secure", RandomizedSecureSettingsConfig().apply { globalState = true })
|
||||
val system = container("system", RandomizedSystemSettingsConfig().apply { globalState = true })
|
||||
val global = container("global", RandomizedGlobalSettingsConfig().apply { globalState = true })
|
||||
val secureSettings = container("secure_settings", RandomizedSecureSettingsConfig().apply { globalState = true })
|
||||
val systemSettings = container("system_settings", RandomizedSystemSettingsConfig().apply { globalState = true })
|
||||
val globalSettings = container("global_settings", RandomizedGlobalSettingsConfig().apply { globalState = true })
|
||||
}
|
||||
|
||||
inner class RandomizedWifiConfig : ConfigContainer(hasGlobalState = true) {
|
||||
@@ -178,12 +178,17 @@ class Spoof : ConfigContainer(hasGlobalState = true) {
|
||||
}
|
||||
val generateFreshProfileAction = string("generate_fresh_profile_action")
|
||||
val viewCurrentProfileAction = string("view_current_profile_action")
|
||||
val backupProfileAction = string("backup_profile_action")
|
||||
val restoreProfileAction = string("restore_profile_action")
|
||||
val profileGenerationToken = string("profile_generation_token") {
|
||||
addFlags(ConfigFlag.HIDDEN)
|
||||
}
|
||||
val currentProfileSnapshot = string("current_profile_snapshot") {
|
||||
addFlags(ConfigFlag.HIDDEN)
|
||||
}
|
||||
val profileData = string("profile_data") {
|
||||
addFlags(ConfigFlag.HIDDEN)
|
||||
}
|
||||
}
|
||||
|
||||
inner class SpoofDeviceIdConfig : ConfigContainer() {
|
||||
|
||||
@@ -33,6 +33,7 @@ class UserInterfaceTweaks : ConfigContainer() {
|
||||
val mapFriendNameTags = boolean("map_friend_nametags") { requireRestart() }
|
||||
val preventMessageListAutoScroll = boolean("prevent_message_list_auto_scroll") { requireRestart(); addNotices(FeatureNotice.UNSTABLE) }
|
||||
val streakExpirationInfo = boolean("streak_expiration_info") { requireRestart() }
|
||||
val sortSocialTabByStreakLength = boolean("sort_social_tab_by_streak_length").apply { set(true) }
|
||||
val hideFriendFeedEntry = boolean("hide_friend_feed_entry") { requireRestart() }
|
||||
val hideStreakRestore = boolean("hide_streak_restore") { requireRestart() }
|
||||
val hideQuickAddSuggestions = boolean("hide_quick_add_suggestions") { requireRestart() }
|
||||
@@ -56,13 +57,14 @@ class UserInterfaceTweaks : ConfigContainer() {
|
||||
val oldBitmojiSelfie = unique("old_bitmoji_selfie", "2d", "3d") { requireCleanCache() }
|
||||
val disableSpotlight = boolean("disable_spotlight") { requireRestart() }
|
||||
val verticalStoryViewer = boolean("vertical_story_viewer") { requireRestart() }
|
||||
val messageIndicators = multiple("message_indicators", "encryption_indicator", "platform_indicator", "location_indicator", "ovf_editor_indicator", "director_mode_indicator") { requireRestart() }
|
||||
val messageIndicators = multiple("message_indicators", "encryption_indicator", "platform_indicator", "location_indicator", "ovf_editor_indicator", "director_mode_indicator", "memories_indicator", "skip_own_indicators", "disable_indicators_in_groups") { requireRestart() }
|
||||
val stealthModeIndicator = boolean("stealth_mode_indicator") { requireRestart() }
|
||||
val editTextOverride = multiple("edit_text_override", "multi_line_chat_input", "bypass_text_input_limit") {
|
||||
requireRestart(); addNotices(FeatureNotice.BAN_RISK, FeatureNotice.INTERNAL_BEHAVIOR)
|
||||
}
|
||||
val preventForcedKeyboard = boolean("prevent_forced_keyboard") { requireRestart() }
|
||||
val settingsMenu = unique("settings_menu", "default", "legacy") { requireRestart() }.apply { set("default") }
|
||||
val chatHoldKillActions = multiple("chat_hold_kill_actions", "kill_snapchat", "kill_purrfectsnap") { requireRestart() }
|
||||
|
||||
inner class SpoofSnapScore : ConfigContainer(hasGlobalState = true) {
|
||||
val customSnapScore = string("custom_snap_score") {
|
||||
@@ -75,4 +77,14 @@ class UserInterfaceTweaks : ConfigContainer() {
|
||||
}
|
||||
|
||||
val spoofSnapScore = container("spoof_snap_score", SpoofSnapScore()) { requireRestart() }
|
||||
|
||||
inner class SpoofFollowersCount : ConfigContainer(hasGlobalState = true) {
|
||||
val customFollowersCount = string("custom_followers_count") {
|
||||
requireRestart()
|
||||
digitsOnlyInput = true
|
||||
inputCheck = { input -> input.isEmpty() || input.all { it.isDigit() } }
|
||||
}
|
||||
}
|
||||
|
||||
val spoofFollowersCount = container("spoof_followers_count", SpoofFollowersCount()) { requireRestart() }
|
||||
}
|
||||
|
||||
@@ -72,24 +72,10 @@ enum class FileType(
|
||||
if (majorBrand in imageBrands) return false
|
||||
|
||||
return majorBrand in setOf(
|
||||
"mp41",
|
||||
"mp42",
|
||||
"isom",
|
||||
"iso2",
|
||||
"iso3",
|
||||
"iso4",
|
||||
"iso5",
|
||||
"iso6",
|
||||
"avc1",
|
||||
"dash",
|
||||
"cmfc",
|
||||
"msnv",
|
||||
"3gp4",
|
||||
"3gp5",
|
||||
"3gp6",
|
||||
"3g2a",
|
||||
"3g2b"
|
||||
) || majorBrand.isNotEmpty() // FALLBACK: If it has the ftyp box and isn't a known image brand, it's a video
|
||||
"mp41", "mp42", "isom", "iso2", "iso3", "iso4", "iso5", "iso6",
|
||||
"avc1", "dash", "cmfc", "msnv", "3gp4", "3gp5", "3gp6", "3g2a", "3g2b",
|
||||
"mp4v", "mp4a", "m4v ", "m4a ", "f4v ", "f4a "
|
||||
) || majorBrand.isNotEmpty()
|
||||
}
|
||||
|
||||
fun fromFile(file: File): FileType {
|
||||
|
||||
@@ -49,6 +49,8 @@ enum class MessagingRuleType(
|
||||
val configNotices: Array<FeatureNotice> = emptyArray()
|
||||
) {
|
||||
STEALTH("stealth", true, Icons.Outlined.TrackChanges),
|
||||
SNAP_STEALTH("snap_stealth", true, Icons.Outlined.PhotoCamera, showInFriendMenu = true),
|
||||
CHAT_STEALTH("chat_stealth", true, Icons.Outlined.ChatBubbleOutline, showInFriendMenu = true),
|
||||
HIDE_TYPING_INDICATOR("hide_typing_indicator", true, Icons.Outlined.KeyboardHide, defaultValue = "whitelist"),
|
||||
AUTO_DOWNLOAD("auto_download", true, Icons.Outlined.DownloadForOffline),
|
||||
AUTO_SAVE("auto_save", true, Icons.Outlined.Save, defaultValue = "blacklist"),
|
||||
@@ -63,6 +65,7 @@ enum class MessagingRuleType(
|
||||
AUTO_DELETE_SENT_MESSAGES("auto_delete_sent_messages", true, Icons.Outlined.DeleteSweep, defaultValue = "blacklist");
|
||||
|
||||
fun translateOptionKey(optionKey: String): String {
|
||||
if (key.contains("stealth")) return "features.options.friend_feed_menu_buttons.$key"
|
||||
return if (listMode) "rules.properties.$key.options.$optionKey" else "rules.properties.$key.name"
|
||||
}
|
||||
|
||||
@@ -71,6 +74,58 @@ enum class MessagingRuleType(
|
||||
}
|
||||
}
|
||||
|
||||
private val partialStealthRuleTypes = setOf(
|
||||
MessagingRuleType.SNAP_STEALTH,
|
||||
MessagingRuleType.CHAT_STEALTH
|
||||
)
|
||||
|
||||
private val allStealthRuleTypes = partialStealthRuleTypes + MessagingRuleType.STEALTH
|
||||
|
||||
fun MessagingRuleType.isStealthRule(): Boolean = this in allStealthRuleTypes
|
||||
|
||||
fun Collection<MessagingRuleType>.normalizeStealthRules(): Set<MessagingRuleType> {
|
||||
val normalizedRules = toMutableSet()
|
||||
if (MessagingRuleType.STEALTH in normalizedRules) {
|
||||
normalizedRules.removeAll(partialStealthRuleTypes)
|
||||
return normalizedRules
|
||||
}
|
||||
|
||||
if (partialStealthRuleTypes.all { it in normalizedRules }) {
|
||||
normalizedRules.removeAll(partialStealthRuleTypes)
|
||||
normalizedRules.add(MessagingRuleType.STEALTH)
|
||||
}
|
||||
|
||||
return normalizedRules
|
||||
}
|
||||
|
||||
fun Collection<MessagingRuleType>.withNormalizedRuleToggle(
|
||||
ruleType: MessagingRuleType,
|
||||
enabled: Boolean
|
||||
): Set<MessagingRuleType> {
|
||||
val updatedRules = toMutableSet().apply {
|
||||
if (enabled) {
|
||||
add(ruleType)
|
||||
} else {
|
||||
remove(ruleType)
|
||||
}
|
||||
}
|
||||
|
||||
if (!ruleType.isStealthRule()) {
|
||||
return updatedRules
|
||||
}
|
||||
|
||||
if (enabled) {
|
||||
when (ruleType) {
|
||||
MessagingRuleType.STEALTH -> updatedRules.removeAll(partialStealthRuleTypes)
|
||||
MessagingRuleType.SNAP_STEALTH,
|
||||
MessagingRuleType.CHAT_STEALTH -> updatedRules.remove(MessagingRuleType.STEALTH)
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
|
||||
return updatedRules.normalizeStealthRules()
|
||||
}
|
||||
|
||||
@Parcelize
|
||||
data class FriendStreaks(
|
||||
val notify: Boolean = true,
|
||||
|
||||
@@ -22,7 +22,7 @@ open class ScriptRuntime(
|
||||
|
||||
private val modules = mutableMapOf<String, JSModule>()
|
||||
|
||||
fun eachModule(f: JSModule.() -> Unit) {
|
||||
open fun eachModule(f: JSModule.() -> Unit) {
|
||||
modules.values.forEach { module ->
|
||||
runCatching {
|
||||
module.f()
|
||||
|
||||
@@ -59,12 +59,14 @@ fun InputStream.toParcelFileDescriptor(coroutineScope: CoroutineScope): ParcelFi
|
||||
val fos = ParcelFileDescriptor.AutoCloseOutputStream(pfd[1])
|
||||
|
||||
coroutineScope.launch(Dispatchers.IO) {
|
||||
try {
|
||||
copyTo(fos)
|
||||
} finally {
|
||||
close()
|
||||
fos.flush()
|
||||
fos.close()
|
||||
runCatching {
|
||||
try {
|
||||
copyTo(fos)
|
||||
} finally {
|
||||
close()
|
||||
fos.flush()
|
||||
fos.close()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -164,10 +164,10 @@ class ModContext(
|
||||
disableMetrics = config.global.disableMetrics.get(),
|
||||
valdiHooks = config.experimental.nativeHooks.valdiHooks.globalState == true &&
|
||||
Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q,
|
||||
customEmojiFontPath = getCustomEmojiFontPath(this)
|
||||
)
|
||||
)
|
||||
}
|
||||
customEmojiFontPath = getCustomEmojiFontPath(this),
|
||||
debugFontRedirect = config.experimental.nativeHooks.debugFontRedirect.get()
|
||||
)
|
||||
) }
|
||||
|
||||
fun getConfigLocale(): String {
|
||||
return _config.locale
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package me.eternal.purrfectsnap.core
|
||||
|
||||
import me.eternal.purrfectsnap.common.scripting.JSModule
|
||||
import android.app.Activity
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
|
||||
@@ -7,6 +7,7 @@ import android.content.Intent
|
||||
import android.content.ServiceConnection
|
||||
import android.os.*
|
||||
import android.util.Log
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||
import kotlinx.coroutines.sync.Semaphore
|
||||
@@ -41,16 +42,111 @@ class BridgeClient(
|
||||
private var continuation: Continuation<Boolean>? = null
|
||||
private val connectSemaphore = Semaphore(permits = 1)
|
||||
private val reconnectSemaphore = Semaphore(permits = 1)
|
||||
private lateinit var service: BridgeInterface
|
||||
private val serviceStateLock = Any()
|
||||
@Volatile
|
||||
private var service: BridgeInterface? = null
|
||||
@Volatile
|
||||
private var serviceBinder: IBinder? = null
|
||||
@Volatile
|
||||
private var isBound = false
|
||||
@Volatile
|
||||
private var isHandlingServiceConnection = false
|
||||
private val connectionExecutor = Executors.newSingleThreadExecutor()
|
||||
private val legacyBindThread = HandlerThread("BridgeClient").apply { start() }
|
||||
private val legacyBindHandler by lazy { Handler(legacyBindThread.looper) }
|
||||
|
||||
private val onConnectedCallbacks = mutableListOf<suspend () -> Unit>()
|
||||
private var cachePurrfectSnapApkPath: String? = null
|
||||
|
||||
private val serviceDeathRecipient = IBinder.DeathRecipient {
|
||||
clearConnectedService()
|
||||
}
|
||||
|
||||
private fun clearConnectedServiceLocked() {
|
||||
serviceBinder?.let { binder ->
|
||||
runCatching { binder.unlinkToDeath(serviceDeathRecipient, 0) }
|
||||
}
|
||||
serviceBinder = null
|
||||
service = null
|
||||
}
|
||||
|
||||
private fun clearConnectedService() {
|
||||
synchronized(serviceStateLock) {
|
||||
clearConnectedServiceLocked()
|
||||
}
|
||||
}
|
||||
|
||||
private fun attachConnectedService(binder: IBinder): Boolean {
|
||||
synchronized(serviceStateLock) {
|
||||
clearConnectedServiceLocked()
|
||||
serviceBinder = binder
|
||||
service = BridgeInterface.Stub.asInterface(binder)
|
||||
return runCatching {
|
||||
binder.linkToDeath(serviceDeathRecipient, 0)
|
||||
true
|
||||
}.getOrElse { throwable ->
|
||||
Log.w("BridgeClient", "Failed to link bridge death recipient", throwable)
|
||||
clearConnectedServiceLocked()
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun isServiceAlive(): Boolean {
|
||||
val binder = serviceBinder ?: service?.asBinder() ?: return false
|
||||
return binder.isBinderAlive && binder.pingBinder()
|
||||
}
|
||||
|
||||
private val connectedService: BridgeInterface
|
||||
get() {
|
||||
val currentService = service ?: throw DeadObjectException()
|
||||
val binder = currentService.asBinder()
|
||||
if (!binder.isBinderAlive || !binder.pingBinder()) throw DeadObjectException()
|
||||
return currentService
|
||||
}
|
||||
|
||||
private fun Context.unbindBridgeIfNeeded() {
|
||||
if (!isBound) return
|
||||
runCatching { unbindService(this@BridgeClient) }.onFailure { throwable ->
|
||||
if (throwable !is IllegalArgumentException) throw throwable
|
||||
}
|
||||
isBound = false
|
||||
}
|
||||
|
||||
private fun Context.bindBridge(intent: Intent): Boolean {
|
||||
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
bindService(
|
||||
intent,
|
||||
Context.BIND_AUTO_CREATE,
|
||||
connectionExecutor,
|
||||
this@BridgeClient
|
||||
)
|
||||
} else {
|
||||
this::class.java.methods.firstOrNull {
|
||||
it.name == "bindServiceAsUser" && it.parameterTypes.size == 5
|
||||
}?.invoke(
|
||||
this,
|
||||
intent,
|
||||
this@BridgeClient,
|
||||
Context.BIND_AUTO_CREATE,
|
||||
legacyBindHandler,
|
||||
Process.myUserHandle()
|
||||
) as? Boolean ?: false
|
||||
}
|
||||
}
|
||||
|
||||
private fun isRecoverableBinderFailure(throwable: Throwable): Boolean {
|
||||
return throwable is DeadObjectException ||
|
||||
throwable is RemoteException ||
|
||||
throwable.cause is DeadObjectException ||
|
||||
throwable.cause is RemoteException
|
||||
}
|
||||
|
||||
fun addOnConnectedCallback(initNow: Boolean = false, callback: suspend () -> Unit) {
|
||||
synchronized(onConnectedCallbacks) {
|
||||
onConnectedCallbacks.add(callback)
|
||||
}
|
||||
initNow.takeIf { it && this::service.isInitialized }?.let {
|
||||
initNow.takeIf { it && isServiceAlive() }?.let {
|
||||
runBlocking {
|
||||
callback()
|
||||
}
|
||||
@@ -67,7 +163,7 @@ class BridgeClient(
|
||||
}
|
||||
|
||||
suspend fun connect(onFailure: (Throwable) -> Unit): Boolean? {
|
||||
if (this::service.isInitialized && service.asBinder().pingBinder()) {
|
||||
if (isServiceAlive()) {
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -75,10 +171,9 @@ class BridgeClient(
|
||||
val retryDelay = 3000L
|
||||
|
||||
return withTimeoutOrNull(connectionTimeout) {
|
||||
var result: Boolean? = null
|
||||
|
||||
for (retry in 0.. (connectionTimeout / retryDelay).toInt()) {
|
||||
result = withTimeoutOrNull(retryDelay) {
|
||||
val attempts = (connectionTimeout / retryDelay).toInt() + 1
|
||||
repeat(attempts) { attempt ->
|
||||
val result = withTimeoutOrNull(retryDelay) {
|
||||
suspendCancellableCoroutine { cancellableContinuation ->
|
||||
continuation = cancellableContinuation
|
||||
with(context.androidContext) {
|
||||
@@ -93,31 +188,12 @@ class BridgeClient(
|
||||
runCatching {
|
||||
val intent = Intent()
|
||||
.setClassName(Constants.MODULE_PACKAGE_NAME, "me.eternal.purrfectsnap.bridge.BridgeService")
|
||||
runCatching {
|
||||
if (this@BridgeClient::service.isInitialized) {
|
||||
unbindService(this@BridgeClient)
|
||||
}
|
||||
}
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
bindService(
|
||||
intent,
|
||||
Context.BIND_AUTO_CREATE,
|
||||
Executors.newSingleThreadExecutor(),
|
||||
this@BridgeClient
|
||||
)
|
||||
} else {
|
||||
val handler = Handler(HandlerThread("BridgeClient").apply { start() }.looper)
|
||||
this::class.java.methods.firstOrNull {
|
||||
it.name == "bindServiceAsUser" && it.parameterTypes.size == 5
|
||||
}?.invoke(
|
||||
this,
|
||||
intent,
|
||||
this@BridgeClient,
|
||||
Context.BIND_AUTO_CREATE,
|
||||
handler,
|
||||
Process.myUserHandle()
|
||||
) ?: throw NoSuchMethodException("bindServiceAsUser")
|
||||
unbindBridgeIfNeeded()
|
||||
clearConnectedService()
|
||||
if (!bindBridge(intent)) {
|
||||
throw IllegalStateException("bindService returned false")
|
||||
}
|
||||
isBound = true
|
||||
}.onFailure {
|
||||
onFailure(it)
|
||||
resumeContinuation(false)
|
||||
@@ -125,54 +201,89 @@ class BridgeClient(
|
||||
}
|
||||
}
|
||||
}
|
||||
if (result != null) break
|
||||
if (result == true) {
|
||||
return@withTimeoutOrNull true
|
||||
}
|
||||
if (attempt + 1 < attempts) {
|
||||
delay(250L)
|
||||
}
|
||||
}
|
||||
|
||||
result
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
override fun onServiceConnected(name: ComponentName, service: IBinder) {
|
||||
this.service = BridgeInterface.Stub.asInterface(service)
|
||||
runBlocking {
|
||||
onConnectedCallbacks.forEach {
|
||||
runCatching {
|
||||
it()
|
||||
}.onFailure {
|
||||
context.log.error("Failed to run onConnectedCallback", it)
|
||||
}
|
||||
}
|
||||
}
|
||||
cachePurrfectSnapApkPath = this.service.applicationApkPath.also {
|
||||
if (cachePurrfectSnapApkPath != null && cachePurrfectSnapApkPath != it) {
|
||||
context.log.verbose("Restarting Snapchat due to PurrfectSnap update")
|
||||
context.softRestartApp()
|
||||
isHandlingServiceConnection = true
|
||||
try {
|
||||
if (!attachConnectedService(service)) {
|
||||
resumeContinuation(false)
|
||||
return
|
||||
}
|
||||
|
||||
runBlocking {
|
||||
onConnectedCallbacks.forEach {
|
||||
runCatching {
|
||||
it()
|
||||
}.onFailure {
|
||||
context.log.error("Failed to run onConnectedCallback", it)
|
||||
}
|
||||
}
|
||||
}
|
||||
val remoteApkPath = runCatching {
|
||||
connectedService.applicationApkPath
|
||||
}.getOrElse { throwable ->
|
||||
if (isRecoverableBinderFailure(throwable)) {
|
||||
Log.w("BridgeClient", "Bridge died during onServiceConnected initialization", throwable)
|
||||
} else {
|
||||
Log.e("BridgeClient", "Bridge initialization failed", throwable)
|
||||
}
|
||||
clearConnectedService()
|
||||
resumeContinuation(false)
|
||||
return
|
||||
}
|
||||
cachePurrfectSnapApkPath = remoteApkPath.also {
|
||||
if (cachePurrfectSnapApkPath != null && cachePurrfectSnapApkPath != it) {
|
||||
context.log.verbose("Restarting Snapchat due to PurrfectSnap update")
|
||||
context.softRestartApp()
|
||||
return
|
||||
}
|
||||
}
|
||||
resumeContinuation(true)
|
||||
} finally {
|
||||
isHandlingServiceConnection = false
|
||||
}
|
||||
resumeContinuation(true)
|
||||
}
|
||||
|
||||
override fun onNullBinding(name: ComponentName) {
|
||||
clearConnectedService()
|
||||
isBound = false
|
||||
resumeContinuation(false)
|
||||
}
|
||||
|
||||
override fun onServiceDisconnected(name: ComponentName) {
|
||||
clearConnectedService()
|
||||
isBound = false
|
||||
continuation = null
|
||||
}
|
||||
|
||||
override fun onBindingDied(name: ComponentName) {
|
||||
clearConnectedService()
|
||||
isBound = false
|
||||
resumeContinuation(false)
|
||||
}
|
||||
|
||||
private fun tryReconnect() {
|
||||
runBlocking {
|
||||
reconnectSemaphore.withPermit {
|
||||
if (service.asBinder().pingBinder()) return@runBlocking
|
||||
if (isServiceAlive()) return@withPermit
|
||||
Log.d("BridgeClient", "service is dead, restarting")
|
||||
val canLoad = connect {
|
||||
Log.e("BridgeClient", "connection failed", it)
|
||||
context.softRestartApp()
|
||||
}
|
||||
if (canLoad != true) {
|
||||
Log.e("BridgeClient", "failed to reconnect to service, result=$canLoad")
|
||||
context.softRestartApp()
|
||||
return@runBlocking
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -182,16 +293,16 @@ class BridgeClient(
|
||||
return runCatching {
|
||||
block()
|
||||
}.getOrElse { throwable ->
|
||||
if (throwable is DeadObjectException) {
|
||||
tryReconnect()
|
||||
return@getOrElse runCatching {
|
||||
block()
|
||||
}.getOrElse {
|
||||
Log.e("BridgeClient", "service call failed", it)
|
||||
if (it is DeadObjectException) {
|
||||
context.softRestartApp()
|
||||
if (isRecoverableBinderFailure(throwable)) {
|
||||
clearConnectedService()
|
||||
if (!isHandlingServiceConnection) {
|
||||
tryReconnect()
|
||||
return@getOrElse runCatching {
|
||||
block()
|
||||
}.getOrElse {
|
||||
Log.e("BridgeClient", "service call failed", it)
|
||||
throw it
|
||||
}
|
||||
throw it
|
||||
}
|
||||
}
|
||||
throw throwable
|
||||
@@ -201,15 +312,15 @@ class BridgeClient(
|
||||
fun broadcastLog(tag: String, level: String, message: String) {
|
||||
message.chunked(1024 * 256).forEach {
|
||||
runCatching {
|
||||
service.broadcastLog(tag, level, it)
|
||||
connectedService.broadcastLog(tag, level, it)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun getApplicationApkPath(): String = safeServiceCall { service.applicationApkPath }
|
||||
fun getApplicationApkPath(): String = safeServiceCall { connectedService.applicationApkPath }
|
||||
|
||||
fun enqueueDownload(intent: Intent, callback: DownloadCallback) = safeServiceCall {
|
||||
service.enqueueDownload(intent, callback)
|
||||
connectedService.enqueueDownload(intent, callback)
|
||||
}
|
||||
|
||||
fun convertMedia(
|
||||
@@ -219,40 +330,42 @@ class BridgeClient(
|
||||
audioCodec: String?,
|
||||
videoCodec: String?
|
||||
): ParcelFileDescriptor? = safeServiceCall {
|
||||
service.convertMedia(input, inputExtension, outputExtension, audioCodec, videoCodec)
|
||||
connectedService.convertMedia(input, inputExtension, outputExtension, audioCodec, videoCodec)
|
||||
}
|
||||
|
||||
fun sync(callback: SyncCallback) {
|
||||
if (!context.database.hasMain()) return
|
||||
safeServiceCall {
|
||||
service.sync(callback)
|
||||
connectedService.sync(callback)
|
||||
}
|
||||
}
|
||||
|
||||
fun triggerSync(scope: SocialScope, id: String) = safeServiceCall {
|
||||
service.triggerSync(scope.key, id)
|
||||
connectedService.triggerSync(scope.key, id)
|
||||
}
|
||||
|
||||
fun passGroupsAndFriends(groups: List<MessagingGroupInfo>, friends: List<MessagingFriendInfo>) =
|
||||
safeServiceCall {
|
||||
val serializedGroups = groups.mapNotNull { it.toSerialized() }
|
||||
val serializedFriends = friends.mapNotNull { it.toSerialized() }
|
||||
|
||||
// Binder transaction limit is 1MB. Use 128KB chunks to avoid TransactionTooLargeException.
|
||||
val maxChunkBytes = 128 * 1024
|
||||
|
||||
fun chunkSerialized(values: List<String>): List<List<String>> {
|
||||
fun calculateParts(values: List<String>): List<List<String>> {
|
||||
if (values.isEmpty()) return listOf(emptyList())
|
||||
val result = mutableListOf<List<String>>()
|
||||
val currentChunk = mutableListOf<String>()
|
||||
var currentChunk = mutableListOf<String>()
|
||||
var currentSize = 0
|
||||
|
||||
values.forEach { value ->
|
||||
val valueSize = value.toByteArray(StandardCharsets.UTF_8).size + 32
|
||||
val valueSize = value.toByteArray(Charsets.UTF_8).size + 32
|
||||
if (currentChunk.isNotEmpty() && currentSize + valueSize > maxChunkBytes) {
|
||||
result += currentChunk.toList()
|
||||
currentChunk.clear()
|
||||
currentChunk = mutableListOf()
|
||||
currentSize = 0
|
||||
}
|
||||
currentChunk += value
|
||||
currentChunk.add(value)
|
||||
currentSize += valueSize
|
||||
}
|
||||
|
||||
@@ -262,73 +375,71 @@ class BridgeClient(
|
||||
return result
|
||||
}
|
||||
|
||||
val groupChunks = chunkSerialized(serializedGroups)
|
||||
val friendChunks = chunkSerialized(serializedFriends)
|
||||
val chunkCount = maxOf(groupChunks.size, friendChunks.size)
|
||||
val groupParts = calculateParts(serializedGroups)
|
||||
val friendParts = calculateParts(serializedFriends)
|
||||
val totalParts = maxOf(groupParts.size, friendParts.size)
|
||||
|
||||
context.log.info(
|
||||
"Sending social snapshot in $chunkCount chunk(s): " +
|
||||
"${serializedGroups.size} groups, ${serializedFriends.size} friends"
|
||||
)
|
||||
context.log.info("Synchronizing social data in $totalParts part(s): ${serializedGroups.size} groups, ${serializedFriends.size} friends")
|
||||
|
||||
repeat(chunkCount) { index ->
|
||||
service.passGroupsAndFriends(
|
||||
groupChunks.getOrElse(index) { emptyList() },
|
||||
friendChunks.getOrElse(index) { emptyList() }
|
||||
repeat(totalParts) { index ->
|
||||
connectedService.passGroupsAndFriends(
|
||||
groupParts.getOrElse(index) { emptyList() },
|
||||
friendParts.getOrElse(index) { emptyList() },
|
||||
index,
|
||||
totalParts
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun getRules(targetUuid: String): List<MessagingRuleType> = safeServiceCall {
|
||||
service.getRules(targetUuid).mapNotNull { MessagingRuleType.getByName(it) }
|
||||
connectedService.getRules(targetUuid).mapNotNull { MessagingRuleType.getByName(it) }
|
||||
}
|
||||
|
||||
fun getRuleIds(ruleType: MessagingRuleType): List<String> = safeServiceCall {
|
||||
service.getRuleIds(ruleType.key)
|
||||
connectedService.getRuleIds(ruleType.key)
|
||||
}
|
||||
|
||||
fun setRule(targetUuid: String, type: MessagingRuleType, state: Boolean) = safeServiceCall {
|
||||
service.setRule(targetUuid, type.key, state)
|
||||
connectedService.setRule(targetUuid, type.key, state)
|
||||
}
|
||||
|
||||
fun getScopeNotes(id: String): String? = safeServiceCall { service.getScopeNotes(id) }
|
||||
fun getScopeNotes(id: String): String? = safeServiceCall { connectedService.getScopeNotes(id) }
|
||||
|
||||
fun setScopeNotes(id: String, content: String?) = safeServiceCall { service.setScopeNotes(id, content) }
|
||||
fun setScopeNotes(id: String, content: String?) = safeServiceCall { connectedService.setScopeNotes(id, content) }
|
||||
|
||||
fun getAllScopeNotes(): Map<String, String> = safeServiceCall { service.getAllScopeNotes() }
|
||||
fun getAllScopeNotes(): Map<String, String> = safeServiceCall { connectedService.getAllScopeNotes() }
|
||||
|
||||
fun setAllScopeNotes(notes: Map<String, String>) = safeServiceCall { service.setAllScopeNotes(notes) }
|
||||
fun setAllScopeNotes(notes: Map<String, String>) = safeServiceCall { connectedService.setAllScopeNotes(notes) }
|
||||
|
||||
fun getScriptingInterface(): IScripting? = safeServiceCall<IScripting?> { service.scriptingInterface }
|
||||
fun getScriptingInterface(): IScripting? = safeServiceCall<IScripting?> { connectedService.scriptingInterface }
|
||||
|
||||
fun getE2eeInterface(): E2eeInterface = safeServiceCall { service.e2eeInterface }
|
||||
fun getE2eeInterface(): E2eeInterface = safeServiceCall { connectedService.e2eeInterface }
|
||||
|
||||
fun getMessageLogger(): LoggerInterface = safeServiceCall { service.logger }
|
||||
fun getMessageLogger(): LoggerInterface = safeServiceCall { connectedService.logger }
|
||||
|
||||
fun getTracker(): TrackerInterface = safeServiceCall { service.tracker }
|
||||
fun getTracker(): TrackerInterface = safeServiceCall { connectedService.tracker }
|
||||
|
||||
fun getAccountStorage(): AccountStorage = safeServiceCall { service.accountStorage }
|
||||
fun getAccountStorage(): AccountStorage = safeServiceCall { connectedService.accountStorage }
|
||||
|
||||
fun getFileHandlerManager(): FileHandleManager = safeServiceCall { service.fileHandleManager }
|
||||
fun getFileHandlerManager(): FileHandleManager = safeServiceCall { connectedService.fileHandleManager }
|
||||
|
||||
fun getLocationManager(): LocationManager = safeServiceCall { service.locationManager }
|
||||
fun getLocationManager(): LocationManager = safeServiceCall { connectedService.locationManager }
|
||||
|
||||
fun getTaskInterface(): TaskInterface = safeServiceCall { service.taskInterface }
|
||||
fun getTaskInterface(): TaskInterface = safeServiceCall { connectedService.taskInterface }
|
||||
|
||||
fun registerMessagingBridge(bridge: MessagingBridge) = safeServiceCall { service.registerMessagingBridge(bridge) }
|
||||
fun registerMessagingBridge(bridge: MessagingBridge) = safeServiceCall { connectedService.registerMessagingBridge(bridge) }
|
||||
|
||||
fun openOverlay(type: OverlayType) = safeServiceCall { service.openOverlay(type.key) }
|
||||
fun closeOverlay() = safeServiceCall { service.closeOverlay() }
|
||||
fun openOverlay(type: OverlayType) = safeServiceCall { connectedService.openOverlay(type.key) }
|
||||
fun closeOverlay() = safeServiceCall { connectedService.closeOverlay() }
|
||||
|
||||
fun registerConfigStateListener(listener: ConfigStateListener) = safeServiceCall { service.registerConfigStateListener(listener) }
|
||||
fun registerConfigStateListener(listener: ConfigStateListener) = safeServiceCall { connectedService.registerConfigStateListener(listener) }
|
||||
|
||||
fun getDebugProp(name: String, defaultValue: String? = null): String? = safeServiceCall { service.getDebugProp(name, defaultValue) }
|
||||
fun getDebugProp(name: String, defaultValue: String? = null): String? = safeServiceCall { connectedService.getDebugProp(name, defaultValue) }
|
||||
|
||||
fun startCallDownload(
|
||||
startTimestamp: Long,
|
||||
author: String,
|
||||
): CallDownloadSession {
|
||||
return safeServiceCall { service.startCallDownload(startTimestamp, author) }
|
||||
return safeServiceCall { connectedService.startCallDownload(startTimestamp, author) }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -47,6 +47,14 @@ class DatabaseAccess(
|
||||
} == true
|
||||
}
|
||||
|
||||
private val hasArroyoUserConversationTable by lazy {
|
||||
useDatabase(DatabaseType.ARROYO)?.performOperation {
|
||||
safeRawQuery("SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'user_conversation'")?.use { query ->
|
||||
query.moveToFirst() && query.getStringOrNull("name") == "user_conversation"
|
||||
}
|
||||
} == true
|
||||
}
|
||||
|
||||
private fun useDatabase(database: DatabaseType, writeMode: Boolean = false): SQLiteDatabase? {
|
||||
// only cache read-only databases
|
||||
if (!writeMode && openedDatabases.containsKey(database) && openedDatabases[database]?.isOpen == true) {
|
||||
@@ -148,6 +156,10 @@ class DatabaseAccess(
|
||||
}?.toMutableMap() ?: mutableMapOf()
|
||||
}
|
||||
|
||||
if (!hasArroyoUserConversationTable) {
|
||||
return@lazy mutableMapOf()
|
||||
}
|
||||
|
||||
(useDatabase(DatabaseType.ARROYO)?.performOperation {
|
||||
safeRawQuery(
|
||||
"SELECT client_conversation_id, conversation_type, user_id FROM user_conversation WHERE user_id != ?",
|
||||
@@ -354,7 +366,7 @@ class DatabaseAccess(
|
||||
}
|
||||
|
||||
fun getConversationType(conversationId: String): Int? {
|
||||
if (hasArroyoConversationTable) {
|
||||
if (hasArroyoConversationTable || !hasArroyoUserConversationTable) {
|
||||
return getFeedEntryByConversationId(conversationId)?.conversationType
|
||||
}
|
||||
|
||||
@@ -372,7 +384,9 @@ class DatabaseAccess(
|
||||
}
|
||||
|
||||
fun getDMConversationId(userId: String): String? {
|
||||
if (hasArroyoConversationTable) {
|
||||
friendDMsCache[userId]?.let { return it }
|
||||
|
||||
if (hasArroyoConversationTable || !hasArroyoUserConversationTable) {
|
||||
return friendDMsCache[userId]
|
||||
}
|
||||
|
||||
@@ -408,6 +422,10 @@ class DatabaseAccess(
|
||||
}
|
||||
}
|
||||
|
||||
if (!hasArroyoUserConversationTable) {
|
||||
return getFeedEntryByConversationId(conversationId)?.participants
|
||||
}
|
||||
|
||||
return useDatabase(DatabaseType.ARROYO)?.performOperation {
|
||||
safeRawQuery(
|
||||
"SELECT user_id FROM user_conversation WHERE client_conversation_id = ?",
|
||||
|
||||
@@ -47,7 +47,7 @@ class EventDispatcher(
|
||||
cacheHook(
|
||||
methodParam.thisObject<Any>()::class.java
|
||||
) {
|
||||
hook(bindMethod.get().toString(), HookStage.BEFORE) bindViewMethod@{ param ->
|
||||
hook(bindMethod.get().toString(), HookStage.AFTER) bindViewMethod@{ param ->
|
||||
val instance = param.thisObject<Any>()
|
||||
val view = instance::class.java.methods.firstOrNull {
|
||||
it.name == getViewMethod.get().toString()
|
||||
@@ -161,7 +161,6 @@ class EventDispatcher(
|
||||
adapter = param
|
||||
}
|
||||
) {
|
||||
if (canceled) param.setResult(null)
|
||||
postHookEvent()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -80,6 +80,7 @@ class FeatureManager(
|
||||
MessageLogger(),
|
||||
ConvertMessageLocally(),
|
||||
SnapchatPlus(),
|
||||
AdBlockFix(),
|
||||
DisableMetrics(),
|
||||
EndpointsBlocker(),
|
||||
PreventMessageSending(),
|
||||
@@ -99,6 +100,7 @@ class FeatureManager(
|
||||
MeoPasscodeBypass(),
|
||||
AppLock(),
|
||||
CameraTweaks(),
|
||||
PerformanceMode(),
|
||||
InfiniteStoryBoost(),
|
||||
PinConversations(),
|
||||
DeviceSpooferHook(),
|
||||
@@ -166,6 +168,7 @@ class FeatureManager(
|
||||
CustomTheming(),
|
||||
HideTypingIndicator(),
|
||||
FakeSnapScore(),
|
||||
CompanionServer(),
|
||||
)
|
||||
|
||||
features.values.toList().forEach { feature ->
|
||||
|
||||
@@ -2,9 +2,11 @@ package me.eternal.purrfectsnap.core.features
|
||||
|
||||
import me.eternal.purrfectsnap.common.data.MessagingRuleType
|
||||
import me.eternal.purrfectsnap.common.data.RuleState
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
|
||||
abstract class MessagingRuleFeature(name: String, val ruleType: MessagingRuleType) : Feature(name) {
|
||||
private val listeners = mutableListOf<(String, Boolean) -> Unit>()
|
||||
private val ruleCache = ConcurrentHashMap<String, Boolean>()
|
||||
|
||||
fun addStateListener(listener: (conversationId: String, newState: Boolean) -> Unit) {
|
||||
listeners.add(listener)
|
||||
@@ -13,20 +15,24 @@ abstract class MessagingRuleFeature(name: String, val ruleType: MessagingRuleTyp
|
||||
open fun getRuleState() = context.config.rules.getRuleState(ruleType)
|
||||
|
||||
fun setState(conversationId: String, state: Boolean) {
|
||||
val targetId = context.database.getDMOtherParticipant(conversationId) ?: conversationId
|
||||
context.bridgeClient.setRule(
|
||||
context.database.getDMOtherParticipant(conversationId) ?: conversationId,
|
||||
targetId,
|
||||
ruleType,
|
||||
state
|
||||
)
|
||||
ruleCache[targetId] = state
|
||||
listeners.forEach { it(conversationId, state) }
|
||||
}
|
||||
|
||||
fun getState(conversationId: String) =
|
||||
context.bridgeClient.getRules(
|
||||
context.database.getDMOtherParticipant(conversationId) ?: conversationId
|
||||
).contains(ruleType) && getRuleState() != null
|
||||
fun getState(conversationId: String): Boolean {
|
||||
val targetId = context.database.getDMOtherParticipant(conversationId) ?: conversationId
|
||||
return ruleCache.getOrPut(targetId) {
|
||||
context.bridgeClient.getRules(targetId).contains(ruleType)
|
||||
} && getRuleState() != null
|
||||
}
|
||||
|
||||
fun canUseRule(conversationId: String): Boolean {
|
||||
open fun canUseRule(conversationId: String): Boolean {
|
||||
if (ruleType.key == "translation" && context.config.messaging.instantTranslation.globalState != true) {
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package me.eternal.purrfectsnap.core.features.impl
|
||||
|
||||
import me.eternal.purrfectsnap.core.features.Feature
|
||||
import me.eternal.purrfectsnap.core.features.impl.messaging.Messaging
|
||||
|
||||
import me.eternal.purrfectsnap.core.util.hook.HookStage
|
||||
import me.eternal.purrfectsnap.core.util.hook.Hooker
|
||||
@@ -37,18 +38,49 @@ class ConfigurationOverride : Feature("Configuration Override") {
|
||||
}.getOrNull()
|
||||
|
||||
val propertyOverrides = mutableMapOf<String, ConfigFilter>()
|
||||
val loggedOverrides = mutableSetOf<String>()
|
||||
|
||||
fun overrideProperty(key: String, filter: (ConfigKeyInfo) -> Boolean, value: (ConfigKeyInfo) -> Any?, isAppExperiment: Boolean = false) {
|
||||
propertyOverrides[key] = ConfigFilter(filter, value, isAppExperiment)
|
||||
}
|
||||
|
||||
fun logPerformanceOverride(key: String, value: Any?) {
|
||||
if (!key.contains("PRELOAD") &&
|
||||
!key.contains("PERFORMANCE") &&
|
||||
!key.contains("WARM") &&
|
||||
!key.contains("PREFETCH") &&
|
||||
!key.contains("LATENCY") &&
|
||||
!key.contains("ANALYTICS") &&
|
||||
!key.contains("THREAD_PRIORITY") &&
|
||||
!key.contains("HD_MODE") &&
|
||||
!key.contains("LENS") &&
|
||||
!key.contains("THUMBNAIL")
|
||||
) {
|
||||
return
|
||||
}
|
||||
synchronized(loggedOverrides) {
|
||||
if (!loggedOverrides.add(key)) return
|
||||
}
|
||||
context.log.info("Performance override applied: $key=$value", "PerformanceMode")
|
||||
}
|
||||
|
||||
overrideProperty("STREAK_EXPIRATION_INFO", { context.config.userInterface.streakExpirationInfo.get() },
|
||||
{ true })
|
||||
overrideProperty("TRANSCODING_MAX_QUALITY", { context.config.global.mediaUploadQualityConfig.forceVideoUploadSourceQuality.get() },
|
||||
overrideProperty("TRANSCODING_MAX_QUALITY", { context.config.global.mediaUploadQualityConfig.forceVideoUploadSourceQuality.get() || context.config.messaging.galleryMediaSendOverride.mode.getNullable() != null },
|
||||
{ true }, isAppExperiment = true)
|
||||
|
||||
overrideProperty("BYPASS_AD_FEATURE_GATE", { context.config.global.blockAds.get() },
|
||||
{ true })
|
||||
|
||||
overrideProperty("SPONSORED_SNAPS_ENABLED", { context.config.global.blockAds.get() }, { false })
|
||||
overrideProperty("SPONSORED_SNAP_UPDATE_SPONSORED_FEED_ITEM", { context.config.global.blockAds.get() }, { false })
|
||||
|
||||
arrayOf("CUSTOM_AD_TRACKER_URL", "CUSTOM_AD_INIT_SERVER_URL", "CUSTOM_AD_SERVER_URL", "INIT_PRIMARY_URL", "INIT_SHADOW_URL", "GRAPHENE_HOST").forEach {
|
||||
overrideProperty(it, { context.config.global.blockAds.get() }, { "http://127.0.0.1" })
|
||||
}
|
||||
|
||||
run {
|
||||
val isForceQuality = { _: ConfigKeyInfo -> context.config.global.mediaUploadQualityConfig.forceVideoUploadSourceQuality.get() }
|
||||
val isForceQuality = { _: ConfigKeyInfo -> context.config.global.mediaUploadQualityConfig.forceVideoUploadSourceQuality.get() || context.config.messaging.galleryMediaSendOverride.mode.getNullable() != null }
|
||||
val level7Value = { _: ConfigKeyInfo -> 700 }
|
||||
arrayOf(
|
||||
"MY_STORY_UPLOAD_QUALITY_LEVEL",
|
||||
@@ -72,10 +104,14 @@ class ConfigurationOverride : Feature("Configuration Override") {
|
||||
isForceQuality, { true })
|
||||
overrideProperty("MEDIA_QUALITY_LEVEL_DOWNGRADING_PERCENTAGE",
|
||||
isForceQuality, { 0.0f })
|
||||
overrideProperty("CHAT_MEDIA_IMPORT_TRANSCODED_QUALITY",
|
||||
isForceQuality, { 4 })
|
||||
overrideProperty("POSTED_STORY_IMPORT_TRANSCODED_QUALITY",
|
||||
isForceQuality, { 4 })
|
||||
}
|
||||
|
||||
run {
|
||||
val isDisableCompression = { _: ConfigKeyInfo -> context.config.global.mediaUploadQualityConfig.disableImageCompression.get() }
|
||||
val isDisableCompression = { _: ConfigKeyInfo -> context.config.global.mediaUploadQualityConfig.disableImageCompression.get() || context.config.messaging.galleryMediaSendOverride.mode.getNullable() != null }
|
||||
overrideProperty("LIBJPEG_IMAGE_ENCODING_QUALITY", isDisableCompression, { 100 })
|
||||
overrideProperty("LIBJPEG_IMAGE_ENCODING_QUALITY_V2", isDisableCompression, { 100 })
|
||||
}
|
||||
@@ -84,6 +120,53 @@ class ConfigurationOverride : Feature("Configuration Override") {
|
||||
{ true })
|
||||
overrideProperty("MEDIA_RECORDER_MAX_QUALITY_LEVEL", { context.config.camera.forceCameraSourceEncoding.get() },
|
||||
{ true })
|
||||
overrideProperty("ENABLE_MESSAGE_WINDOW_MANAGER", { context.config.global.performanceMode.profile.getNullable() != null },
|
||||
{ true })
|
||||
overrideProperty("ENABLE_SIMPLE_CONVERSATION_RESET", { context.config.global.performanceMode.profile.getNullable() == "max" },
|
||||
{ false })
|
||||
overrideProperty("PREVIEW_PRELOAD_ACTIVATOR", { context.config.global.performanceMode.profile.getNullable() != null },
|
||||
{ true })
|
||||
overrideProperty("BUFFERED_VIDEO_RECORDING_ACTIVATOR", { context.config.global.performanceMode.profile.getNullable() != null },
|
||||
{ true })
|
||||
overrideProperty("CAMERA_THREAD_PRIORITY", { context.config.global.performanceMode.profile.getNullable() != null },
|
||||
{ true })
|
||||
overrideProperty("HD_MODE_ACTIVATOR", { context.config.global.performanceMode.profile.getNullable() == "max" },
|
||||
{ true })
|
||||
|
||||
arrayOf(
|
||||
"FEATURE_PRELOADER",
|
||||
"SERVER_PREFETCH",
|
||||
"SERVER_PREFETCH_WITH_COF",
|
||||
"DISCOVER_FEED_PERFORMANCE",
|
||||
"LOGIN_PRELOAD",
|
||||
"PREFETCH_REPO_SUBSCRIBE_ON_CPU",
|
||||
"COMPUTE_FEED_CACHE_WITH_TTL",
|
||||
"COMPUTE_FEED_NETWORK_WITH_CACHE",
|
||||
"OPERA_WARMUP",
|
||||
"SHOW_PREFETCH",
|
||||
).forEach { key ->
|
||||
overrideProperty(key, { context.config.global.performanceMode.profile.getNullable() != null }, { true })
|
||||
}
|
||||
|
||||
arrayOf(
|
||||
"USER_STORY_PRELOAD",
|
||||
"STARTUP_LENS_ACTIVATOR",
|
||||
"LENSES_PREVIEW_ACTIVATOR",
|
||||
"THUMBNAIL_PRESENTER_ACTIVATOR",
|
||||
"SINGLE_SEGMENT_THUMBNAIL_ACTIVATOR",
|
||||
"DISCOVER_FEED_STORY_PREFETCH",
|
||||
"DISCOVER_FEED_THUMBNAILS",
|
||||
"REFACTORED_WITH_WARMUP_LENS",
|
||||
).forEach { key ->
|
||||
overrideProperty(key, { context.config.global.performanceMode.profile.getNullable() == "max" }, { false })
|
||||
}
|
||||
|
||||
overrideProperty("LOAD_LATENCY_TRACKER_ACTIVATOR", { context.config.global.performanceMode.profile.getNullable() != null },
|
||||
{ false })
|
||||
overrideProperty("ANALYTICS_ACTIVATOR", { context.config.global.performanceMode.profile.getNullable() != null },
|
||||
{ false })
|
||||
overrideProperty("LOCK_SCREEN_ANALYTICS_ACTIVATOR", { context.config.global.performanceMode.profile.getNullable() != null },
|
||||
{ false })
|
||||
overrideProperty("REDUCE_MY_PROFILE_UI_COMPLEXITY", { context.config.userInterface.mapFriendNameTags.get() },
|
||||
{ true })
|
||||
|
||||
@@ -93,18 +176,12 @@ class ConfigurationOverride : Feature("Configuration Override") {
|
||||
|
||||
overrideProperty("DF_VOPERA_FOR_STORIES", { context.config.userInterface.verticalStoryViewer.get() },
|
||||
{ true }, isAppExperiment = true)
|
||||
overrideProperty("SPOTLIGHT_5TH_TAB_ENABLED", { context.config.userInterface.disableSpotlight.get() },
|
||||
overrideProperty("SPOTLIGHT_5TH_TAB_ENABLED", {
|
||||
context.config.userInterface.disableSpotlight.get() &&
|
||||
context.feature(Messaging::class).openedConversationUUID == null
|
||||
},
|
||||
{ false })
|
||||
|
||||
overrideProperty("BYPASS_AD_FEATURE_GATE", { context.config.global.blockAds.get() },
|
||||
{ true })
|
||||
|
||||
overrideProperty("SPONSORED_SNAPS_ENABLED", { context.config.global.blockAds.get() }, { false })
|
||||
overrideProperty("SPONSORED_SNAP_UPDATE_SPONSORED_FEED_ITEM", { context.config.global.blockAds.get() }, { false })
|
||||
|
||||
arrayOf("CUSTOM_AD_TRACKER_URL", "CUSTOM_AD_INIT_SERVER_URL", "CUSTOM_AD_SERVER_URL", "INIT_PRIMARY_URL", "INIT_SHADOW_URL", "GRAPHENE_HOST").forEach {
|
||||
overrideProperty(it, { context.config.global.blockAds.get() }, { "http://127.0.0.1" })
|
||||
}
|
||||
overrideProperty("GIFTING_CHAT_BIRTHDAY_UPSELL_ENABLED", { context.config.userInterface.hideUiComponents.get().contains("hide_snapchat_plus_gift_reminders") }, { false })
|
||||
|
||||
classReference.getAsClass()?.hook(
|
||||
@@ -115,7 +192,10 @@ class ConfigurationOverride : Feature("Configuration Override") {
|
||||
|
||||
propertyOverrides[propertyKey.name]?.let { (filter, value) ->
|
||||
if (!filter(propertyKey)) return@let
|
||||
param.setResult(value(propertyKey))
|
||||
value(propertyKey).also {
|
||||
logPerformanceOverride(propertyKey.name ?: return@also, it)
|
||||
param.setResult(it)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -135,7 +215,10 @@ class ConfigurationOverride : Feature("Configuration Override") {
|
||||
propertyOverrides[key]?.let { (filter, value) ->
|
||||
val keyInfo = getConfigKeyInfo(enumData) ?: return@let
|
||||
if (!filter(keyInfo)) return@let
|
||||
setValue(value(keyInfo))
|
||||
value(keyInfo).also {
|
||||
logPerformanceOverride(key, it)
|
||||
setValue(it)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -151,7 +234,10 @@ class ConfigurationOverride : Feature("Configuration Override") {
|
||||
}
|
||||
propertyOverrides[keyInfo.name]?.let { (filter, value, isAppExperiment) ->
|
||||
if (isAppExperiment != true || !filter(keyInfo)) return@let
|
||||
param.setResult(value(keyInfo))
|
||||
value(keyInfo).also {
|
||||
logPerformanceOverride(keyInfo.name ?: return@also, it)
|
||||
param.setResult(it)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -174,7 +260,10 @@ class ConfigurationOverride : Feature("Configuration Override") {
|
||||
}
|
||||
|
||||
val propertyOverride = propertyOverrides[keyInfo.name] ?: return@hook
|
||||
propertyOverride.isAppExperiment.takeIf { propertyOverride.filter(keyInfo) }?.let { param.setResult(it) }
|
||||
propertyOverride.isAppExperiment.takeIf { propertyOverride.filter(keyInfo) }?.let {
|
||||
logPerformanceOverride(keyInfo.name ?: return@let, it)
|
||||
param.setResult(it)
|
||||
}
|
||||
}
|
||||
}
|
||||
}.onFailure {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -144,8 +144,24 @@ class DeviceSpooferHook : Feature("Device Spoofer") {
|
||||
}
|
||||
|
||||
private fun getRandomizedProfile(): RandomizedDeviceProfile {
|
||||
val generationToken = context.config.experimental.spoof.randomizeDeviceProfile.profileGenerationToken.getNullable()
|
||||
return randomizedProfile ?: RandomizedDeviceProfileStore
|
||||
if (randomizedProfile != null) return randomizedProfile!!
|
||||
|
||||
val spoofConfig = context.config.experimental.spoof.randomizeDeviceProfile
|
||||
val configProfileJson = spoofConfig.profileData.getNullable()
|
||||
|
||||
if (!configProfileJson.isNullOrBlank()) {
|
||||
runCatching {
|
||||
val profile = RandomizedDeviceProfile.fromJson(configProfileJson)
|
||||
randomizedProfile = profile
|
||||
context.log.verbose("Using restored randomized device profile from config")
|
||||
return profile
|
||||
}.onFailure {
|
||||
context.log.warn("Failed to parse restored device profile from config, generating fresh one: ${it.message}")
|
||||
}
|
||||
}
|
||||
|
||||
val generationToken = spoofConfig.profileGenerationToken.getNullable()
|
||||
return RandomizedDeviceProfileStore
|
||||
.getOrCreate(context.androidContext, context.log, generationToken)
|
||||
.also { profile ->
|
||||
randomizedProfile = profile
|
||||
@@ -155,18 +171,23 @@ class DeviceSpooferHook : Feature("Device Spoofer") {
|
||||
|
||||
private fun persistRandomizedProfileSnapshot(profile: RandomizedDeviceProfile) {
|
||||
val spoofConfig = context.config.experimental.spoof.randomizeDeviceProfile
|
||||
val profileJson = profile.toJson().toString()
|
||||
val snapshot = profile.toJson().toString(2)
|
||||
if (spoofConfig.currentProfileSnapshot.getNullable() == snapshot) return
|
||||
|
||||
if (spoofConfig.currentProfileSnapshot.getNullable() == snapshot && spoofConfig.profileData.getNullable() == profileJson) return
|
||||
|
||||
spoofConfig.currentProfileSnapshot.set(snapshot)
|
||||
spoofConfig.profileData.set(profileJson) // Synchronize raw profile data for multi-process persistence
|
||||
|
||||
runCatching {
|
||||
val field = context.javaClass.getDeclaredField("_config\$delegate")
|
||||
field.isAccessible = true
|
||||
val lazyConfig = field.get(context) as Lazy<*>
|
||||
val modConfig = lazyConfig.value as? ModConfig ?: return@runCatching
|
||||
modConfig.writeConfig(dispatchConfigListener = false)
|
||||
context.log.verbose("Persisted randomized device profile snapshot to config")
|
||||
context.log.verbose("Persisted randomized device profile data to config")
|
||||
}.onFailure {
|
||||
context.log.warn("Failed to persist randomized device profile snapshot: ${it.message}")
|
||||
context.log.warn("Failed to persist randomized device profile: ${it.message}")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -296,9 +317,9 @@ class DeviceSpooferHook : Feature("Device Spoofer") {
|
||||
|
||||
private fun getRandomizedSettingsToggleState(): RandomizedSettingsToggleState {
|
||||
val config = context.config.experimental.spoof.randomizeDeviceProfile.settingsOptions
|
||||
val secure = config.secure
|
||||
val system = config.system
|
||||
val global = config.global
|
||||
val secure = config.secureSettings
|
||||
val system = config.systemSettings
|
||||
val global = config.globalSettings
|
||||
return RandomizedSettingsToggleState(
|
||||
secure = secure.globalState == true && (secure.base.get() || secure.tts.get()),
|
||||
system = system.globalState == true && (system.base.get() || system.bluetooth.get()),
|
||||
@@ -357,6 +378,10 @@ class DeviceSpooferHook : Feature("Device Spoofer") {
|
||||
supported32BitAbis: List<String>? = null,
|
||||
supported64BitAbis: List<String>? = null
|
||||
) {
|
||||
val safeSupportedAbis = supportedAbis?.takeIf { it.isNotEmpty() } ?: (Build.SUPPORTED_ABIS?.toList() ?: emptyList())
|
||||
val safeSupported32BitAbis = supported32BitAbis?.takeIf { it.isNotEmpty() } ?: (Build.SUPPORTED_32_BIT_ABIS?.toList() ?: emptyList())
|
||||
val safeSupported64BitAbis = supported64BitAbis?.takeIf { it.isNotEmpty() } ?: (Build.SUPPORTED_64_BIT_ABIS?.toList() ?: emptyList())
|
||||
|
||||
Build::class.java.fields.forEach { field ->
|
||||
if (!field.isAccessible) field.isAccessible = true
|
||||
runCatching {
|
||||
@@ -377,9 +402,9 @@ class DeviceSpooferHook : Feature("Device Spoofer") {
|
||||
"DISPLAY" -> if (overrideDisplay) runCatching { field.set(null, display) }
|
||||
"HOST" -> if (overrideHost) runCatching { field.set(null, host) }
|
||||
"TIME" -> if (overrideBuildTime) runCatching { field.setLong(null, buildTime) }
|
||||
"SUPPORTED_ABIS" -> if (overrideAbiLists) runCatching { field.set(null, supportedAbis?.toTypedArray()) }
|
||||
"SUPPORTED_32_BIT_ABIS" -> if (overrideAbiLists) runCatching { field.set(null, supported32BitAbis?.toTypedArray()) }
|
||||
"SUPPORTED_64_BIT_ABIS" -> if (overrideAbiLists) runCatching { field.set(null, supported64BitAbis?.toTypedArray()) }
|
||||
"SUPPORTED_ABIS" -> if (overrideAbiLists) runCatching { field.set(null, safeSupportedAbis.toTypedArray()) }
|
||||
"SUPPORTED_32_BIT_ABIS" -> if (overrideAbiLists) runCatching { field.set(null, safeSupported32BitAbis.toTypedArray()) }
|
||||
"SUPPORTED_64_BIT_ABIS" -> if (overrideAbiLists) runCatching { field.set(null, safeSupported64BitAbis.toTypedArray()) }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -70,33 +70,37 @@ class MediaFilePicker : Feature("Media File Picker") {
|
||||
private const val SNAP_CHUNK_DURATION_MS = 10_000L
|
||||
private val queuedSplitItems = ArrayDeque<Any>()
|
||||
private val queuedSplitItemIds = ArrayDeque<String>()
|
||||
private val queuedSplitCleanupUris = mutableMapOf<String, String>()
|
||||
private val queuedSplitCleanupItems = mutableMapOf<String, PreparedMediaItem>()
|
||||
private var originalUnsplitItem: Any? = null
|
||||
private var reusableOriginalItem: Any? = null
|
||||
private var queuedOverrideType: String? = null
|
||||
private var queuedOverrideSnapDurationMs: Int? = null
|
||||
private var bypassSplitOnce = false
|
||||
private var sendSingleItemHandler: ((Any) -> Boolean)? = null
|
||||
private var cleanupItemHandler: ((String) -> Unit)? = null
|
||||
private var cleanupItemHandler: ((String, String?) -> Unit)? = null
|
||||
fun hasQueuedSplitItems(): Boolean = queuedSplitItems.isNotEmpty()
|
||||
fun hasPendingSplitCleanup(): Boolean = queuedSplitItemIds.isNotEmpty()
|
||||
fun hasOriginalUnsplitItem(): Boolean = originalUnsplitItem != null
|
||||
fun hasReusableOriginalItem(): Boolean = reusableOriginalItem != null
|
||||
fun setQueuedOverrideType(value: String?) {
|
||||
fun setQueuedOverrideType(value: String?, snapDurationMs: Int? = 10_000) {
|
||||
queuedOverrideType = value
|
||||
queuedOverrideSnapDurationMs = if (value == null) null else snapDurationMs
|
||||
}
|
||||
fun getQueuedOverrideType(): String? = queuedOverrideType
|
||||
fun getQueuedOverrideSnapDurationMs(): Int? = queuedOverrideSnapDurationMs
|
||||
fun clearQueuedSplitItems(deleteTempItems: Boolean = true) {
|
||||
if (deleteTempItems) {
|
||||
val cleanup = cleanupItemHandler
|
||||
queuedSplitCleanupUris.values.toList().forEach { uri ->
|
||||
cleanup?.invoke(uri)
|
||||
queuedSplitCleanupItems.values.toList().forEach { item ->
|
||||
cleanup?.invoke(item.uri, item.filePath)
|
||||
}
|
||||
}
|
||||
queuedSplitItems.clear()
|
||||
queuedSplitItemIds.clear()
|
||||
queuedSplitCleanupUris.clear()
|
||||
queuedSplitCleanupItems.clear()
|
||||
originalUnsplitItem = null
|
||||
queuedOverrideType = null
|
||||
queuedOverrideSnapDurationMs = null
|
||||
}
|
||||
fun sendReusableOriginalItem(): Boolean {
|
||||
val item = reusableOriginalItem ?: return false
|
||||
@@ -110,30 +114,34 @@ class MediaFilePicker : Feature("Media File Picker") {
|
||||
items.drop(1).forEach { queuedSplitItems.addLast(it) }
|
||||
preparedItems.forEach {
|
||||
queuedSplitItemIds.addLast(it.itemId)
|
||||
queuedSplitCleanupUris[it.itemId] = it.uri
|
||||
queuedSplitCleanupItems[it.itemId] = it
|
||||
}
|
||||
}
|
||||
fun sendOriginalUnsplitItem(): Boolean {
|
||||
val item = originalUnsplitItem ?: return false
|
||||
val overrideType = queuedOverrideType
|
||||
val overrideSnapDurationMs = queuedOverrideSnapDurationMs
|
||||
clearQueuedSplitItems(deleteTempItems = true)
|
||||
queuedOverrideType = overrideType
|
||||
queuedOverrideSnapDurationMs = overrideSnapDurationMs
|
||||
bypassSplitOnce = true
|
||||
val sender = sendSingleItemHandler ?: return false
|
||||
return sender(item)
|
||||
}
|
||||
fun handleCurrentQueuedItemSuccess(): Boolean {
|
||||
queuedSplitItemIds.removeFirstOrNull()?.let { itemId ->
|
||||
queuedSplitCleanupUris.remove(itemId)?.let { uri ->
|
||||
cleanupItemHandler?.invoke(uri)
|
||||
queuedSplitCleanupItems.remove(itemId)?.let { item ->
|
||||
cleanupItemHandler?.invoke(item.uri, item.filePath)
|
||||
}
|
||||
}
|
||||
if (queuedSplitItems.isEmpty()) {
|
||||
queuedOverrideType = null
|
||||
queuedOverrideSnapDurationMs = null
|
||||
return false
|
||||
}
|
||||
val next = queuedSplitItems.removeFirstOrNull() ?: run {
|
||||
queuedOverrideType = null
|
||||
queuedOverrideSnapDurationMs = null
|
||||
return false
|
||||
}
|
||||
val sender = sendSingleItemHandler ?: return false
|
||||
@@ -151,7 +159,8 @@ class MediaFilePicker : Feature("Media File Picker") {
|
||||
private data class PreparedMediaItem(
|
||||
val itemId: String,
|
||||
val durationMs: Long,
|
||||
val uri: String
|
||||
val uri: String,
|
||||
val filePath: String? = null
|
||||
)
|
||||
|
||||
private fun splitVideoIntoChunks(
|
||||
@@ -301,7 +310,12 @@ class MediaFilePicker : Feature("Media File Picker") {
|
||||
runCatching { resolver.delete(uri, null, null) }
|
||||
}
|
||||
|
||||
return PreparedMediaItem(itemId = itemId, durationMs = durationMs, uri = uri.toString())
|
||||
return PreparedMediaItem(
|
||||
itemId = itemId,
|
||||
durationMs = durationMs,
|
||||
uri = uri.toString(),
|
||||
filePath = file.absolutePath
|
||||
)
|
||||
}
|
||||
|
||||
private fun buildDrawerItems(itemClass: Any, mediaItems: List<PreparedMediaItem>): List<Any> {
|
||||
@@ -423,11 +437,18 @@ class MediaFilePicker : Feature("Media File Picker") {
|
||||
false
|
||||
}
|
||||
}
|
||||
cleanupItemHandler = { uriString ->
|
||||
cleanupItemHandler = { uriString, filePath ->
|
||||
runCatching {
|
||||
// Industrial Cleanup: Direct file deletion is the gold standard for Android 14
|
||||
filePath?.let { path ->
|
||||
val file = File(path)
|
||||
if (file.exists()) file.delete()
|
||||
}
|
||||
context.androidContext.contentResolver.delete(Uri.parse(uriString), null, null)
|
||||
}.onFailure {
|
||||
context.log.warn("MediaFilePicker: Failed to delete temp split media: ${it.message}")
|
||||
if (it.message?.contains("no access") == false) {
|
||||
context.log.warn("MediaFilePicker: Failed to delete temp split media: ${it.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
if (sendItemsHookedHandler === handlerInstance) return@hook
|
||||
|
||||
@@ -385,7 +385,7 @@ object RandomizedDeviceProfileStore {
|
||||
bluetoothMacAddress = bluetoothMac,
|
||||
ipAddress = ipAddress,
|
||||
wifiSsid = region.randomWifiSsid(),
|
||||
wifiRssi = random.nextInt(-72, -36),
|
||||
wifiRssi = randomInt(-72, -36),
|
||||
localeTag = locale.toLanguageTag(),
|
||||
countryIso = region.countryIso,
|
||||
timeZoneId = region.timeZoneId,
|
||||
@@ -450,7 +450,7 @@ object RandomizedDeviceProfileStore {
|
||||
|
||||
private fun randomPublicIpv4(prefixes: List<Int>? = null): String {
|
||||
val firstOctet = prefixes?.takeIf { it.isNotEmpty() }?.let { pick(it) } ?: run {
|
||||
generateSequence { random.nextInt(1, 224) }
|
||||
generateSequence { randomInt(1, 224) }
|
||||
.first { candidate ->
|
||||
candidate != 10 &&
|
||||
candidate != 127 &&
|
||||
@@ -459,13 +459,18 @@ object RandomizedDeviceProfileStore {
|
||||
candidate != 192
|
||||
}
|
||||
}
|
||||
val secondOctet = random.nextInt(1, 255)
|
||||
val thirdOctet = random.nextInt(1, 255)
|
||||
val fourthOctet = random.nextInt(2, 255)
|
||||
val secondOctet = randomInt(1, 255)
|
||||
val thirdOctet = randomInt(1, 255)
|
||||
val fourthOctet = randomInt(2, 255)
|
||||
val candidate = "$firstOctet.$secondOctet.$thirdOctet.$fourthOctet"
|
||||
return runCatching { InetAddress.getByName(candidate).hostAddress }.getOrDefault(candidate)
|
||||
}
|
||||
|
||||
private fun randomInt(minInclusive: Int, maxExclusive: Int): Int {
|
||||
require(maxExclusive > minInclusive)
|
||||
return minInclusive + random.nextInt(maxExclusive - minInclusive)
|
||||
}
|
||||
|
||||
private fun randomLong(minInclusive: Long, maxExclusive: Long): Long {
|
||||
require(maxExclusive > minInclusive)
|
||||
val bound = maxExclusive - minInclusive
|
||||
|
||||
@@ -0,0 +1,217 @@
|
||||
package me.eternal.purrfectsnap.core.features.impl.global
|
||||
|
||||
import android.os.SystemClock
|
||||
import android.view.View
|
||||
import me.eternal.purrfectsnap.core.features.Feature
|
||||
import me.eternal.purrfectsnap.core.ui.hideViewCompletely
|
||||
import me.eternal.purrfectsnap.core.ui.dispatchSyntheticTap
|
||||
import me.eternal.purrfectsnap.core.util.dataBuilder
|
||||
import me.eternal.purrfectsnap.core.util.hook.HookStage
|
||||
import me.eternal.purrfectsnap.core.util.hook.hook
|
||||
import me.eternal.purrfectsnap.core.util.hook.hookConstructor
|
||||
import me.eternal.purrfectsnap.core.util.ktx.getObjectField
|
||||
import me.eternal.purrfectsnap.core.util.ktx.getObjectFieldOrNull
|
||||
import me.eternal.purrfectsnap.core.wrapper.impl.SnapUUID
|
||||
import me.eternal.purrfectsnap.core.wrapper.impl.media.opera.Layer
|
||||
import me.eternal.purrfectsnap.core.wrapper.impl.media.opera.ParamMap
|
||||
import me.eternal.purrfectsnap.mapper.impl.CallbackMapper
|
||||
import me.eternal.purrfectsnap.mapper.impl.OperaPageViewControllerMapper
|
||||
import java.util.ArrayList
|
||||
import java.util.Collections
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
|
||||
class AdBlockFix : Feature("AdBlockFix") {
|
||||
private val adConversationIds = Collections.newSetFromMap(ConcurrentHashMap<String, Boolean>())
|
||||
|
||||
@Volatile
|
||||
private var lastAutoSkippedOperaFingerprint: String? = null
|
||||
|
||||
@Volatile
|
||||
private var lastAutoSkippedOperaAt = 0L
|
||||
|
||||
override fun init() {
|
||||
if (!context.config.global.blockAds.get()) return
|
||||
|
||||
hookFeedEntryTracking()
|
||||
hookMessagingFeedCallbacks()
|
||||
hookOperaAutoSkip()
|
||||
}
|
||||
|
||||
private fun hookFeedEntryTracking() {
|
||||
findClass("com.snapchat.client.messaging.FeedEntry").hookConstructor(HookStage.AFTER) { param ->
|
||||
val feedEntry = param.thisObject<Any>()
|
||||
val conversationId = feedEntry.getObjectFieldOrNull("mConversationId")?.let(::SnapUUID)?.toString()
|
||||
?: return@hookConstructor
|
||||
|
||||
if (isCampaignFeedEntry(feedEntry)) {
|
||||
adConversationIds.add(conversationId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun hookMessagingFeedCallbacks() {
|
||||
context.mappings.useMapper(CallbackMapper::class) {
|
||||
classLoader = context.androidContext.classLoader
|
||||
val callbackMap = callbacks.getAsMap().orEmpty()
|
||||
val hookedCallbacks = mutableSetOf<String>()
|
||||
|
||||
fun hookOnce(
|
||||
callbackClassName: String,
|
||||
methodName: String,
|
||||
block: (param: me.eternal.purrfectsnap.core.util.hook.HookAdapter) -> Unit
|
||||
) {
|
||||
val hookKey = "$callbackClassName#$methodName"
|
||||
if (!hookedCallbacks.add(hookKey)) return
|
||||
runCatching {
|
||||
findClass(callbackClassName).hook(methodName, HookStage.BEFORE) { param ->
|
||||
block(param)
|
||||
}
|
||||
}.onFailure {
|
||||
context.log.warn("Failed to hook $methodName on $callbackClassName")
|
||||
}
|
||||
}
|
||||
|
||||
callbackMap.entries.forEach { (callbackName, callbackClassName) ->
|
||||
val className = callbackClassName ?: return@forEach
|
||||
when {
|
||||
callbackName.startsWith("FetchAndSyncFeed") && callbackName.endsWith("Callback") -> {
|
||||
hookOnce(className, "onFetchAndSyncFeedComplete") { param ->
|
||||
val deletedEntries = param.argNullable<ArrayList<Any>>(2)
|
||||
filterCampaignFeed(param.arg(0), deletedEntries)
|
||||
if (deletedEntries?.isNotEmpty() == true) {
|
||||
param.setArg(4, true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
callbackName.contains("SyncFeed") && callbackName.endsWith("Callback") -> {
|
||||
hookOnce(className, "onSyncFeedComplete") { param ->
|
||||
filterCampaignFeed(param.arg(0), param.argNullable(2))
|
||||
}
|
||||
}
|
||||
|
||||
callbackName == "FetchFeedCallback" || callbackName.contains("FetchFeedCallback") -> {
|
||||
hookOnce(className, "onFetchFeedComplete") { param ->
|
||||
filterCampaignFeed(param.arg(0))
|
||||
}
|
||||
}
|
||||
|
||||
callbackName == "FetchFeedEntriesCallback" || callbackName.contains("FetchFeedEntriesCallback") -> {
|
||||
hookOnce(className, "onFetchFeedEntriesComplete") { param ->
|
||||
filterCampaignFeed(param.arg(0))
|
||||
}
|
||||
}
|
||||
|
||||
callbackName == "QueryFeedCallback" || callbackName.contains("QueryFeedCallback") -> {
|
||||
hookOnce(className, "onQueryFeedComplete") { param ->
|
||||
filterCampaignFeed(param.arg(0))
|
||||
}
|
||||
}
|
||||
|
||||
callbackName == "FeedManagerDelegate" -> {
|
||||
hookOnce(className, "onFeedEntriesUpdated") { param ->
|
||||
filterCampaignFeed(param.arg(0))
|
||||
}
|
||||
hookOnce(className, "onInternalSyncFeed") { param ->
|
||||
filterCampaignFeed(param.arg(0))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun hookOperaAutoSkip() {
|
||||
onNextActivityCreate {
|
||||
context.mappings.useMapper(OperaPageViewControllerMapper::class) {
|
||||
arrayOf(onDisplayStateChange, onDisplayStateChangeGesture).forEach { methodName ->
|
||||
val resolvedMethod = methodName.get() ?: return@forEach
|
||||
classReference.get()?.hook(resolvedMethod, HookStage.AFTER) { param ->
|
||||
val instance = param.thisObject<Any>()
|
||||
val viewState = runCatching {
|
||||
instance::class.java.methods.firstOrNull { it.name.contains("ViewState") || it.name == "g" }?.invoke(instance)?.toString()
|
||||
}.getOrNull() ?: return@hook
|
||||
if (viewState != "FULLY_DISPLAYED") return@hook
|
||||
|
||||
val layerList = runCatching {
|
||||
instance::class.java.methods.firstOrNull { it.name.contains("LayerList") || it.name == "l" }?.invoke(instance) as? ArrayList<*>
|
||||
}.getOrNull() ?: return@hook
|
||||
val paramMap = runCatching {
|
||||
layerList.map { Layer(it).paramMap }.firstOrNull()
|
||||
}.getOrNull() ?: return@hook
|
||||
|
||||
if (!isSpotlightCommercialPage(paramMap)) return@hook
|
||||
|
||||
val fingerprint = buildOperaFingerprint(paramMap)
|
||||
val now = SystemClock.elapsedRealtime()
|
||||
if (fingerprint == lastAutoSkippedOperaFingerprint && now - lastAutoSkippedOperaAt < 1_500L) {
|
||||
return@hook
|
||||
}
|
||||
lastAutoSkippedOperaFingerprint = fingerprint
|
||||
lastAutoSkippedOperaAt = now
|
||||
|
||||
runOnUiThread {
|
||||
context.mainActivity?.window?.decorView?.postDelayed({
|
||||
context.mainActivity?.window?.decorView?.let { decorView ->
|
||||
val x = decorView.width * 0.88f
|
||||
val y = decorView.height * 0.5f
|
||||
decorView.dispatchSyntheticTap(x, y)
|
||||
}
|
||||
}, 70L)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun filterCampaignFeed(entries: ArrayList<Any>, deletedEntries: ArrayList<Any>? = null) {
|
||||
entries.removeIf { feedEntry ->
|
||||
if (!isCampaignFeedEntry(feedEntry)) return@removeIf false
|
||||
val conversationIdInstance = feedEntry.getObjectFieldOrNull("mConversationId") ?: return@removeIf true
|
||||
deletedEntries?.add(createDeletedFeedEntry(conversationIdInstance))
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
private fun createDeletedFeedEntry(conversationIdInstance: Any) =
|
||||
findClass("com.snapchat.client.messaging.DeletedFeedEntry").dataBuilder {
|
||||
from("mFeedEntryIdentifier") {
|
||||
set("mConversationId", conversationIdInstance)
|
||||
}
|
||||
set("mReason", "AD_CAMPAIGN_COMPLETE")
|
||||
}!!
|
||||
|
||||
private fun isCampaignFeedEntry(feedEntry: Any?): Boolean {
|
||||
if (feedEntry == null) return false
|
||||
if (feedEntry.getObjectFieldOrNull("mConversationSubType")?.toString() == "CAMPAIGN") {
|
||||
return true
|
||||
}
|
||||
return feedEntry.getObjectFieldOrNull("mConversationSubTypeMetadata")
|
||||
?.getObjectFieldOrNull("mCampaignMetadata") != null
|
||||
}
|
||||
|
||||
private fun isSpotlightCommercialPage(paramMap: ParamMap): Boolean {
|
||||
val snapSource = paramMap["SNAP_SOURCE"]?.toString()
|
||||
if (snapSource != "SINGLE_SNAP_STORY" && snapSource != "SPOTLIGHT" && snapSource != "PUBLIC_STORY") {
|
||||
return false
|
||||
}
|
||||
|
||||
val adProductType = paramMap["ad_product_type"]?.toString()
|
||||
if (!adProductType.isNullOrBlank() && adProductType != "UNKNOWN" && adProductType != "null") {
|
||||
return true
|
||||
}
|
||||
|
||||
val pageDump = paramMap.toString().uppercase()
|
||||
return pageDump.contains("COMMERCIAL") || pageDump.contains("PROMOTED_STORY")
|
||||
}
|
||||
|
||||
private fun buildOperaFingerprint(paramMap: ParamMap): String {
|
||||
val storyId = paramMap["STORY_ID"]?.toString()
|
||||
val snapId = paramMap["SNAP_ID"]?.toString()
|
||||
?: paramMap["snap_id"]?.toString()
|
||||
val index = paramMap["snap_index_in_story"]?.toString()
|
||||
?: paramMap["SNAP_POSITION_IN_STORY"]?.toString()
|
||||
return listOfNotNull(storyId, snapId, index, paramMap["ad_product_type"]?.toString()).joinToString("|")
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -7,7 +7,7 @@ import me.eternal.purrfectsnap.core.features.Feature
|
||||
|
||||
class DisableMetrics : Feature("DisableMetrics") {
|
||||
override fun init() {
|
||||
if (!context.config.global.disableMetrics.get()) return
|
||||
if (!context.config.global.disableMetrics.get() && context.config.global.performanceMode.profile.getNullable() == null) return
|
||||
|
||||
context.event.subscribe(NetworkApiRequestEvent::class) { param ->
|
||||
val url = param.url
|
||||
|
||||
@@ -8,57 +8,68 @@ import me.eternal.purrfectsnap.core.util.hook.HookStage
|
||||
import me.eternal.purrfectsnap.core.util.hook.hook
|
||||
import me.eternal.purrfectsnap.core.util.hook.hookConstructor
|
||||
import me.eternal.purrfectsnap.mapper.impl.PlusSubscriptionMapper
|
||||
import java.time.LocalDate
|
||||
import java.time.ZoneId
|
||||
import java.time.format.DateTimeFormatter
|
||||
|
||||
class SnapchatPlus: Feature("SnapchatPlus") {
|
||||
private val originalSubscriptionTime = (System.currentTimeMillis() - 7776000000L)
|
||||
private val expirationTimeMillis = (System.currentTimeMillis() + 15552000000L)
|
||||
|
||||
override fun init() {
|
||||
val snapchatPlusTier = context.config.global.snapchatPlus.getNullable()
|
||||
if (snapchatPlusTier == null || snapchatPlusTier == "not_subscribed") return
|
||||
|
||||
if (snapchatPlusTier != null) {
|
||||
context.mappings.useMapper(PlusSubscriptionMapper::class) {
|
||||
classReference.get()?.hookConstructor(HookStage.AFTER) { param ->
|
||||
param.thisObject<Any>().dataBuilder {
|
||||
//subscription tier
|
||||
if (get<Any>(tierField.getAsString()!!)?.javaClass?.isEnum == true) {
|
||||
set(tierField.getAsString()!!, when (snapchatPlusTier) {
|
||||
"not_subscribed" -> "NO_ACCESS"
|
||||
"basic" -> "SNAPCHAT_PLUS"
|
||||
"ad_free" -> "SNAPCHAT_PLUS_AD_FREE"
|
||||
else -> "SNAPCHAT_PLUS"
|
||||
})
|
||||
} else {
|
||||
set(tierField.getAsString()!!, when (snapchatPlusTier) {
|
||||
"not_subscribed" -> 1
|
||||
"basic" -> 2
|
||||
"ad_free" -> 3
|
||||
else -> 2
|
||||
})
|
||||
}
|
||||
// Pre-calculate custom purchase date to eliminate main thread lag
|
||||
val customPurchaseDateRaw = context.config.global.snapchatPlusPurchaseDate.get().trim()
|
||||
val customPurchaseDateMillis = if (customPurchaseDateRaw.isNotEmpty()) {
|
||||
runCatching {
|
||||
LocalDate.parse(customPurchaseDateRaw, DateTimeFormatter.ISO_LOCAL_DATE)
|
||||
.atStartOfDay(ZoneId.systemDefault())
|
||||
.toInstant()
|
||||
.toEpochMilli()
|
||||
}.getOrNull()
|
||||
} else (System.currentTimeMillis() - 7776000000L) // 3 months fallback
|
||||
|
||||
//subscription status
|
||||
set(statusField.getAsString()!!, 2)
|
||||
|
||||
set(originalSubscriptionTimeMillisField.getAsString()!!, originalSubscriptionTime)
|
||||
set(expirationTimeMillisField.getAsString()!!, expirationTimeMillis)
|
||||
context.mappings.useMapper(PlusSubscriptionMapper::class) {
|
||||
classReference.get()?.hookConstructor(HookStage.AFTER) { param ->
|
||||
param.thisObject<Any>().dataBuilder {
|
||||
//subscription tier
|
||||
if (get<Any>(tierField.getAsString()!!)?.javaClass?.isEnum == true) {
|
||||
set(tierField.getAsString()!!, when (snapchatPlusTier) {
|
||||
"not_subscribed" -> "NO_ACCESS"
|
||||
"basic" -> "SNAPCHAT_PLUS"
|
||||
"ad_free" -> "SNAPCHAT_PLUS_AD_FREE"
|
||||
else -> "SNAPCHAT_PLUS"
|
||||
})
|
||||
} else {
|
||||
set(tierField.getAsString()!!, when (snapchatPlusTier) {
|
||||
"not_subscribed" -> 1
|
||||
"basic" -> 2
|
||||
"ad_free" -> 3
|
||||
else -> 2
|
||||
})
|
||||
}
|
||||
|
||||
//subscription status
|
||||
set(statusField.getAsString()!!, 2)
|
||||
|
||||
set(
|
||||
originalSubscriptionTimeMillisField.getAsString()!!,
|
||||
customPurchaseDateMillis
|
||||
)
|
||||
set(expirationTimeMillisField.getAsString()!!, expirationTimeMillis)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Force enable all premium features in the catalog
|
||||
if (context.config.experimental.hiddenSnapchatPlusFeatures.get()) {
|
||||
findClass("com.snap.plus.FeatureCatalog").methods.last {
|
||||
!it.name.contains("init") &&
|
||||
it.parameterTypes.isNotEmpty() &&
|
||||
it.parameterTypes[0].name != "java.lang.Boolean"
|
||||
}.hook(HookStage.BEFORE) { param ->
|
||||
val instance = param.thisObject<Any>()
|
||||
val firstArg = param.argNullable<Any>(0) ?: return@hook
|
||||
|
||||
instance.findFieldNamesByType(firstArg::class.java).forEach { fieldName ->
|
||||
instance.setObjectField(fieldName, firstArg)
|
||||
runCatching {
|
||||
val featureCatalogClass = findClass("com.snap.plus.FeatureCatalog")
|
||||
featureCatalogClass.hook("isFeatureEnabled", HookStage.BEFORE) { param ->
|
||||
param.setResult(true)
|
||||
}
|
||||
context.log.verbose("Successfully unlocked premium Snapchat features")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -257,8 +257,8 @@ class AutoMarkAsRead : Feature("Auto Mark As Read") {
|
||||
val snapManager = context.feature(Messaging::class).snapManager ?: return@hook
|
||||
val stealthMode = context.feature(StealthMode::class)
|
||||
|
||||
// ignore non-stealth mode conversations
|
||||
if (!stealthMode.canUseRule(conversationId.toString())) return@hook
|
||||
// ignore conversations without snap stealth enabled
|
||||
if (!stealthMode.canUseSnapStealth(conversationId.toString())) return@hook
|
||||
|
||||
stealthMode.addSnapInteractionException(clientMessageId)
|
||||
|
||||
|
||||
@@ -62,7 +62,7 @@ class AutoSave : MessagingRuleFeature("Auto Save", MessagingRuleType.AUTO_SAVE)
|
||||
if (messaging.openedConversationUUID?.toString() != targetConversationId) return false
|
||||
}
|
||||
|
||||
if (context.feature(StealthMode::class).canUseRule(targetConversationId)) return false
|
||||
if (context.feature(StealthMode::class).canUseChatStealth(targetConversationId)) return false
|
||||
|
||||
return canUseRule(targetConversationId)
|
||||
}
|
||||
|
||||
@@ -57,7 +57,7 @@ class Messaging : Feature("Messaging") {
|
||||
|
||||
private fun shouldHideBitmojiPresence(stealthMode: StealthMode): Boolean {
|
||||
return context.config.messaging.hideBitmojiPresence.get() ||
|
||||
currentConversationId()?.let { stealthMode.canUseRule(it) } == true
|
||||
currentConversationId()?.let { stealthMode.canUseChatStealth(it) } == true
|
||||
}
|
||||
|
||||
private fun shouldSpoofViewingGalleryPresence(stealthMode: StealthMode): Boolean {
|
||||
@@ -70,12 +70,12 @@ class Messaging : Feature("Messaging") {
|
||||
|
||||
private fun shouldHideTyping(stealthMode: StealthMode, hideTypingIndicator: HideTypingIndicator): Boolean {
|
||||
return context.config.messaging.hideTypingNotifications.get() ||
|
||||
currentConversationId()?.let { stealthMode.canUseRule(it) || hideTypingIndicator.canUseRule(it) } == true
|
||||
currentConversationId()?.let { stealthMode.canUseChatStealth(it) || hideTypingIndicator.canUseRule(it) } == true
|
||||
}
|
||||
|
||||
private fun shouldHidePeek(stealthMode: StealthMode): Boolean {
|
||||
return context.config.messaging.hidePeekAPeek.get() ||
|
||||
currentConversationId()?.let { stealthMode.canUseRule(it) } == true
|
||||
currentConversationId()?.let { stealthMode.canUseChatStealth(it) } == true
|
||||
}
|
||||
|
||||
private fun clearField(instance: Any, typeNamePart: String, shouldClear: Boolean) {
|
||||
@@ -361,4 +361,3 @@ class Messaging : Feature("Messaging") {
|
||||
return (future.get() as? List<*>)?.map { Snapchatter(it) } ?: return emptyList()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -118,7 +118,7 @@ class Notifications : Feature("Notifications") {
|
||||
val intent = SnapWidgetBroadcastReceiverHelper.create(remoteAction) {
|
||||
putExtra("conversation_id", conversationId)
|
||||
putExtra("notification_id", notificationData.id)
|
||||
putExtra("client_message_id", message.messageDescriptor!!.messageId!!)
|
||||
putExtra("client_message_id", message.messageDescriptor!!.messageId!!.toLong())
|
||||
}
|
||||
|
||||
val action = Notification.Action.Builder(null, title, PendingIntent.getBroadcast(
|
||||
@@ -160,7 +160,9 @@ class Notifications : Feature("Notifications") {
|
||||
context.event.subscribe(SnapWidgetBroadcastReceiveEvent::class) { event ->
|
||||
val intent = event.intent ?: return@subscribe
|
||||
val conversationId = intent.getStringExtra("conversation_id") ?: return@subscribe
|
||||
val clientMessageId = intent.getLongExtra("client_message_id", -1)
|
||||
val clientMessageId = intent.getLongExtra("client_message_id", -1L).takeIf { it != -1L }
|
||||
?: intent.getStringExtra("client_message_id")?.toLongOrNull()
|
||||
?: intent.getIntExtra("client_message_id", -1).toLong()
|
||||
val notificationId = intent.getIntExtra("notification_id", -1)
|
||||
|
||||
val updateNotification: (Int, (Notification) -> Unit) -> Unit = { id, notificationBuilder ->
|
||||
@@ -209,10 +211,15 @@ class Notifications : Feature("Notifications") {
|
||||
})
|
||||
}
|
||||
ACTION_DOWNLOAD -> {
|
||||
runCatching {
|
||||
context.feature(MediaDownloader::class).downloadMessageId(clientMessageId, isPreview = false)
|
||||
}.onFailure {
|
||||
context.longToast(it)
|
||||
context.shortToast(context.translation.getCategory("download_processor")["download_started_toast"] ?: "Downloading...")
|
||||
context.coroutineScope.launch(coroutineDispatcher) {
|
||||
runCatching {
|
||||
if (clientMessageId <= 0) throw Exception("Message not found or expired in database.")
|
||||
context.feature(MediaDownloader::class).downloadMessageId(clientMessageId, isPreview = false)
|
||||
}.onFailure {
|
||||
val msg = if (it.message?.contains("not found", true) == true) "Message expired or already viewed." else it.message
|
||||
context.longToast("Download failed: $msg")
|
||||
}
|
||||
}
|
||||
}
|
||||
ACTION_MARK_AS_READ -> {
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
package me.eternal.purrfectsnap.core.features.impl.messaging
|
||||
|
||||
import android.app.Notification
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.app.PendingIntent
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.IntentFilter
|
||||
import android.os.Build
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.background
|
||||
@@ -28,10 +34,12 @@ import androidx.compose.ui.text.input.KeyboardType
|
||||
import kotlinx.coroutines.*
|
||||
import me.eternal.purrfectsnap.bridge.task.TaskListener
|
||||
import me.eternal.purrfectsnap.common.data.ContentType
|
||||
import me.eternal.purrfectsnap.common.config.PropertyValue
|
||||
import me.eternal.purrfectsnap.common.ui.createComposeAlertDialog
|
||||
import me.eternal.purrfectsnap.common.util.protobuf.ProtoEditor
|
||||
import me.eternal.purrfectsnap.common.util.protobuf.ProtoReader
|
||||
import me.eternal.purrfectsnap.common.util.protobuf.ProtoWriter
|
||||
import me.eternal.purrfectsnap.core.ModContext
|
||||
import me.eternal.purrfectsnap.core.event.events.impl.MediaUploadEvent
|
||||
import me.eternal.purrfectsnap.core.event.events.impl.NativeUnaryCallEvent
|
||||
import me.eternal.purrfectsnap.core.event.events.impl.SendMessageWithContentEvent
|
||||
@@ -64,38 +72,126 @@ import kotlin.time.toDuration
|
||||
class SendOverride : Feature("Send Override") {
|
||||
companion object {
|
||||
private const val NOTIFICATION_CHANNEL_ID = "scheduled_send"
|
||||
private const val CONTINUOUS_SEND_CHANNEL_ID = "continuous_send_status"
|
||||
private const val STATUS_NOTIFICATION_ID = 54322
|
||||
private const val COMPLETION_NOTIFICATION_ID = 54323
|
||||
|
||||
const val ACTION_PAUSE_RESUME = "me.eternal.purrfectsnap.CONTINUOUS_SEND_PAUSE_RESUME"
|
||||
const val ACTION_STOP = "me.eternal.purrfectsnap.CONTINUOUS_SEND_STOP"
|
||||
|
||||
private val internalMultipartSend = ThreadLocal.withInitial { false }
|
||||
private var queuedOriginalItemRepeatCount = 0
|
||||
private var queuedOriginalItemRepeatOverrideType: String? = null
|
||||
private var queuedOriginalItemRepeatSnapDurationMs: Int? = null
|
||||
|
||||
private fun queueOriginalItemRepeats(repeatCount: Int, overrideType: String) {
|
||||
// Notification & Loop Tracking
|
||||
private var totalRepeatCount = 0
|
||||
private var processedRepeatCount = 0
|
||||
private var currentRecipientName: String = "Unknown"
|
||||
private val isPaused = java.util.concurrent.atomic.AtomicBoolean(false)
|
||||
private val isStopped = java.util.concurrent.atomic.AtomicBoolean(false)
|
||||
private val engineActive = java.util.concurrent.atomic.AtomicBoolean(true)
|
||||
|
||||
private fun queueOriginalItemRepeats(repeatCount: Int, overrideType: String, snapDurationMs: Int?) {
|
||||
queuedOriginalItemRepeatCount = repeatCount
|
||||
queuedOriginalItemRepeatOverrideType = overrideType
|
||||
MediaFilePicker.setQueuedOverrideType(overrideType)
|
||||
queuedOriginalItemRepeatSnapDurationMs = snapDurationMs
|
||||
MediaFilePicker.setQueuedOverrideType(overrideType, snapDurationMs)
|
||||
}
|
||||
|
||||
private fun clearQueuedOriginalItemRepeats() {
|
||||
queuedOriginalItemRepeatCount = 0
|
||||
queuedOriginalItemRepeatOverrideType = null
|
||||
queuedOriginalItemRepeatSnapDurationMs = null
|
||||
}
|
||||
|
||||
private fun handleQueuedOriginalItemRepeatSuccess(): Boolean {
|
||||
if (queuedOriginalItemRepeatCount <= 0) {
|
||||
private fun updateContinuousSendNotification(context: ModContext) {
|
||||
if (!engineActive.get()) return
|
||||
|
||||
val notificationManager = context.androidContext.getSystemService(NotificationManager::class.java)
|
||||
val remaining = queuedOriginalItemRepeatCount
|
||||
val processed = processedRepeatCount
|
||||
val total = totalRepeatCount
|
||||
val isWorking = remaining > 0 && !isStopped.get() && engineActive.get()
|
||||
|
||||
if (!isWorking) {
|
||||
notificationManager.cancel(STATUS_NOTIFICATION_ID)
|
||||
showCompletionNotification(context, processed, total)
|
||||
return
|
||||
}
|
||||
|
||||
val progressPercent = if (total > 0) (processed * 100) / total else 0
|
||||
|
||||
val builder = Notification.Builder(context.androidContext, CONTINUOUS_SEND_CHANNEL_ID)
|
||||
.setOngoing(true)
|
||||
.setOnlyAlertOnce(true)
|
||||
.setSmallIcon(android.R.drawable.ic_popup_sync)
|
||||
.setColor(0xFF3498DB.toInt())
|
||||
.setContentTitle("Sending Snaps to $currentRecipientName")
|
||||
.setContentText("Progress: $processed / $total ($progressPercent%)")
|
||||
.setSubText("$processed / $total")
|
||||
.setProgress(total, processed, false)
|
||||
|
||||
val pauseResumeLabel = if (isPaused.get()) "Resume" else "Pause"
|
||||
builder.addAction(Notification.Action.Builder(null, pauseResumeLabel, createPendingIntent(context, ACTION_PAUSE_RESUME)).build())
|
||||
builder.addAction(Notification.Action.Builder(null, "Stop", createPendingIntent(context, ACTION_STOP)).build())
|
||||
|
||||
notificationManager.notify(STATUS_NOTIFICATION_ID, builder.build())
|
||||
}
|
||||
|
||||
private fun showCompletionNotification(context: ModContext, sent: Int, total: Int) {
|
||||
val isError = sent < total && !isStopped.get()
|
||||
val title = when {
|
||||
isStopped.get() -> "Continuous Send Stopped"
|
||||
isError -> "Continuous Send Failed"
|
||||
else -> "Continuous Send Finished"
|
||||
}
|
||||
val content = "Sent $sent / $total snaps to $currentRecipientName"
|
||||
|
||||
val notificationManager = context.androidContext.getSystemService(NotificationManager::class.java)
|
||||
val builder = Notification.Builder(context.androidContext, CONTINUOUS_SEND_CHANNEL_ID)
|
||||
.setSmallIcon(if (isError) android.R.drawable.stat_notify_error else android.R.drawable.checkbox_on_background)
|
||||
.setColor(if (isError) 0xFFE74C3C.toInt() else 0xFF2ECC71.toInt())
|
||||
.setContentTitle(title)
|
||||
.setContentText(content)
|
||||
.setAutoCancel(true)
|
||||
|
||||
notificationManager.notify(COMPLETION_NOTIFICATION_ID, builder.build())
|
||||
}
|
||||
|
||||
private fun createPendingIntent(context: ModContext, action: String): PendingIntent {
|
||||
val intent = Intent(action).setPackage(context.androidContext.packageName)
|
||||
return PendingIntent.getBroadcast(
|
||||
context.androidContext,
|
||||
action.hashCode(),
|
||||
intent,
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
||||
)
|
||||
}
|
||||
|
||||
private fun handleQueuedOriginalItemRepeatSuccess(context: ModContext): Boolean {
|
||||
if (isStopped.get() || queuedOriginalItemRepeatCount <= 0) {
|
||||
clearQueuedOriginalItemRepeats()
|
||||
updateContinuousSendNotification(context)
|
||||
return false
|
||||
}
|
||||
|
||||
val overrideType = queuedOriginalItemRepeatOverrideType ?: run {
|
||||
clearQueuedOriginalItemRepeats()
|
||||
updateContinuousSendNotification(context)
|
||||
return false
|
||||
}
|
||||
val snapDurationMs = queuedOriginalItemRepeatSnapDurationMs
|
||||
|
||||
processedRepeatCount++
|
||||
queuedOriginalItemRepeatCount--
|
||||
MediaFilePicker.setQueuedOverrideType(overrideType)
|
||||
updateContinuousSendNotification(context)
|
||||
|
||||
MediaFilePicker.setQueuedOverrideType(overrideType, snapDurationMs)
|
||||
val result = MediaFilePicker.sendReusableOriginalItem()
|
||||
if (!result) {
|
||||
queuedOriginalItemRepeatCount++
|
||||
clearQueuedOriginalItemRepeats()
|
||||
updateContinuousSendNotification(context)
|
||||
}
|
||||
return result
|
||||
}
|
||||
@@ -112,6 +208,20 @@ class SendOverride : Feature("Send Override") {
|
||||
private val backgroundHookLock = Any()
|
||||
private var backgroundHookRefs = 0
|
||||
private var backgroundHooks: List<Hooker.HookHandle>? = null
|
||||
|
||||
private fun createContinuousNotificationChannel() {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
val notificationManager = context.androidContext.getSystemService(NotificationManager::class.java)
|
||||
val channel = NotificationChannel(
|
||||
CONTINUOUS_SEND_CHANNEL_ID,
|
||||
"Continuous Send",
|
||||
NotificationManager.IMPORTANCE_LOW
|
||||
)
|
||||
channel.description = "Progress status for continuous snap sending"
|
||||
notificationManager.createNotificationChannel(channel)
|
||||
}
|
||||
}
|
||||
|
||||
private fun acquireScheduledSendBackground(): () -> Unit {
|
||||
if (!context.config.messaging.scheduledSendAllowRunningInBackground.get()) return {}
|
||||
var enableFailed = false
|
||||
@@ -192,7 +302,35 @@ class SendOverride : Feature("Send Override") {
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
override fun init() {
|
||||
createNotificationChannel()
|
||||
|
||||
createContinuousNotificationChannel()
|
||||
|
||||
val actionReceiver = object : BroadcastReceiver() {
|
||||
override fun onReceive(ctx: Context?, intent: Intent?) {
|
||||
when (intent?.action) {
|
||||
ACTION_PAUSE_RESUME -> {
|
||||
isPaused.set(!isPaused.get())
|
||||
updateContinuousSendNotification(context)
|
||||
}
|
||||
ACTION_STOP -> {
|
||||
isStopped.set(true)
|
||||
if (isPaused.get()) {
|
||||
isPaused.set(false)
|
||||
}
|
||||
updateContinuousSendNotification(context)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
val filter = IntentFilter().apply {
|
||||
addAction(ACTION_PAUSE_RESUME)
|
||||
addAction(ACTION_STOP)
|
||||
}
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
context.androidContext.registerReceiver(actionReceiver, filter, Context.RECEIVER_NOT_EXPORTED)
|
||||
} else {
|
||||
context.androidContext.registerReceiver(actionReceiver, filter)
|
||||
}
|
||||
|
||||
val stripMediaMetadata = context.config.messaging.stripMediaMetadata.get()
|
||||
var postSavePolicy: Int? = null
|
||||
|
||||
@@ -727,10 +865,10 @@ class SendOverride : Feature("Send Override") {
|
||||
completionCallback: Any?
|
||||
): Boolean {
|
||||
val sourceReader = ProtoReader(sourceMessageContent.content ?: return false)
|
||||
val mediaCount = sourceReader.followPath(3)?.getCount(3) ?: 0
|
||||
val mediaCount = (sourceReader.followPath(3) as? ProtoReader)?.getCount(3) ?: 0
|
||||
if (overrideType != "ORIGINAL" && mediaCount > 1) {
|
||||
val mediaBuffers = mutableListOf<ByteArray>()
|
||||
sourceReader.followPath(3)?.eachBuffer { id, buffer ->
|
||||
(sourceReader.followPath(3) as? ProtoReader)?.eachBuffer { id, buffer ->
|
||||
if (id == 3) mediaBuffers.add(buffer)
|
||||
}
|
||||
if (mediaBuffers.isEmpty()) return false
|
||||
@@ -802,22 +940,54 @@ class SendOverride : Feature("Send Override") {
|
||||
if (repeatCount <= 0) return false
|
||||
|
||||
fun sendIteration(index: Int) {
|
||||
val callback = if (index == repeatCount - 1) {
|
||||
originalCallback
|
||||
} else {
|
||||
CallbackBuilder(sendMessageCallbackClass)
|
||||
context.coroutineScope.launch {
|
||||
while (isPaused.get() && !isStopped.get()) {
|
||||
delay(500)
|
||||
}
|
||||
if (isStopped.get()) {
|
||||
clearQueuedOriginalItemRepeats()
|
||||
updateContinuousSendNotification(context)
|
||||
return@launch
|
||||
}
|
||||
|
||||
val callback = CallbackBuilder(sendMessageCallbackClass)
|
||||
.override("onSuccess") {
|
||||
sendIteration(index + 1)
|
||||
processedRepeatCount++
|
||||
queuedOriginalItemRepeatCount--
|
||||
updateContinuousSendNotification(context)
|
||||
|
||||
if (index < repeatCount - 1) {
|
||||
sendIteration(index + 1)
|
||||
} else {
|
||||
// Batch Finished: Trigger original Snapchat callback
|
||||
originalCallback?.let { cb ->
|
||||
runCatching {
|
||||
val method = cb.javaClass.methods.firstOrNull { it.name == "onSuccess" }
|
||||
if (method != null) {
|
||||
if (method.parameterCount == 0) {
|
||||
method.invoke(cb)
|
||||
} else {
|
||||
// Pass null for all required parameters to safely trigger the completion UI
|
||||
method.invoke(cb, *arrayOfNulls<Any>(method.parameterCount))
|
||||
}
|
||||
}
|
||||
}.onFailure { context.log.error("Failed to trigger completion handshake", it) }
|
||||
}
|
||||
}
|
||||
}
|
||||
.override("onError", shouldUnhook = false) {
|
||||
invokeCallbackError(originalCallback, it.argNullable<Any>(0))
|
||||
clearQueuedOriginalItemRepeats()
|
||||
updateContinuousSendNotification(context)
|
||||
}
|
||||
.build()
|
||||
}
|
||||
|
||||
val preparedContent = createMessageContentFromOriginal()
|
||||
if (!sendMediaManual(preparedContent, overrideType, snapDurationMs, callback)) {
|
||||
invokeCallbackError(originalCallback, "Failed to send")
|
||||
if (index > 0) delay(1000) // Human-like delay
|
||||
|
||||
val preparedContent = createMessageContentFromOriginal()
|
||||
if (!sendMediaManual(preparedContent, overrideType, snapDurationMs, callback)) {
|
||||
invokeCallbackError(originalCallback, "Failed to send")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -830,15 +1000,21 @@ class SendOverride : Feature("Send Override") {
|
||||
return applyOverride(localMessageContent, messageProtoReader, overrideType, snapDurationMs)
|
||||
}
|
||||
|
||||
val resolvedOverrideType = MediaFilePicker.getQueuedOverrideType()
|
||||
val queuedOverrideType = MediaFilePicker.getQueuedOverrideType()
|
||||
val resolvedOverrideType = queuedOverrideType
|
||||
?: configOverrideType?.takeIf { it != "always_ask" }
|
||||
val resolvedSnapDurationMs = if (queuedOverrideType != null) {
|
||||
MediaFilePicker.getQueuedOverrideSnapDurationMs()
|
||||
} else {
|
||||
10000
|
||||
}
|
||||
|
||||
fun attachQueuedRepeatCallbacks(sendEvent: SendMessageWithContentEvent) {
|
||||
sendEvent.addCallbackResult("onSuccess") {
|
||||
context.runOnUiThread {
|
||||
val handledSplit = MediaFilePicker.handleCurrentQueuedItemSuccess()
|
||||
val handledRepeat = if (!handledSplit) {
|
||||
handleQueuedOriginalItemRepeatSuccess()
|
||||
handleQueuedOriginalItemRepeatSuccess(context)
|
||||
} else {
|
||||
false
|
||||
}
|
||||
@@ -858,7 +1034,7 @@ class SendOverride : Feature("Send Override") {
|
||||
if (MediaFilePicker.hasPendingSplitCleanup() || MediaFilePicker.getQueuedOverrideType() != null || queuedOriginalItemRepeatCount > 0) {
|
||||
attachQueuedRepeatCallbacks(event)
|
||||
}
|
||||
if (sendMedia(resolvedOverrideType, 10000)) {
|
||||
if (sendMedia(resolvedOverrideType, resolvedSnapDurationMs)) {
|
||||
if (event.canceled) invokeOriginalAndRestoreResult(event)
|
||||
}
|
||||
return@subscribe
|
||||
@@ -866,7 +1042,6 @@ class SendOverride : Feature("Send Override") {
|
||||
|
||||
context.runOnUiThread {
|
||||
val recipientNameForTask = recipientName
|
||||
val mediaCount = messageProtoReader.followPath(3)?.getCount(3) ?: 0
|
||||
|
||||
createComposeAlertDialog(context.mainActivity!!) { alertDialog ->
|
||||
PurrfectOverlayTheme {
|
||||
@@ -1274,6 +1449,11 @@ class SendOverride : Feature("Send Override") {
|
||||
}
|
||||
Button(onClick = {
|
||||
val finalSelectedType = selectedType
|
||||
val selectedSnapDurationMs = if (finalSelectedType != "SAVEABLE_SNAP") {
|
||||
convertDuration(customDuration)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
val repeatCount = if (continuousSendEnabled) {
|
||||
continuousSendCount.toIntOrNull()?.takeIf { it > 0 }
|
||||
} else {
|
||||
@@ -1295,13 +1475,13 @@ class SendOverride : Feature("Send Override") {
|
||||
}
|
||||
alertDialog.dismiss()
|
||||
if (disableSplitForCurrentSend && MediaFilePicker.hasOriginalUnsplitItem()) {
|
||||
MediaFilePicker.setQueuedOverrideType(finalSelectedType)
|
||||
MediaFilePicker.setQueuedOverrideType(finalSelectedType, selectedSnapDurationMs)
|
||||
if (!MediaFilePicker.sendOriginalUnsplitItem()) {
|
||||
MediaFilePicker.setQueuedOverrideType(null)
|
||||
}
|
||||
return@Button
|
||||
} else if (MediaFilePicker.hasPendingSplitCleanup()) {
|
||||
MediaFilePicker.setQueuedOverrideType(finalSelectedType)
|
||||
MediaFilePicker.setQueuedOverrideType(finalSelectedType, selectedSnapDurationMs)
|
||||
event.addCallbackResult("onSuccess") {
|
||||
context.runOnUiThread {
|
||||
if (!MediaFilePicker.handleCurrentQueuedItemSuccess()) {
|
||||
@@ -1361,7 +1541,7 @@ class SendOverride : Feature("Send Override") {
|
||||
if (sendRepeatedMediaManual(
|
||||
repeatCount,
|
||||
finalSelectedType,
|
||||
if (finalSelectedType != "SAVEABLE_SNAP") convertDuration(customDuration) else null
|
||||
selectedSnapDurationMs
|
||||
)) {
|
||||
val successText = context.translation.format("schedule_sent_to", "name" to recipientNameForTask) ?: "Sent to $recipientNameForTask"
|
||||
context.inAppOverlay.showStatusToast(
|
||||
@@ -1410,22 +1590,32 @@ class SendOverride : Feature("Send Override") {
|
||||
}
|
||||
} else {
|
||||
if (repeatCount == 1) {
|
||||
if (sendMedia(finalSelectedType, if (finalSelectedType != "SAVEABLE_SNAP") convertDuration(customDuration) else null)) {
|
||||
if (sendMedia(finalSelectedType, selectedSnapDurationMs)) {
|
||||
invokeOriginalAndRestoreResult(event)
|
||||
}
|
||||
} else if (MediaFilePicker.hasReusableOriginalItem()) {
|
||||
queueOriginalItemRepeats(repeatCount - 1, finalSelectedType)
|
||||
totalRepeatCount = repeatCount
|
||||
processedRepeatCount = 1
|
||||
currentRecipientName = recipientNameForTask
|
||||
|
||||
queueOriginalItemRepeats(repeatCount - 1, finalSelectedType, selectedSnapDurationMs)
|
||||
attachQueuedRepeatCallbacks(event)
|
||||
if (sendMedia(finalSelectedType, if (finalSelectedType != "SAVEABLE_SNAP") convertDuration(customDuration) else null)) {
|
||||
if (sendMedia(finalSelectedType, selectedSnapDurationMs)) {
|
||||
invokeOriginalAndRestoreResult(event)
|
||||
} else {
|
||||
clearQueuedOriginalItemRepeats()
|
||||
updateContinuousSendNotification(context)
|
||||
}
|
||||
} else {
|
||||
totalRepeatCount = repeatCount
|
||||
processedRepeatCount = 0
|
||||
queuedOriginalItemRepeatCount = repeatCount
|
||||
currentRecipientName = recipientNameForTask
|
||||
|
||||
sendRepeatedMediaManual(
|
||||
repeatCount,
|
||||
finalSelectedType,
|
||||
if (finalSelectedType != "SAVEABLE_SNAP") convertDuration(customDuration) else null
|
||||
selectedSnapDurationMs
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -146,13 +146,17 @@ class MessageLogger : MessagingRuleFeature("MessageLogger", MessagingRuleType.ME
|
||||
it.messageId = uniqueMessageIdentifier
|
||||
it.conversationId = conversationId
|
||||
it.userId = event.message.senderId.toString()
|
||||
it.username = usernameCache.getOrPut(it.userId) {
|
||||
context.database.getFriendInfo(it.userId)?.mutableUsername ?: it.userId
|
||||
}
|
||||
it.username = usernameCache[it.userId]
|
||||
?: context.database.getFriendInfo(it.userId)?.mutableUsername?.also { resolvedUsername ->
|
||||
usernameCache[it.userId] = resolvedUsername
|
||||
}
|
||||
?: it.userId
|
||||
it.sendTimestamp = event.message.messageMetadata?.createdAt ?: System.currentTimeMillis()
|
||||
it.groupTitle = groupTitleCache.getOrPut(conversationId) {
|
||||
context.database.getFeedEntryByConversationId(conversationId)?.feedDisplayName ?: conversationId
|
||||
}
|
||||
it.groupTitle = groupTitleCache[conversationId]
|
||||
?: context.database.getFeedEntryByConversationId(conversationId)?.feedDisplayName?.also { resolvedGroupTitle ->
|
||||
groupTitleCache[conversationId] = resolvedGroupTitle
|
||||
}
|
||||
?: conversationId
|
||||
it.messageData = context.gson.toJson(messageInstance).toByteArray(Charsets.UTF_8)
|
||||
}
|
||||
)
|
||||
|
||||
@@ -1,16 +1,55 @@
|
||||
package me.eternal.purrfectsnap.core.features.impl.spying
|
||||
|
||||
import me.eternal.purrfectsnap.common.data.MessagingRuleType
|
||||
import me.eternal.purrfectsnap.common.data.RuleState
|
||||
import me.eternal.purrfectsnap.core.event.events.impl.OnSnapInteractionEvent
|
||||
import me.eternal.purrfectsnap.core.features.MessagingRuleFeature
|
||||
import me.eternal.purrfectsnap.core.util.hook.HookStage
|
||||
import me.eternal.purrfectsnap.core.util.hook.hook
|
||||
import me.eternal.purrfectsnap.core.wrapper.impl.SnapUUID
|
||||
import java.util.concurrent.CopyOnWriteArraySet
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
|
||||
class StealthMode : MessagingRuleFeature("StealthMode", MessagingRuleType.STEALTH) {
|
||||
private val displayedMessageQueue = CopyOnWriteArraySet<Long>()
|
||||
private val snapInteractionQueue = CopyOnWriteArraySet<Long>()
|
||||
private val ruleSnapshotCache = ConcurrentHashMap<String, Pair<Long, Set<MessagingRuleType>>>()
|
||||
|
||||
private fun getTargetId(conversationId: String): String {
|
||||
return context.database.getDMOtherParticipant(conversationId) ?: conversationId
|
||||
}
|
||||
|
||||
private fun getRuleSnapshot(conversationId: String): Set<MessagingRuleType> {
|
||||
val targetId = getTargetId(conversationId)
|
||||
val now = System.currentTimeMillis()
|
||||
ruleSnapshotCache[targetId]?.takeIf { now - it.first < 1_000L }?.let { return it.second }
|
||||
return context.bridgeClient.getRules(targetId).toSet().also { rules ->
|
||||
ruleSnapshotCache[targetId] = now to rules
|
||||
}
|
||||
}
|
||||
|
||||
private fun isRuleActive(
|
||||
conversationId: String,
|
||||
ruleType: MessagingRuleType
|
||||
): Boolean {
|
||||
val ruleState = context.config.rules.getRuleState(ruleType) ?: return false
|
||||
val enabled = ruleType in getRuleSnapshot(conversationId)
|
||||
return if (ruleState == RuleState.BLACKLIST) !enabled else enabled
|
||||
}
|
||||
|
||||
fun canUseChatStealth(conversationId: String): Boolean {
|
||||
return isRuleActive(conversationId, MessagingRuleType.STEALTH) ||
|
||||
isRuleActive(conversationId, MessagingRuleType.CHAT_STEALTH)
|
||||
}
|
||||
|
||||
fun canUseSnapStealth(conversationId: String): Boolean {
|
||||
return isRuleActive(conversationId, MessagingRuleType.STEALTH) ||
|
||||
isRuleActive(conversationId, MessagingRuleType.SNAP_STEALTH)
|
||||
}
|
||||
|
||||
fun isAnyStealthEnabled(conversationId: String): Boolean {
|
||||
return canUseChatStealth(conversationId) || canUseSnapStealth(conversationId)
|
||||
}
|
||||
|
||||
fun addDisplayedMessageException(clientMessageId: Long) {
|
||||
displayedMessageQueue.add(clientMessageId)
|
||||
@@ -22,12 +61,10 @@ class StealthMode : MessagingRuleFeature("StealthMode", MessagingRuleType.STEALT
|
||||
|
||||
|
||||
override fun init() {
|
||||
val isConversationInStealthMode: (SnapUUID) -> Boolean = { canUseRule(it.toString()) }
|
||||
|
||||
arrayOf("mediaMessagesDisplayed", "displayedMessages").forEach { methodName: String ->
|
||||
context.classCache.conversationManager.hook(methodName, HookStage.BEFORE) { param ->
|
||||
if (displayedMessageQueue.removeIf { param.arg<Long>(1) == it }) return@hook
|
||||
if (isConversationInStealthMode(SnapUUID(param.arg(0)))) {
|
||||
if (canUseChatStealth(SnapUUID(param.arg(0)).toString())) {
|
||||
param.setResult(null)
|
||||
}
|
||||
}
|
||||
@@ -35,7 +72,7 @@ class StealthMode : MessagingRuleFeature("StealthMode", MessagingRuleType.STEALT
|
||||
|
||||
context.event.subscribe(OnSnapInteractionEvent::class) { event ->
|
||||
if (snapInteractionQueue.removeIf { event.messageId == it }) return@subscribe
|
||||
if (isConversationInStealthMode(event.conversationId)) {
|
||||
if (canUseSnapStealth(event.conversationId.toString())) {
|
||||
event.canceled = true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import android.hardware.camera2.CameraCharacteristics.Key
|
||||
import android.hardware.camera2.CameraManager
|
||||
import android.media.Image
|
||||
import android.media.ImageReader
|
||||
import android.os.Build
|
||||
import android.util.Range
|
||||
import me.eternal.purrfectsnap.core.features.Feature
|
||||
import me.eternal.purrfectsnap.core.util.hook.HookStage
|
||||
@@ -28,6 +29,9 @@ class CameraTweaks : Feature("Camera Tweaks") {
|
||||
@SuppressLint("MissingPermission", "DiscouragedApi")
|
||||
override fun init() {
|
||||
val config = context.config.camera
|
||||
val skipUnstableStillCaptureTweaks = Build.MANUFACTURER.equals("samsung", ignoreCase = true) ||
|
||||
Build.HARDWARE.contains("exynos", ignoreCase = true) ||
|
||||
Build.BRAND.equals("samsung", ignoreCase = true)
|
||||
|
||||
// Toggle A: Audio & Video Optimizations (Bitrates)
|
||||
if (config.audioVideoOptimizations.get()) {
|
||||
@@ -44,7 +48,7 @@ class CameraTweaks : Feature("Camera Tweaks") {
|
||||
}
|
||||
|
||||
// Toggle B: Camera Optimizations (Hardware ISP - UNSTABLE)
|
||||
if (config.cameraOptimizations.get()) {
|
||||
if (config.cameraOptimizations.get() && !skipUnstableStillCaptureTweaks) {
|
||||
CaptureRequest.Builder::class.java.hook("set", HookStage.BEFORE) { param ->
|
||||
val key = param.arg<CaptureRequest.Key<*>>(0)
|
||||
when (key) {
|
||||
|
||||
@@ -0,0 +1,357 @@
|
||||
package me.eternal.purrfectsnap.core.features.impl.tweaks
|
||||
|
||||
import android.animation.ValueAnimator
|
||||
import android.app.Activity
|
||||
import android.app.Dialog
|
||||
import android.database.sqlite.SQLiteDatabase
|
||||
import android.hardware.camera2.CaptureRequest
|
||||
import android.media.MediaRecorder
|
||||
import android.os.Build
|
||||
import android.os.HandlerThread
|
||||
import android.os.Process
|
||||
import android.transition.Transition
|
||||
import android.util.Range
|
||||
import android.view.View
|
||||
import android.view.TextureView
|
||||
import android.view.ViewPropertyAnimator
|
||||
import android.view.WindowManager
|
||||
import android.view.animation.Animation
|
||||
import android.widget.OverScroller
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import androidx.recyclerview.widget.StaggeredGridLayoutManager
|
||||
import me.eternal.purrfectsnap.core.features.Feature
|
||||
import me.eternal.purrfectsnap.core.event.events.impl.NetworkApiRequestEvent
|
||||
import me.eternal.purrfectsnap.core.util.hook.HookStage
|
||||
import me.eternal.purrfectsnap.core.util.hook.findRestrictedMethod
|
||||
import me.eternal.purrfectsnap.core.util.hook.hook
|
||||
import me.eternal.purrfectsnap.core.util.hook.hookConstructor
|
||||
import okhttp3.Dispatcher
|
||||
import java.lang.Thread
|
||||
import java.lang.reflect.Method
|
||||
import java.util.concurrent.ThreadPoolExecutor
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
|
||||
class PerformanceMode : Feature("Performance Mode") {
|
||||
override fun init() {
|
||||
val profile = context.config.global.performanceMode.profile.getNullable() ?: return
|
||||
val isMaxProfile = profile == "max"
|
||||
val threadPriority = if (isMaxProfile) {
|
||||
Process.THREAD_PRIORITY_DISPLAY
|
||||
} else {
|
||||
Process.THREAD_PRIORITY_MORE_FAVORABLE
|
||||
}
|
||||
val minimumFrameRate = if (isMaxProfile) 60 else 45
|
||||
val durationScale = if (isMaxProfile) 0.35f else 0.55f
|
||||
val recyclerViewCacheSize = if (isMaxProfile) 64 else 32
|
||||
val maxRequests = if (isMaxProfile) 192 else 96
|
||||
val maxRequestsPerHost = if (isMaxProfile) 32 else 16
|
||||
val minimumCoreThreads = if (isMaxProfile) 16 else 8
|
||||
val prefetchItemCount = if (isMaxProfile) 24 else 12
|
||||
val maxAnimationDurationMs = if (isMaxProfile) 90L else 140L
|
||||
val maxScrollDurationMs = if (isMaxProfile) 72 else 180
|
||||
val preferredRefreshRate = if (isMaxProfile) 120f else 90f
|
||||
|
||||
context.log.info(
|
||||
"Performance mode enabled: profile=$profile, threadPriority=$threadPriority, minFps=$minimumFrameRate, durationScale=$durationScale, rvCache=$recyclerViewCacheSize, maxRequests=$maxRequests/$maxRequestsPerHost, minCoreThreads=$minimumCoreThreads, prefetch=$prefetchItemCount, maxAnimMs=$maxAnimationDurationMs, maxScrollMs=$maxScrollDurationMs, preferredRefreshRate=$preferredRefreshRate",
|
||||
"PerformanceMode"
|
||||
)
|
||||
|
||||
runCatching {
|
||||
ValueAnimator.setFrameDelay(0L)
|
||||
}
|
||||
|
||||
fun isPerformanceSensitiveThread(name: String?): Boolean {
|
||||
val normalizedName = name?.lowercase() ?: return false
|
||||
return listOf("camera", "preview", "codec", "render", "gl", "transcod", "lens", "feed", "story", "opera", "messag", "network", "db", "disk", "map", "mapbox", "snapmap", "viewport").any {
|
||||
normalizedName.contains(it)
|
||||
}
|
||||
}
|
||||
|
||||
fun clampPositiveDuration(durationMs: Long, maxDurationMs: Long): Long {
|
||||
if (durationMs <= 0L) return durationMs
|
||||
return durationMs.coerceAtMost(maxDurationMs)
|
||||
}
|
||||
|
||||
context.event.subscribe(NetworkApiRequestEvent::class) { event ->
|
||||
if (!isMaxProfile) return@subscribe
|
||||
val url = event.url
|
||||
if (url.contains("mapbox") && (url.contains("events.") || url.contains("telemetry"))) {
|
||||
event.canceled = true
|
||||
}
|
||||
}
|
||||
|
||||
HandlerThread::class.java.hookConstructor(HookStage.BEFORE) { param ->
|
||||
if (param.args().size < 2) return@hookConstructor
|
||||
val threadName = param.argNullable<String>(0)
|
||||
if (!isPerformanceSensitiveThread(threadName)) return@hookConstructor
|
||||
param.setArg(1, threadPriority)
|
||||
}
|
||||
|
||||
HandlerThread::class.java.hook("start", HookStage.AFTER) { param ->
|
||||
val thread = param.nullableThisObject<Any>() as? HandlerThread ?: return@hook
|
||||
if (!isPerformanceSensitiveThread(thread.name)) return@hook
|
||||
runCatching {
|
||||
val tid = thread.threadId
|
||||
if (tid > 0) {
|
||||
Process.setThreadPriority(tid, threadPriority)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Thread::class.java.hook("start", HookStage.AFTER) { param ->
|
||||
val thread = param.thisObject<Thread>()
|
||||
if (!isPerformanceSensitiveThread(thread.name)) return@hook
|
||||
runCatching {
|
||||
thread.priority = if (isMaxProfile) Thread.MAX_PRIORITY else Thread.NORM_PRIORITY + 1
|
||||
}
|
||||
}
|
||||
|
||||
ThreadPoolExecutor::class.java.hookConstructor(HookStage.AFTER) { param ->
|
||||
val executor = param.thisObject<ThreadPoolExecutor>()
|
||||
runCatching {
|
||||
val targetCorePoolSize = executor.maximumPoolSize.coerceAtLeast(1).coerceAtMost(minimumCoreThreads.coerceAtLeast(executor.corePoolSize))
|
||||
if (executor.corePoolSize < targetCorePoolSize) {
|
||||
executor.corePoolSize = targetCorePoolSize
|
||||
}
|
||||
executor.allowCoreThreadTimeOut(false)
|
||||
executor.prestartAllCoreThreads()
|
||||
}
|
||||
}
|
||||
|
||||
Dispatcher::class.java.hookConstructor(HookStage.AFTER) { param ->
|
||||
val dispatcher = param.thisObject<Dispatcher>()
|
||||
runCatching {
|
||||
dispatcher.maxRequests = maxRequests
|
||||
dispatcher.maxRequestsPerHost = maxRequestsPerHost
|
||||
}
|
||||
}
|
||||
|
||||
ValueAnimator::class.java.hook("getDurationScale", HookStage.AFTER) { param ->
|
||||
param.setResult(durationScale)
|
||||
}
|
||||
|
||||
ValueAnimator::class.java.hook("setDuration", HookStage.BEFORE) { param ->
|
||||
val original = param.arg<Long>(0)
|
||||
val updated = original.coerceAtMost(maxAnimationDurationMs)
|
||||
if (updated != original) {
|
||||
param.setArg(0, updated)
|
||||
}
|
||||
}
|
||||
|
||||
ViewPropertyAnimator::class.java.hook("setDuration", HookStage.BEFORE) { param ->
|
||||
val original = param.arg<Long>(0)
|
||||
val updated = original.coerceAtMost(maxAnimationDurationMs)
|
||||
if (updated != original) {
|
||||
param.setArg(0, updated)
|
||||
}
|
||||
}
|
||||
|
||||
Transition::class.java.hook("setDuration", HookStage.BEFORE) { param ->
|
||||
val original = param.arg<Long>(0)
|
||||
val updated = original.coerceAtMost(maxAnimationDurationMs)
|
||||
if (updated != original) {
|
||||
param.setArg(0, updated)
|
||||
}
|
||||
}
|
||||
|
||||
Animation::class.java.hook("setDuration", HookStage.BEFORE) { param ->
|
||||
val original = param.arg<Long>(0)
|
||||
val updated = original.coerceAtMost(maxAnimationDurationMs)
|
||||
if (updated != original) {
|
||||
param.setArg(0, updated)
|
||||
}
|
||||
}
|
||||
|
||||
RecyclerView::class.java.hookConstructor(HookStage.AFTER) { param ->
|
||||
val recyclerView = param.thisObject<RecyclerView>()
|
||||
recyclerView.setItemViewCacheSize(recyclerViewCacheSize)
|
||||
recyclerView.overScrollMode = View.OVER_SCROLL_NEVER
|
||||
recyclerView.recycledViewPool.setMaxRecycledViews(0, 20)
|
||||
if (isMaxProfile) {
|
||||
recyclerView.itemAnimator = null
|
||||
}
|
||||
}
|
||||
|
||||
RecyclerView::class.java.hook("setAdapter", HookStage.AFTER) { param ->
|
||||
val recyclerView = param.thisObject<RecyclerView>()
|
||||
recyclerView.setItemViewCacheSize(recyclerViewCacheSize)
|
||||
recyclerView.recycledViewPool.setMaxRecycledViews(0, 20)
|
||||
if (isMaxProfile) {
|
||||
recyclerView.itemAnimator = null
|
||||
}
|
||||
}
|
||||
|
||||
RecyclerView::class.java.hook("setLayoutManager", HookStage.AFTER) { param ->
|
||||
val recyclerView = param.thisObject<RecyclerView>()
|
||||
val layoutManager = param.argNullable<Any>(0)
|
||||
when (layoutManager) {
|
||||
is LinearLayoutManager -> {
|
||||
layoutManager.isItemPrefetchEnabled = true
|
||||
layoutManager.initialPrefetchItemCount = prefetchItemCount.coerceAtLeast(12)
|
||||
}
|
||||
is StaggeredGridLayoutManager -> {
|
||||
layoutManager.isItemPrefetchEnabled = true
|
||||
layoutManager.gapStrategy = StaggeredGridLayoutManager.GAP_HANDLING_MOVE_ITEMS_BETWEEN_SPANS
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun SQLiteDatabase.applyPerformancePragmas() {
|
||||
runCatching { execSQL("PRAGMA synchronous = NORMAL") }
|
||||
runCatching { execSQL("PRAGMA temp_store = MEMORY") }
|
||||
runCatching { execSQL("PRAGMA cache_size = -32768") }
|
||||
runCatching { execSQL("PRAGMA mmap_size = 268435456") }
|
||||
runCatching { execSQL("PRAGMA journal_size_limit = 1048576") }
|
||||
runCatching { execSQL("PRAGMA optimize") }
|
||||
}
|
||||
|
||||
SQLiteDatabase::class.java.hook("openDatabase", HookStage.AFTER) { param ->
|
||||
(param.getResult() as? SQLiteDatabase)?.applyPerformancePragmas()
|
||||
}
|
||||
|
||||
SQLiteDatabase::class.java.hook("openOrCreateDatabase", HookStage.AFTER) { param ->
|
||||
(param.getResult() as? SQLiteDatabase)?.applyPerformancePragmas()
|
||||
}
|
||||
|
||||
MediaRecorder::class.java.hook("setVideoFrameRate", HookStage.BEFORE) { param ->
|
||||
val currentRate = param.arg<Int>(0)
|
||||
val applied = currentRate
|
||||
.coerceAtLeast(if (isMaxProfile) 30 else 24)
|
||||
.coerceAtMost(if (isMaxProfile) 60 else 45)
|
||||
if (applied != currentRate) {
|
||||
param.setArg(0, applied)
|
||||
}
|
||||
}
|
||||
|
||||
OverScroller::class.java.hook("startScroll", HookStage.BEFORE) { param ->
|
||||
if (param.args().size >= 5) {
|
||||
val original = param.arg<Int>(4)
|
||||
val updated = original.coerceAtMost(maxScrollDurationMs)
|
||||
if (updated != original) {
|
||||
param.setArg(4, updated)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
OverScroller::class.java.hook("fling", HookStage.BEFORE) { param ->
|
||||
if (param.args().size >= 10) {
|
||||
val overX = param.arg<Int>(8)
|
||||
val overY = param.arg<Int>(9)
|
||||
if (overX != 0) param.setArg(8, 0)
|
||||
if (overY != 0) param.setArg(9, 0)
|
||||
}
|
||||
}
|
||||
|
||||
runCatching {
|
||||
val nativeMapViewClass = findClass("com.mapbox.mapboxsdk.maps.NativeMapView")
|
||||
val transitionOptionsClass = findClass("com.mapbox.mapboxsdk.style.layers.TransitionOptions")
|
||||
val transitionOptionsCtor = transitionOptionsClass.getDeclaredConstructor(Long::class.javaPrimitiveType, Long::class.javaPrimitiveType, Boolean::class.javaPrimitiveType).apply {
|
||||
isAccessible = true
|
||||
}
|
||||
|
||||
fun findNativeMapMethod(name: String, predicate: (Method) -> Boolean): Method? {
|
||||
return nativeMapViewClass.findRestrictedMethod { method ->
|
||||
method.name == name && predicate(method)
|
||||
}?.apply {
|
||||
isAccessible = true
|
||||
}
|
||||
}
|
||||
|
||||
val nativeCancelTransitions = findNativeMapMethod("nativeCancelTransitions") { it.parameterCount == 0 }
|
||||
val nativeSetPrefetchTiles = findNativeMapMethod("nativeSetPrefetchTiles") { it.parameterCount == 1 && it.parameterTypes[0] == Boolean::class.javaPrimitiveType }
|
||||
val nativeSetPrefetchZoomDelta = findNativeMapMethod("nativeSetPrefetchZoomDelta") { it.parameterCount == 1 && it.parameterTypes[0] == Int::class.javaPrimitiveType }
|
||||
val nativeSetTransitionDelay = findNativeMapMethod("nativeSetTransitionDelay") { it.parameterCount == 1 && it.parameterTypes[0] == Long::class.javaPrimitiveType }
|
||||
val nativeSetTransitionDuration = findNativeMapMethod("nativeSetTransitionDuration") { it.parameterCount == 1 && it.parameterTypes[0] == Long::class.javaPrimitiveType }
|
||||
val nativeSetTransitionOptions = findNativeMapMethod("nativeSetTransitionOptions") { it.parameterCount == 1 && it.parameterTypes[0].name == transitionOptionsClass.name }
|
||||
|
||||
nativeMapViewClass.hookConstructor(HookStage.AFTER) { param ->
|
||||
val nativeMapView = param.thisObject<Any>()
|
||||
runCatching {
|
||||
nativeSetPrefetchTiles?.invoke(nativeMapView, true)
|
||||
nativeSetPrefetchZoomDelta?.invoke(nativeMapView, 6)
|
||||
nativeSetTransitionDelay?.invoke(nativeMapView, 0L)
|
||||
nativeSetTransitionDuration?.invoke(nativeMapView, 0L)
|
||||
nativeSetTransitionOptions?.invoke(
|
||||
nativeMapView,
|
||||
transitionOptionsCtor.newInstance(0L, 0L, false)
|
||||
)
|
||||
nativeCancelTransitions?.invoke(nativeMapView)
|
||||
}
|
||||
}
|
||||
|
||||
nativeMapViewClass.findRestrictedMethod { method ->
|
||||
method.name == "g" &&
|
||||
method.parameterCount == 6 &&
|
||||
method.parameterTypes.last() == Long::class.javaPrimitiveType
|
||||
}?.hook(HookStage.BEFORE) { param ->
|
||||
val original = param.arg<Long>(5)
|
||||
val applied = original.coerceAtMost(16L)
|
||||
if (applied != original) {
|
||||
param.setArg(5, applied)
|
||||
}
|
||||
runCatching { nativeCancelTransitions?.invoke(param.thisObject<Any>()) }
|
||||
}
|
||||
|
||||
nativeMapViewClass.findRestrictedMethod { method ->
|
||||
method.name == "v" &&
|
||||
method.parameterCount == 3 &&
|
||||
method.parameterTypes[0] == Double::class.javaPrimitiveType &&
|
||||
method.parameterTypes[1] == Double::class.javaPrimitiveType &&
|
||||
method.parameterTypes[2] == Long::class.javaPrimitiveType
|
||||
}?.hook(HookStage.BEFORE) { param ->
|
||||
val original = param.arg<Long>(2)
|
||||
val applied = original.coerceAtMost(8L)
|
||||
if (applied != original) {
|
||||
param.setArg(2, applied)
|
||||
}
|
||||
runCatching { nativeCancelTransitions?.invoke(param.thisObject<Any>()) }
|
||||
}
|
||||
}.onFailure {
|
||||
context.log.error("Failed to install Snap Map transition hooks", it, "PerformanceMode")
|
||||
}
|
||||
|
||||
fun applyActivityPerformanceTuning(activity: Activity) {
|
||||
runCatching {
|
||||
activity.window.decorView.setLayerType(View.LAYER_TYPE_HARDWARE, null)
|
||||
val display = activity.display
|
||||
val targetRefreshRate = display?.supportedModes?.maxByOrNull { it.refreshRate }?.refreshRate
|
||||
?.coerceAtLeast(preferredRefreshRate) ?: preferredRefreshRate
|
||||
activity.window.attributes = activity.window.attributes.apply {
|
||||
this.preferredRefreshRate = targetRefreshRate
|
||||
}
|
||||
}
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N && isMaxProfile) {
|
||||
runCatching {
|
||||
activity.window.setSustainedPerformanceMode(true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onNextActivityCreate {
|
||||
applyActivityPerformanceTuning(it)
|
||||
}
|
||||
|
||||
Dialog::class.java.hook("show", HookStage.AFTER) { param ->
|
||||
val dialog = param.nullableThisObject<Any>() as? Dialog ?: return@hook
|
||||
val window = dialog.window ?: return@hook
|
||||
runCatching {
|
||||
window.setWindowAnimations(0)
|
||||
window.decorView.setLayerType(View.LAYER_TYPE_HARDWARE, null)
|
||||
window.attributes = window.attributes.apply {
|
||||
flags = flags or WindowManager.LayoutParams.FLAG_HARDWARE_ACCELERATED
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
TextureView::class.java.hookConstructor(HookStage.AFTER) { param ->
|
||||
val textureView = param.thisObject<TextureView>()
|
||||
runCatching {
|
||||
// Universal Guard: Only accelerate views owned by Snapchat.
|
||||
// This prevents crashes in native hardware providers across all devices.
|
||||
if (textureView.context.packageName != context.androidContext.packageName) return@runCatching
|
||||
textureView.setLayerType(View.LAYER_TYPE_HARDWARE, null)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -35,6 +35,7 @@ import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.unit.times
|
||||
import me.eternal.purrfectsnap.common.scripting.JSModule
|
||||
import me.eternal.purrfectsnap.common.scripting.ui.EnumScriptInterface
|
||||
import me.eternal.purrfectsnap.common.scripting.ui.InterfaceManager
|
||||
import me.eternal.purrfectsnap.common.scripting.ui.ScriptInterface
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
package me.eternal.purrfectsnap.core.features.impl.ui
|
||||
|
||||
import android.widget.TextView
|
||||
import me.eternal.purrfectsnap.core.features.Feature
|
||||
import me.eternal.purrfectsnap.core.util.hook.HookStage
|
||||
import me.eternal.purrfectsnap.core.util.hook.hook
|
||||
import java.math.BigDecimal
|
||||
import java.math.BigInteger
|
||||
import java.math.MathContext
|
||||
import java.math.RoundingMode
|
||||
import java.text.NumberFormat
|
||||
import java.util.Locale
|
||||
|
||||
class FakeFollowersCount : Feature("Fake Followers Count") {
|
||||
|
||||
override fun init() {
|
||||
if (context.config.userInterface.spoofFollowersCount.globalState != true) return
|
||||
val raw = context.config.userInterface.spoofFollowersCount.customFollowersCount.getNullable()?.trim()?.takeIf { it.isNotBlank() }
|
||||
?: return
|
||||
val digits = raw.replace(Regex("[^0-9]"), "")
|
||||
if (digits.isEmpty()) return
|
||||
val followerValue = try {
|
||||
BigInteger(digits)
|
||||
} catch (_: NumberFormatException) {
|
||||
return
|
||||
}
|
||||
|
||||
onNextActivityCreate {
|
||||
TextView::class.java.hook("setText", HookStage.BEFORE) { param ->
|
||||
val textView = param.thisObject() as? TextView ?: return@hook
|
||||
if (textView.javaClass.name !in COMPOSER_SNAP_TEXT_VIEW) return@hook
|
||||
val arg0 = param.argNullable<Any>(0) ?: return@hook
|
||||
if (arg0 !is CharSequence) return@hook
|
||||
val text = arg0.toString()
|
||||
if (!FOLLOWERS_LINE.containsMatchIn(text)) return@hook
|
||||
val display = formatFollowersDisplay(followerValue)
|
||||
val replaced = FOLLOWERS_LINE.replace(text) { mr ->
|
||||
"$display ${mr.groupValues[2]}"
|
||||
}
|
||||
param.setArg(0, replaced)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun formatFollowersDisplay(n: BigInteger): String {
|
||||
val v = n.max(BigInteger.ZERO)
|
||||
if (v < TEN_THOUSAND) {
|
||||
return NumberFormat.getIntegerInstance(Locale.US).format(v.toLong())
|
||||
}
|
||||
val (scaled, suffix) = scaleToTier(v)
|
||||
return formatMantissaMaxFourDigits(scaled) + suffix
|
||||
}
|
||||
|
||||
private fun scaleToTier(v: BigInteger): Pair<BigDecimal, String> {
|
||||
var i = SCALE_TIERS.indexOfLast { v >= it.floor }.coerceAtLeast(0)
|
||||
var floor = SCALE_TIERS[i].floor
|
||||
var suffix = SCALE_TIERS[i].suffix
|
||||
var scaled = BigDecimal(v, MATH_CTX).divide(BigDecimal(floor, MATH_CTX), MATH_CTX)
|
||||
while (scaled >= THOUSAND_BD && i + 1 < SCALE_TIERS.size) {
|
||||
i++
|
||||
floor = SCALE_TIERS[i].floor
|
||||
suffix = SCALE_TIERS[i].suffix
|
||||
scaled = BigDecimal(v, MATH_CTX).divide(BigDecimal(floor, MATH_CTX), MATH_CTX)
|
||||
}
|
||||
return Pair(scaled, suffix)
|
||||
}
|
||||
|
||||
private fun formatMantissaMaxFourDigits(scaled: BigDecimal): String {
|
||||
val x = scaled.abs().setScale(12, RoundingMode.HALF_UP)
|
||||
if (x.compareTo(BigDecimal.ZERO) == 0) return "0"
|
||||
if (x >= HUNDRED) {
|
||||
val i = x.setScale(0, RoundingMode.HALF_UP)
|
||||
var s = i.toPlainString()
|
||||
if (digitCount(s) > 4) {
|
||||
s = x.round(MathContext(4, RoundingMode.HALF_UP)).setScale(0, RoundingMode.HALF_UP).toPlainString()
|
||||
}
|
||||
return s
|
||||
}
|
||||
if (x >= TEN) {
|
||||
var s = stripFrac(x.setScale(1, RoundingMode.HALF_UP))
|
||||
if (digitCount(s) > 4) {
|
||||
s = x.setScale(0, RoundingMode.HALF_UP).toPlainString()
|
||||
}
|
||||
return s
|
||||
}
|
||||
var s = stripFrac(x.setScale(2, RoundingMode.HALF_UP))
|
||||
if (digitCount(s) > 4) {
|
||||
s = stripFrac(x.setScale(1, RoundingMode.HALF_UP))
|
||||
}
|
||||
if (digitCount(s) > 4) {
|
||||
s = x.setScale(0, RoundingMode.HALF_UP).toPlainString()
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
private fun digitCount(s: String) = s.count { it.isDigit() }
|
||||
|
||||
private fun stripFrac(d: BigDecimal): String {
|
||||
var s = d.stripTrailingZeros().toPlainString()
|
||||
if ('.' in s) {
|
||||
s = s.trimEnd('0').trimEnd('.')
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
private data class ScaleTier(val floor: BigInteger, val suffix: String)
|
||||
|
||||
companion object {
|
||||
private val FOLLOWERS_LINE = Regex("^([\\d,]+)\\s+(Followers)\\b", RegexOption.IGNORE_CASE)
|
||||
private val COMPOSER_SNAP_TEXT_VIEW = setOf(
|
||||
"com.snap.valdi.views.ComposerSnapTextView",
|
||||
"com.snap.composer.views.ComposerSnapTextView",
|
||||
)
|
||||
private val MATH_CTX = MathContext(24, RoundingMode.HALF_UP)
|
||||
private val TEN_THOUSAND = BigInteger("10000")
|
||||
private val THOUSAND_BD = BigDecimal("1000")
|
||||
private val HUNDRED = BigDecimal("100")
|
||||
private val TEN = BigDecimal("10")
|
||||
private val SCALE_TIERS = listOf(
|
||||
ScaleTier(BigInteger("1000"), "K"),
|
||||
ScaleTier(BigInteger("1000000"), "M"),
|
||||
ScaleTier(BigInteger("1000000000"), "B"),
|
||||
ScaleTier(BigInteger("1000000000000"), "T"),
|
||||
ScaleTier(BigInteger("1000000000000000"), "Q"),
|
||||
ScaleTier(BigInteger("1000000000000000000"), "E"),
|
||||
ScaleTier(BigInteger("1000000000000000000000"), "Z"),
|
||||
ScaleTier(BigInteger("1000000000000000000000000"), "Y"),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,8 @@ import android.text.TextPaint
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.graphics.Typeface
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.toArgb
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.launch
|
||||
@@ -78,9 +80,10 @@ class FriendFeedMessagePreview : Feature("FriendFeedMessagePreview") {
|
||||
val ffSdlPrimaryTextStartMargin = 6 * density
|
||||
|
||||
val feedEntryHeight = ffSdlAvatarSize + ffSdlAvatarMargin * 2 + (4 * density).toInt()
|
||||
val separatorHeight = (density * 2).toInt()
|
||||
val safetyGap = (6 * density).toInt()
|
||||
val textPaint = TextPaint().apply {
|
||||
textSize = secondaryTextSize
|
||||
isAntiAlias = true
|
||||
}
|
||||
|
||||
context.event.subscribe(BuildMessageEvent::class) { param ->
|
||||
@@ -105,14 +108,15 @@ class FriendFeedMessagePreview : Feature("FriendFeedMessagePreview") {
|
||||
}
|
||||
|
||||
fetchMessages(conversationId) {
|
||||
var maxTextHeight = 0
|
||||
val previewContainerHeight = messageCache[conversationId]?.sumOf { msg ->
|
||||
val rect = Rect()
|
||||
textPaint.getTextBounds(msg, 0, msg.length, rect)
|
||||
rect.height().also {
|
||||
if (it > maxTextHeight) maxTextHeight = it
|
||||
}.plus(separatorHeight)
|
||||
} ?: run {
|
||||
val universalTextSize = 12 * density
|
||||
val fontMetrics = textPaint.apply { textSize = universalTextSize }.fontMetrics
|
||||
val lineHeight = (fontMetrics.descent - fontMetrics.ascent).toInt()
|
||||
val spacing = (4 * density).toInt()
|
||||
|
||||
val messages = messageCache[conversationId]
|
||||
val previewContainerHeight = if (messages.isNullOrEmpty()) 0 else (messages.size * (lineHeight + spacing))
|
||||
|
||||
if (previewContainerHeight == 0) {
|
||||
ffItem.layoutParams = ffItem.layoutParams.apply {
|
||||
height = ViewGroup.LayoutParams.MATCH_PARENT
|
||||
}
|
||||
@@ -120,22 +124,23 @@ class FriendFeedMessagePreview : Feature("FriendFeedMessagePreview") {
|
||||
}
|
||||
|
||||
ffItem.layoutParams = ffItem.layoutParams.apply {
|
||||
height = feedEntryHeight + previewContainerHeight + separatorHeight
|
||||
height = feedEntryHeight + (safetyGap).toInt() + previewContainerHeight
|
||||
}
|
||||
|
||||
cachedLayouts[conversationId] = frameLayout
|
||||
|
||||
frameLayout.addForegroundDrawable("ffItem", ShapeDrawable(object: Shape() {
|
||||
override fun draw(canvas: Canvas, paint: Paint) {
|
||||
val offsetY = canvas.height.toFloat() - previewContainerHeight
|
||||
paint.textSize = secondaryTextSize
|
||||
paint.color = context.userInterface.colorPrimary
|
||||
val startY = feedEntryHeight.toFloat() - (9 * density)
|
||||
paint.textSize = universalTextSize
|
||||
paint.color = Color(context.userInterface.colorPrimary).copy(alpha = 0.85f).toArgb()
|
||||
paint.typeface = Typeface.DEFAULT
|
||||
paint.isAntiAlias = true
|
||||
|
||||
messageCache[conversationId]?.forEachIndexed { index, messageString ->
|
||||
messages?.forEachIndexed { index, messageString ->
|
||||
canvas.drawText(messageString,
|
||||
feedEntryHeight + ffSdlPrimaryTextStartMargin,
|
||||
offsetY + index * maxTextHeight,
|
||||
ffSdlAvatarSize + ffSdlAvatarMargin + (ffSdlPrimaryTextStartMargin * 3),
|
||||
startY + (index + 1) * lineHeight + (index * spacing),
|
||||
paint
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,17 +1,31 @@
|
||||
package me.eternal.purrfectsnap.core.features.impl.ui
|
||||
|
||||
import android.view.View
|
||||
import me.eternal.purrfectsnap.common.data.MessagingRuleType
|
||||
import me.eternal.purrfectsnap.common.data.RuleState
|
||||
|
||||
import me.eternal.purrfectsnap.core.event.events.impl.BindViewEvent
|
||||
import me.eternal.purrfectsnap.core.features.MessagingRuleFeature
|
||||
import me.eternal.purrfectsnap.core.ui.hideViewCompletely
|
||||
import me.eternal.purrfectsnap.core.util.dataBuilder
|
||||
import me.eternal.purrfectsnap.core.util.hook.HookStage
|
||||
import me.eternal.purrfectsnap.core.util.hook.hook
|
||||
import me.eternal.purrfectsnap.core.util.ktx.getObjectField
|
||||
import me.eternal.purrfectsnap.core.wrapper.impl.SnapUUID
|
||||
import me.eternal.purrfectsnap.mapper.impl.CallbackMapper
|
||||
import java.util.ArrayList
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
|
||||
class HideFriendFeedEntry : MessagingRuleFeature("HideFriendFeedEntry", ruleType = MessagingRuleType.HIDE_FRIEND_FEED) {
|
||||
@Volatile
|
||||
private var cachedRuleIds: Set<String> = emptySet()
|
||||
|
||||
@Volatile
|
||||
private var cachedRuleIdsAt = 0L
|
||||
|
||||
private val conversationTargetsCache = ConcurrentHashMap<String, Set<String>>()
|
||||
private val hideDecisionCache = ConcurrentHashMap<String, Boolean>()
|
||||
private var lastRuleIdsHash = 0
|
||||
|
||||
private fun createDeletedFeedEntry(conversationIdInstance: Any) = findClass("com.snapchat.client.messaging.DeletedFeedEntry").dataBuilder {
|
||||
from("mFeedEntryIdentifier") {
|
||||
set("mConversationId", conversationIdInstance)
|
||||
@@ -19,10 +33,59 @@ class HideFriendFeedEntry : MessagingRuleFeature("HideFriendFeedEntry", ruleType
|
||||
set("mReason", "CLEAR_CONVERSATION")
|
||||
}
|
||||
|
||||
private fun filterFriendFeed(entries: ArrayList<Any>, deletedEntries: ArrayList<Any>? = null) {
|
||||
private fun getRuleIdsSnapshot(): Set<String> {
|
||||
val now = System.currentTimeMillis()
|
||||
if (now - cachedRuleIdsAt <= 1_000L) return cachedRuleIds
|
||||
|
||||
return context.bridgeClient.getRuleIds(ruleType).toSet().also {
|
||||
cachedRuleIds = it
|
||||
cachedRuleIdsAt = now
|
||||
}
|
||||
}
|
||||
|
||||
private fun resolveRuleTargets(conversationId: String): Set<String> {
|
||||
return conversationTargetsCache.getOrPut(conversationId) {
|
||||
val targets = linkedSetOf(conversationId)
|
||||
context.database.getDMOtherParticipant(conversationId)?.let { targets.add(it) }
|
||||
context.database.getFeedEntryByConversationId(conversationId)?.let { entry ->
|
||||
entry.friendUserId?.let { targets.add(it) }
|
||||
entry.participants?.forEach { targets.add(it) }
|
||||
}
|
||||
targets
|
||||
}
|
||||
}
|
||||
|
||||
private fun shouldHideConversation(
|
||||
conversationId: String,
|
||||
ruleIds: Set<String>,
|
||||
ruleState: RuleState?
|
||||
): Boolean {
|
||||
if (ruleState == null) return false
|
||||
|
||||
// Industrial Cache Gating: Clear decisions if the master rule list changed
|
||||
val currentHash = ruleIds.hashCode()
|
||||
if (currentHash != lastRuleIdsHash) {
|
||||
hideDecisionCache.clear()
|
||||
lastRuleIdsHash = currentHash
|
||||
}
|
||||
|
||||
return hideDecisionCache.getOrPut(conversationId) {
|
||||
val isExplicitRuleMatch = resolveRuleTargets(conversationId).any { it in ruleIds }
|
||||
if (ruleState == RuleState.BLACKLIST) !isExplicitRuleMatch else isExplicitRuleMatch
|
||||
}
|
||||
}
|
||||
|
||||
private fun filterFriendFeed(
|
||||
entries: ArrayList<Any>,
|
||||
ruleIds: Set<String>,
|
||||
ruleState: RuleState?,
|
||||
deletedEntries: ArrayList<Any>? = null
|
||||
) {
|
||||
if (ruleState == null || entries.isEmpty()) return
|
||||
entries.removeIf { feedEntry ->
|
||||
val conversationIdInstance = feedEntry.getObjectField("mConversationId") ?: return@removeIf false
|
||||
if (canUseRule(SnapUUID(conversationIdInstance).toString())) {
|
||||
val conversationId = SnapUUID(conversationIdInstance).toString()
|
||||
if (shouldHideConversation(conversationId, ruleIds, ruleState)) {
|
||||
deletedEntries?.add(createDeletedFeedEntry(conversationIdInstance)!!)
|
||||
true
|
||||
} else {
|
||||
@@ -31,6 +94,20 @@ class HideFriendFeedEntry : MessagingRuleFeature("HideFriendFeedEntry", ruleType
|
||||
}
|
||||
}
|
||||
|
||||
private fun hideBoundChatFeedRow(view: View) {
|
||||
var current: View? = view
|
||||
repeat(4) {
|
||||
val parent = current?.parent as? View
|
||||
// Safety: Never hide the actual list container
|
||||
if (parent?.javaClass?.name?.contains("RecyclerView") == true) {
|
||||
current?.hideViewCompletely()
|
||||
return
|
||||
}
|
||||
current?.hideViewCompletely()
|
||||
current = parent
|
||||
}
|
||||
}
|
||||
|
||||
private fun hookCallbackMethod(
|
||||
hookedCallbacks: MutableSet<String>,
|
||||
callbackClassName: String,
|
||||
@@ -51,6 +128,14 @@ class HideFriendFeedEntry : MessagingRuleFeature("HideFriendFeedEntry", ruleType
|
||||
override fun init() {
|
||||
if (!context.config.userInterface.hideFriendFeedEntry.get()) return
|
||||
|
||||
context.event.subscribe(BindViewEvent::class) { event ->
|
||||
event.friendFeedItem { conversationId ->
|
||||
if (shouldHideConversation(conversationId, getRuleIdsSnapshot(), getRuleState())) {
|
||||
hideBoundChatFeedRow(event.view)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
context.mappings.useMapper(CallbackMapper::class) {
|
||||
classLoader = context.androidContext.classLoader
|
||||
val hasFetchAndSyncCallback = callbacks.getAsMap()?.entries?.any {
|
||||
@@ -69,13 +154,13 @@ class HideFriendFeedEntry : MessagingRuleFeature("HideFriendFeedEntry", ruleType
|
||||
when {
|
||||
callbackName.startsWith("FetchAndSyncFeed") && callbackName.endsWith("Callback") -> {
|
||||
hookCallbackMethod(hookedCallbacks, callbackClassName ?: return@forEach, "onFetchAndSyncFeedComplete") { param ->
|
||||
val deletedConversations: ArrayList<Any> = param.arg(2)
|
||||
filterFriendFeed(param.arg(0), deletedConversations)
|
||||
val entries = param.argNullable<ArrayList<Any>>(0) ?: return@hookCallbackMethod
|
||||
val deletedConversations = param.argNullable<ArrayList<Any>>(2)
|
||||
val ruleIds = getRuleIdsSnapshot()
|
||||
val ruleState = getRuleState()
|
||||
filterFriendFeed(entries, ruleIds, ruleState, deletedConversations)
|
||||
|
||||
if (deletedConversations.any {
|
||||
val uuid = SnapUUID(it.getObjectField("mFeedEntryIdentifier")?.getObjectField("mConversationId")).toString()
|
||||
context.database.getFeedEntryByConversationId(uuid) != null
|
||||
}) {
|
||||
if (deletedConversations?.isNotEmpty() == true) {
|
||||
param.setArg(4, true)
|
||||
}
|
||||
}
|
||||
@@ -83,34 +168,40 @@ class HideFriendFeedEntry : MessagingRuleFeature("HideFriendFeedEntry", ruleType
|
||||
|
||||
callbackName.contains("SyncFeed") && callbackName.endsWith("Callback") -> {
|
||||
hookCallbackMethod(hookedCallbacks, callbackClassName ?: return@forEach, "onSyncFeedComplete") { param ->
|
||||
filterFriendFeed(param.arg(0), param.argNullable(2))
|
||||
val entries = param.argNullable<ArrayList<Any>>(0) ?: return@hookCallbackMethod
|
||||
filterFriendFeed(entries, getRuleIdsSnapshot(), getRuleState(), param.argNullable(2))
|
||||
}
|
||||
}
|
||||
|
||||
callbackName == "FetchFeedCallback" || callbackName.contains("FetchFeedCallback") -> {
|
||||
hookCallbackMethod(hookedCallbacks, callbackClassName ?: return@forEach, "onFetchFeedComplete") { param ->
|
||||
filterFriendFeed(param.arg(0))
|
||||
val entries = param.argNullable<ArrayList<Any>>(0) ?: return@hookCallbackMethod
|
||||
filterFriendFeed(entries, getRuleIdsSnapshot(), getRuleState())
|
||||
}
|
||||
}
|
||||
|
||||
callbackName == "FetchFeedEntriesCallback" || callbackName.contains("FetchFeedEntriesCallback") -> {
|
||||
hookCallbackMethod(hookedCallbacks, callbackClassName ?: return@forEach, "onFetchFeedEntriesComplete") { param ->
|
||||
filterFriendFeed(param.arg(0))
|
||||
val entries = param.argNullable<ArrayList<Any>>(0) ?: return@hookCallbackMethod
|
||||
filterFriendFeed(entries, getRuleIdsSnapshot(), getRuleState())
|
||||
}
|
||||
}
|
||||
|
||||
callbackName == "QueryFeedCallback" || callbackName.contains("QueryFeedCallback") -> {
|
||||
hookCallbackMethod(hookedCallbacks, callbackClassName ?: return@forEach, "onQueryFeedComplete") { param ->
|
||||
filterFriendFeed(param.arg(0))
|
||||
val entries = param.argNullable<ArrayList<Any>>(0) ?: return@hookCallbackMethod
|
||||
filterFriendFeed(entries, getRuleIdsSnapshot(), getRuleState())
|
||||
}
|
||||
}
|
||||
|
||||
callbackName == "FeedManagerDelegate" -> {
|
||||
hookCallbackMethod(hookedCallbacks, callbackClassName ?: return@forEach, "onFeedEntriesUpdated") { param ->
|
||||
filterFriendFeed(param.arg(0))
|
||||
val entries = param.argNullable<ArrayList<Any>>(0) ?: return@hookCallbackMethod
|
||||
filterFriendFeed(entries, getRuleIdsSnapshot(), getRuleState())
|
||||
}
|
||||
hookCallbackMethod(hookedCallbacks, callbackClassName ?: return@forEach, "onInternalSyncFeed") { param ->
|
||||
filterFriendFeed(param.arg(0))
|
||||
val entries = param.argNullable<ArrayList<Any>>(0) ?: return@hookCallbackMethod
|
||||
filterFriendFeed(entries, getRuleIdsSnapshot(), getRuleState())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,11 +8,18 @@ import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.*
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.drawWithContent
|
||||
import androidx.compose.ui.graphics.BlendMode
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.ColorFilter
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
@@ -25,6 +32,47 @@ import me.eternal.purrfectsnap.core.features.Feature
|
||||
import me.eternal.purrfectsnap.core.ui.AppleLogo
|
||||
import kotlin.random.Random
|
||||
|
||||
@Composable
|
||||
private fun GradientIcon(
|
||||
imageVector: ImageVector,
|
||||
brush: Brush,
|
||||
size: androidx.compose.ui.unit.Dp = 16.dp
|
||||
) {
|
||||
Image(
|
||||
imageVector = imageVector,
|
||||
contentDescription = null,
|
||||
colorFilter = ColorFilter.tint(Color.White),
|
||||
modifier = Modifier
|
||||
.size(size)
|
||||
.graphicsLayer(alpha = 0.99f)
|
||||
.drawWithContent {
|
||||
drawContent()
|
||||
drawRect(brush = brush, blendMode = BlendMode.SrcAtop)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun GradientText(
|
||||
text: String,
|
||||
brush: Brush,
|
||||
fontWeight: FontWeight,
|
||||
fontSize: androidx.compose.ui.unit.TextUnit
|
||||
) {
|
||||
Text(
|
||||
text = text,
|
||||
color = Color.White,
|
||||
fontWeight = fontWeight,
|
||||
fontSize = fontSize,
|
||||
modifier = Modifier
|
||||
.graphicsLayer(alpha = 0.99f)
|
||||
.drawWithContent {
|
||||
drawContent()
|
||||
drawRect(brush = brush, blendMode = BlendMode.SrcAtop)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
class MessageIndicators : Feature("Message Indicators") {
|
||||
override fun init() {
|
||||
val messageIndicatorsConfig = context.config.userInterface.messageIndicators.getNullable() ?: return
|
||||
@@ -35,28 +83,43 @@ class MessageIndicators : Feature("Message Indicators") {
|
||||
val appleLogo = AppleLogo
|
||||
|
||||
context.event.subscribe(BindViewEvent::class) { event ->
|
||||
event.chatMessage { _, _ ->
|
||||
event.chatMessage { conversationId, _ ->
|
||||
val view = event.view as? ViewGroup ?: return@subscribe
|
||||
view.findViewWithTag<View>(messageInfoTag)?.let { view.removeView(it) }
|
||||
|
||||
val message = event.databaseMessage ?: return@chatMessage
|
||||
if (message.contentType != ContentType.SNAP.id && message.contentType != ContentType.EXTERNAL_MEDIA.id) return@chatMessage
|
||||
if (message.senderId == context.database.myUserId && messageIndicatorsConfig.contains("skip_own_indicators")) return@chatMessage
|
||||
val reader = ProtoReader(message.messageContent ?: return@chatMessage)
|
||||
val isGroupConversation = (context.database.getConversationParticipants(conversationId)?.size ?: 0) > 2
|
||||
if (isGroupConversation && messageIndicatorsConfig.contains("disable_indicators_in_groups")) return@chatMessage
|
||||
|
||||
createComposeView(event.view.context) {
|
||||
val lockBrush = Brush.linearGradient(listOf(Color(0xFF4CD471), Color(0xFF00B8D9)))
|
||||
val locationBrush = Brush.linearGradient(listOf(Color(0xFFFF4B5C), Color(0xFFFFB74D)))
|
||||
val androidBrush = Brush.linearGradient(listOf(Color(0xFF3DDC84), Color(0xFFA5E635)))
|
||||
val appleBrush = Brush.linearGradient(listOf(Color(0xFFFFFFFF), Color(0xFF94A3B8)))
|
||||
val webBrush = Brush.linearGradient(listOf(Color(0xFF4FC3F7), Color(0xFF00E5FF)))
|
||||
val directorBrush = Brush.linearGradient(listOf(Color(0xFFFFB74D), Color(0xFFFF5E3A)))
|
||||
val ovfBrush = Brush.linearGradient(listOf(Color(0xFFFF5CA8), Color(0xFFA64CFF)))
|
||||
val memoriesBrush = Brush.linearGradient(listOf(Color(0xFFFFE066), Color(0xFFFFB300)))
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(50.dp)
|
||||
.padding(top = 4.dp, end = 1.dp),
|
||||
.padding(top = 6.dp, end = 6.dp),
|
||||
contentAlignment = Alignment.TopEnd
|
||||
) {
|
||||
val hasEncryption by rememberAsyncMutableState(defaultValue = false) {
|
||||
// Strictly detect Private Fidelius Wrap (1-on-1 private snaps)
|
||||
reader.containsPath(4, 4, 1, 1) ||
|
||||
reader.containsPath(4, 4, 1, 1, 1) ||
|
||||
reader.getByteArray(4, 3, 3) != null ||
|
||||
reader.containsPath(3, 99, 3)
|
||||
if (reader.containsPath(4, 4, 1, 1)
|
||||
|| reader.containsPath(4, 4, 1, 1, 1)
|
||||
|| reader.getByteArray(4, 3, 3) != null
|
||||
|| reader.containsPath(3, 99, 3)) {
|
||||
return@rememberAsyncMutableState true
|
||||
}
|
||||
if (reader.containsPath(4, 5, 1, 3, 1)) return@rememberAsyncMutableState true
|
||||
reader.getVarInt(4, 5, 1, 3, 2, 9) in setOf(1L, 3L)
|
||||
}
|
||||
val sentFromIosDevice by rememberAsyncMutableState(defaultValue = false) {
|
||||
if (reader.containsPath(4, 4, 3)) !reader.containsPath(4, 4, 3, 3, 17) else reader.getVarInt(4, 4, 11, 17, 7) != null
|
||||
@@ -75,52 +138,41 @@ class MessageIndicators : Feature("Message Indicators") {
|
||||
(it.getVarInt(1) to it.getVarInt(2)) == (0L to 0L)
|
||||
} == true || reader.getByteArray(4, 4, 11, 13, 4, 1, 2, 12, 27, 1) != null
|
||||
}
|
||||
val sentFromMemories by rememberAsyncMutableState(defaultValue = false) {
|
||||
reader.getVarInt(4, 18) != null
|
||||
|| reader.getString(4, 5, 1, 2)?.contains("/h/") == true
|
||||
}
|
||||
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp)
|
||||
) {
|
||||
if (sentWithLocation && messageIndicatorsConfig.contains("location_indicator")) {
|
||||
Image(
|
||||
imageVector = Icons.Default.LocationOn,
|
||||
colorFilter = ColorFilter.tint(Color.Green),
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(15.dp)
|
||||
)
|
||||
GradientIcon(Icons.Default.LocationOn, locationBrush)
|
||||
}
|
||||
if (messageIndicatorsConfig.contains("platform_indicator")) {
|
||||
Image(
|
||||
imageVector = when {
|
||||
sentFromWebApp -> Icons.Default.Laptop
|
||||
sentFromIosDevice -> appleLogo
|
||||
else -> Icons.Default.Android
|
||||
},
|
||||
colorFilter = ColorFilter.tint(Color.Green),
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(15.dp)
|
||||
)
|
||||
val (platformIcon, platformBrush) = when {
|
||||
sentFromWebApp -> Icons.Default.Laptop to webBrush
|
||||
sentFromIosDevice -> appleLogo to appleBrush
|
||||
else -> Icons.Default.Android to androidBrush
|
||||
}
|
||||
GradientIcon(platformIcon, platformBrush)
|
||||
}
|
||||
if (hasEncryption && messageIndicatorsConfig.contains("encryption_indicator")) {
|
||||
Image(
|
||||
imageVector = Icons.Default.Lock,
|
||||
colorFilter = ColorFilter.tint(Color.Green),
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(15.dp)
|
||||
)
|
||||
GradientIcon(Icons.Default.Lock, lockBrush)
|
||||
}
|
||||
if (sentUsingDirectorMode && messageIndicatorsConfig.contains("director_mode_indicator")) {
|
||||
Image(
|
||||
imageVector = Icons.Default.Edit,
|
||||
colorFilter = ColorFilter.tint(Color.Red),
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(15.dp)
|
||||
)
|
||||
GradientIcon(Icons.Default.Edit, directorBrush)
|
||||
}
|
||||
if (sentFromMemories && messageIndicatorsConfig.contains("memories_indicator")) {
|
||||
GradientIcon(Icons.Default.HistoryEdu, memoriesBrush)
|
||||
}
|
||||
if (sentUsingOvfEditor && messageIndicatorsConfig.contains("ovf_editor_indicator")) {
|
||||
Text(
|
||||
GradientText(
|
||||
text = "OVF",
|
||||
color = Color.Red,
|
||||
brush = ovfBrush,
|
||||
fontWeight = FontWeight.ExtraBold,
|
||||
fontSize = 10.sp,
|
||||
fontSize = 11.sp
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user