Compare commits
128 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8b51d78db6 | ||
| e27ec9d7ab | |||
|
|
5c18e38314 | ||
| 64e1de954a | |||
|
|
0404cdc8ee | ||
| 9dc177f527 | |||
| 655603bb59 | |||
|
|
fb9e8bc277 | ||
| 803a076f98 | |||
| 86d284b353 | |||
| 6207d18b3c | |||
|
|
8d2e35d1bc | ||
| 6845398755 | |||
| be52b0fa08 | |||
|
|
298bcfd1e3 | ||
| fce1736312 | |||
|
|
e11304d093 | ||
| 60d76d5165 | |||
|
|
373e700a07 | ||
| f7b25261cc | |||
|
|
41e1b0db9d | ||
| d1022635d2 | |||
|
|
2e6be4b0b3 | ||
| 0d97676edd | |||
|
|
f8e8058de3 | ||
| a1577a3b97 | |||
|
|
80f1df6588 | ||
| 375fa74309 | |||
|
|
0862094e00 | ||
| 45d66bbe28 | |||
|
|
919bc310a7 | ||
| badcfb160c | |||
|
|
1b35f71bb8 | ||
| 96349bf200 | |||
| 472d30940d | |||
|
|
ae9d2bfeb6 | ||
| 61b82d1f40 | |||
|
|
b008011d5f | ||
| 938f000f65 | |||
|
|
7fdf369883 | ||
| c9883a1279 | |||
|
|
5f09b0dd71 | ||
| 0e436f076e | |||
| 2281b31894 | |||
|
|
0eda439897 | ||
| a1bd03b277 | |||
|
|
1c2904898f | ||
| 0fe41a55c1 | |||
| af2f7dbcbc | |||
|
|
0a4a9aa636 | ||
| 746d0aa384 | |||
| c263b681d4 | |||
|
|
6f583d1227 | ||
| 6df60c8348 | |||
| 79d46857f9 | |||
|
|
c9ac92a0c4 | ||
| cb44bc92e4 | |||
| bc07974b4b | |||
|
|
abe44f6ad1 | ||
| c17e1c74ee | |||
|
|
255bfe2262 | ||
| 8eea325e21 | |||
| 3afb2ee80d | |||
|
|
245188c1a1 | ||
| 1d2057d75a | |||
| d8343d089b | |||
|
|
5cc087330c | ||
| 4163f22f71 | |||
|
|
ed0761bd20 | ||
| 6fdaa8dfd3 | |||
|
|
88d0a3f887 | ||
| 240d39593e | |||
| f06d11711b | |||
|
|
75e1fe6812 | ||
| f8c1e0641d | |||
| 6795e692d6 | |||
|
|
1c42974fe8 | ||
| 2cde79e87d | |||
|
|
6ec54c6018 | ||
| e203a46b5a | |||
|
|
746b7e6166 | ||
| 0d089d0244 | |||
|
|
1302bead0e | ||
| 875c4d3b54 | |||
|
|
bc3de084d2 | ||
| 849f4b980d | |||
|
|
f7b79b3e20 | ||
| 149ebfbe1f | |||
|
|
684b74a890 | ||
| b2538670cd | |||
|
|
da9f19e331 | ||
| 91bf4d6053 | |||
| 6ef5fcc2ed | |||
|
|
1f74a133a5 | ||
| 61946d5842 | |||
|
|
e634217d81 | ||
| 84ab4a76dd | |||
| dc5a416949 | |||
| 1bfb18ba86 | |||
|
|
49cc17d105 | ||
| 174d62e68a | |||
|
|
0d61eed963 | ||
| 4f58736f7f | |||
|
|
f6cf5696dd | ||
| 2ca09bbdf0 | |||
| c39a0ae248 | |||
|
|
1e848bc622 | ||
| 3fe00eb3d7 | |||
| 85851d7288 | |||
|
|
47f1b2c40f | ||
| d04c1e8f77 | |||
|
|
76a19f7e02 | ||
| 3d35ea5930 | |||
| 9fe79de3b5 | |||
|
|
044631b05d | ||
| 4bc5b4d790 | |||
| da52ec84b7 | |||
|
|
f758796d83 | ||
| 77d4071728 | |||
| ca855a6945 | |||
|
|
a56662ab69 | ||
| f870fd1c2a | |||
| 70f7d41d9a | |||
|
|
eba647c9c5 | ||
| 08b6dce4ee | |||
| 226f4863ff | |||
|
|
e88bebad3d | ||
| a0eb652098 |
@@ -15,30 +15,34 @@ jobs:
|
|||||||
outputs:
|
outputs:
|
||||||
version: ${{ steps.bump.outputs.VERSION }}
|
version: ${{ steps.bump.outputs.VERSION }}
|
||||||
should_build: ${{ steps.bump.outputs.SHOULD_BUILD }}
|
should_build: ${{ steps.bump.outputs.SHOULD_BUILD }}
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
token: ${{ secrets.GITHUB_TOKEN }}
|
token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
- name: Bump patch version
|
- name: Bump patch version
|
||||||
id: bump
|
id: bump
|
||||||
run: |
|
run: |
|
||||||
CURRENT=$(grep -Po '"version":\s*"\K[^"]+' package.json | head -1)
|
CURRENT=$(grep -Po '"version":\s*"\K[^"]+' package.json | head -1)
|
||||||
echo "Current version: $CURRENT"
|
echo "Current version: $CURRENT"
|
||||||
|
|
||||||
MAJOR=$(echo $CURRENT | cut -d. -f1)
|
MAJOR=$(echo $CURRENT | cut -d. -f1)
|
||||||
MINOR=$(echo $CURRENT | cut -d. -f2)
|
MINOR=$(echo $CURRENT | cut -d. -f2)
|
||||||
PATCH=$(echo $CURRENT | cut -d. -f3)
|
PATCH=$(echo $CURRENT | cut -d. -f3)
|
||||||
NEW_PATCH=$((PATCH + 1))
|
NEW_PATCH=$((PATCH + 1))
|
||||||
NEW_VERSION="${MAJOR}.${MINOR}.${NEW_PATCH}"
|
NEW_VERSION="${MAJOR}.${MINOR}.${NEW_PATCH}"
|
||||||
echo "New version: $NEW_VERSION"
|
echo "New version: $NEW_VERSION"
|
||||||
|
|
||||||
sed -i "s/\"version\": \"${CURRENT}\"/\"version\": \"${NEW_VERSION}\"/" package.json
|
sed -i "s/\"version\": \"${CURRENT}\"/\"version\": \"${NEW_VERSION}\"/" package.json
|
||||||
|
|
||||||
grep '"version"' package.json | head -1
|
grep '"version"' package.json | head -1
|
||||||
|
|
||||||
echo "VERSION=$NEW_VERSION" >> $GITHUB_OUTPUT
|
echo "VERSION=$NEW_VERSION" >> $GITHUB_OUTPUT
|
||||||
echo "SHOULD_BUILD=true" >> $GITHUB_OUTPUT
|
echo "SHOULD_BUILD=true" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
- name: Commit and push version bump
|
- name: Commit and push version bump
|
||||||
run: |
|
run: |
|
||||||
git config user.name "GitHub Actions"
|
git config user.name "GitHub Actions"
|
||||||
@@ -51,69 +55,76 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
needs: increment-version
|
needs: increment-version
|
||||||
if: always() && (needs.increment-version.outputs.should_build == 'true' || needs.increment-version.result == 'skipped')
|
if: always() && (needs.increment-version.outputs.should_build == 'true' || needs.increment-version.result == 'skipped')
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
submodules: false
|
submodules: false
|
||||||
ref: ${{ github.ref_name }}
|
ref: ${{ github.ref_name }}
|
||||||
|
|
||||||
- name: Checkout submodules manually
|
- name: Checkout submodules manually
|
||||||
run: git submodule update --init --recursive
|
run: git submodule update --init --recursive
|
||||||
|
|
||||||
- name: Pull latest (after version bump)
|
- name: Pull latest (after version bump)
|
||||||
if: github.ref == 'refs/heads/master'
|
if: github.ref == 'refs/heads/master'
|
||||||
run: git pull origin master
|
run: git pull origin master
|
||||||
|
|
||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: '22'
|
node-version: '22'
|
||||||
|
|
||||||
- name: Setup Java
|
- name: Setup Java
|
||||||
uses: actions/setup-java@v4
|
uses: actions/setup-java@v4
|
||||||
with:
|
with:
|
||||||
distribution: temurin
|
distribution: temurin
|
||||||
java-version: '21'
|
java-version: '21'
|
||||||
|
|
||||||
- name: Setup Android SDK
|
- name: Setup Android SDK
|
||||||
uses: android-actions/setup-android@v3
|
uses: android-actions/setup-android@v3
|
||||||
|
|
||||||
- name: Install dependencies (root)
|
- name: Install dependencies (root)
|
||||||
|
run: npm ci --prefer-offline
|
||||||
|
|
||||||
|
- name: Apply overlay
|
||||||
|
run: npm run apply-overlay
|
||||||
|
|
||||||
|
- name: Install dependencies (cinny)
|
||||||
|
working-directory: ./cinny
|
||||||
run: npm install --prefer-offline
|
run: npm install --prefer-offline
|
||||||
|
|
||||||
- name: Clean previous cinny dist
|
- name: Clean previous builds
|
||||||
run: rm -rf cinny/dist
|
run: rm -rf cinny/dist
|
||||||
|
|
||||||
- name: Make gradlew executable
|
- name: Make gradlew executable
|
||||||
run: chmod +x android/gradlew
|
run: chmod +x android/gradlew
|
||||||
|
|
||||||
- name: Apply overlay and build Android APK
|
- name: Build Android APK
|
||||||
run: npm run android:apk:linux
|
run: npm run android:apk:linux
|
||||||
|
|
||||||
- name: Upload APK
|
- name: Upload APK
|
||||||
uses: actions/upload-artifact@v3
|
uses: actions/upload-artifact@v3
|
||||||
with:
|
with:
|
||||||
name: Paarrot-Android-debug.apk
|
name: Paarrot-Android.apk
|
||||||
path: android/app/build/outputs/apk/debug/app-debug.apk
|
path: android/app/build/outputs/apk/debug/Paarrot-*.apk
|
||||||
|
|
||||||
create-release:
|
create-release:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
needs: [increment-version, build-android]
|
needs: [increment-version, build-android]
|
||||||
if: always() && needs.build-android.result == 'success'
|
if: always() && needs.build-android.result == 'success'
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
ref: ${{ github.ref_name }}
|
ref: ${{ github.ref_name }}
|
||||||
|
|
||||||
- name: Pull latest (after version bump)
|
- name: Pull latest (after version bump)
|
||||||
if: github.ref == 'refs/heads/master'
|
if: github.ref == 'refs/heads/master'
|
||||||
run: git pull origin master
|
run: git pull origin master
|
||||||
|
|
||||||
- name: Get version
|
- name: Get version
|
||||||
id: version
|
id: version
|
||||||
run: |
|
run: |
|
||||||
@@ -124,17 +135,19 @@ jobs:
|
|||||||
fi
|
fi
|
||||||
echo "VERSION=$VERSION" >> $GITHUB_OUTPUT
|
echo "VERSION=$VERSION" >> $GITHUB_OUTPUT
|
||||||
echo "Version: $VERSION"
|
echo "Version: $VERSION"
|
||||||
|
|
||||||
- name: Check if tag exists
|
- name: Check if tag exists
|
||||||
id: tag_check
|
id: tag_check
|
||||||
run: |
|
run: |
|
||||||
VERSION="${{ steps.version.outputs.VERSION }}"
|
VERSION="${{ steps.version.outputs.VERSION }}"
|
||||||
if git rev-parse "v$VERSION" >/dev/null 2>&1; then
|
if git rev-parse "v$VERSION" >/dev/null 2>&1; then
|
||||||
|
echo "Tag v$VERSION already exists"
|
||||||
echo "exists=true" >> $GITHUB_OUTPUT
|
echo "exists=true" >> $GITHUB_OUTPUT
|
||||||
else
|
else
|
||||||
|
echo "Tag v$VERSION does not exist"
|
||||||
echo "exists=false" >> $GITHUB_OUTPUT
|
echo "exists=false" >> $GITHUB_OUTPUT
|
||||||
fi
|
fi
|
||||||
|
|
||||||
- name: Create and push tag
|
- name: Create and push tag
|
||||||
if: steps.tag_check.outputs.exists == 'false'
|
if: steps.tag_check.outputs.exists == 'false'
|
||||||
run: |
|
run: |
|
||||||
@@ -142,23 +155,19 @@ jobs:
|
|||||||
git config user.email "actions@github.com"
|
git config user.email "actions@github.com"
|
||||||
git tag -a "v${{ steps.version.outputs.VERSION }}" -m "Release v${{ steps.version.outputs.VERSION }}"
|
git tag -a "v${{ steps.version.outputs.VERSION }}" -m "Release v${{ steps.version.outputs.VERSION }}"
|
||||||
git push origin "v${{ steps.version.outputs.VERSION }}"
|
git push origin "v${{ steps.version.outputs.VERSION }}"
|
||||||
|
|
||||||
- name: Download all artifacts
|
- name: Download all artifacts
|
||||||
uses: actions/download-artifact@v3
|
uses: actions/download-artifact@v3
|
||||||
with:
|
with:
|
||||||
path: artifacts
|
path: artifacts
|
||||||
|
|
||||||
- name: Prepare release files
|
- name: Prepare release files
|
||||||
run: |
|
run: |
|
||||||
mkdir -p release-files
|
mkdir -p release-files
|
||||||
find artifacts -type f \( -name "*.exe" -o -name "*.AppImage" -o -name "*.nupkg" -o -name "RELEASES*" \) -exec cp {} release-files/ \;
|
find artifacts -type f -name "*.apk" -exec cp {} release-files/ \;
|
||||||
APK=$(find artifacts -type f -name "*.apk" | head -1)
|
|
||||||
if [ -n "$APK" ]; then
|
|
||||||
cp "$APK" "release-files/Paarrot.apk"
|
|
||||||
fi
|
|
||||||
ls -la release-files/
|
ls -la release-files/
|
||||||
|
|
||||||
- name: Create or Update Release
|
- name: Create or Update Gitea Release
|
||||||
env:
|
env:
|
||||||
GITEA_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GITEA_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
run: |
|
run: |
|
||||||
@@ -166,10 +175,10 @@ jobs:
|
|||||||
TAG_NAME="v${VERSION}"
|
TAG_NAME="v${VERSION}"
|
||||||
API_BASE="http://synbox.ruv.wtf:8418/api/v1"
|
API_BASE="http://synbox.ruv.wtf:8418/api/v1"
|
||||||
REPO="litruv/cinny-mobile"
|
REPO="litruv/cinny-mobile"
|
||||||
|
|
||||||
RELEASE_ID=$(curl -s -H "Authorization: token ${GITEA_TOKEN}" \
|
RELEASE_ID=$(curl -s -H "Authorization: token ${GITEA_TOKEN}" \
|
||||||
"${API_BASE}/repos/${REPO}/releases/tags/${TAG_NAME}" | jq -r '.id // empty')
|
"${API_BASE}/repos/${REPO}/releases/tags/${TAG_NAME}" | jq -r '.id // empty')
|
||||||
|
|
||||||
if [ -z "$RELEASE_ID" ]; then
|
if [ -z "$RELEASE_ID" ]; then
|
||||||
echo "Creating new release for ${TAG_NAME}..."
|
echo "Creating new release for ${TAG_NAME}..."
|
||||||
RELEASE_ID=$(curl -s -X POST \
|
RELEASE_ID=$(curl -s -X POST \
|
||||||
@@ -179,18 +188,20 @@ jobs:
|
|||||||
"${API_BASE}/repos/${REPO}/releases" | jq -r '.id')
|
"${API_BASE}/repos/${REPO}/releases" | jq -r '.id')
|
||||||
echo "Created release with ID: ${RELEASE_ID}"
|
echo "Created release with ID: ${RELEASE_ID}"
|
||||||
else
|
else
|
||||||
echo "Release exists with ID: ${RELEASE_ID}, deleting existing assets..."
|
echo "Release exists with ID: ${RELEASE_ID}"
|
||||||
|
echo "Deleting existing assets..."
|
||||||
ASSETS=$(curl -s -H "Authorization: token ${GITEA_TOKEN}" \
|
ASSETS=$(curl -s -H "Authorization: token ${GITEA_TOKEN}" \
|
||||||
"${API_BASE}/repos/${REPO}/releases/${RELEASE_ID}/assets" | jq -r '.[].id')
|
"${API_BASE}/repos/${REPO}/releases/${RELEASE_ID}/assets" | jq -r '.[].id')
|
||||||
for ASSET_ID in $ASSETS; do
|
for ASSET_ID in $ASSETS; do
|
||||||
|
echo "Deleting asset ${ASSET_ID}..."
|
||||||
curl -s -X DELETE -H "Authorization: token ${GITEA_TOKEN}" \
|
curl -s -X DELETE -H "Authorization: token ${GITEA_TOKEN}" \
|
||||||
"${API_BASE}/repos/${REPO}/releases/${RELEASE_ID}/assets/${ASSET_ID}"
|
"${API_BASE}/repos/${REPO}/releases/${RELEASE_ID}/assets/${ASSET_ID}"
|
||||||
done
|
done
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo "Uploading assets..."
|
echo "Uploading assets..."
|
||||||
for FILE in release-files/*; do
|
for FILE in release-files/*; do
|
||||||
FILENAME=$(basename "$FILE")
|
FILENAME="Paarrot-${VERSION}.apk"
|
||||||
echo "Uploading ${FILENAME}..."
|
echo "Uploading ${FILENAME}..."
|
||||||
curl -s -X POST \
|
curl -s -X POST \
|
||||||
-H "Authorization: token ${GITEA_TOKEN}" \
|
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||||
@@ -199,5 +210,45 @@ jobs:
|
|||||||
"${API_BASE}/repos/${REPO}/releases/${RELEASE_ID}/assets?name=${FILENAME}"
|
"${API_BASE}/repos/${REPO}/releases/${RELEASE_ID}/assets?name=${FILENAME}"
|
||||||
echo ""
|
echo ""
|
||||||
done
|
done
|
||||||
|
|
||||||
echo "Release complete!"
|
echo "Gitea release complete!"
|
||||||
|
|
||||||
|
- name: Create GitHub Release
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ secrets.GHTOKEN }}
|
||||||
|
run: |
|
||||||
|
VERSION="${{ steps.version.outputs.VERSION }}"
|
||||||
|
TAG_NAME="v${VERSION}"
|
||||||
|
GITHUB_REPO="Paarrot/Paarrot-Mobile"
|
||||||
|
|
||||||
|
RELEASE_ID=$(curl -s -H "Authorization: token ${GH_TOKEN}" \
|
||||||
|
"https://api.github.com/repos/${GITHUB_REPO}/releases/tags/${TAG_NAME}" | jq -r '.id // empty')
|
||||||
|
|
||||||
|
if [ -z "$RELEASE_ID" ]; then
|
||||||
|
echo "Creating new GitHub release for ${TAG_NAME}..."
|
||||||
|
RELEASE_ID=$(curl -s -X POST \
|
||||||
|
-H "Authorization: token ${GH_TOKEN}" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d "{\"tag_name\": \"${TAG_NAME}\", \"name\": \"Release ${TAG_NAME}\", \"body\": \"Release ${VERSION}\", \"draft\": false, \"prerelease\": false}" \
|
||||||
|
"https://api.github.com/repos/${GITHUB_REPO}/releases" | jq -r '.id')
|
||||||
|
echo "Created GitHub release with ID: ${RELEASE_ID}"
|
||||||
|
else
|
||||||
|
echo "GitHub release exists with ID: ${RELEASE_ID}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Uploading assets to GitHub..."
|
||||||
|
UPLOAD_URL=$(curl -s -H "Authorization: token ${GH_TOKEN}" \
|
||||||
|
"https://api.github.com/repos/${GITHUB_REPO}/releases/${RELEASE_ID}" | jq -r '.upload_url' | sed 's/{?name,label}//')
|
||||||
|
|
||||||
|
for FILE in release-files/*; do
|
||||||
|
FILENAME="Paarrot-${VERSION}.apk"
|
||||||
|
echo "Uploading ${FILENAME} to GitHub..."
|
||||||
|
curl -s -X POST \
|
||||||
|
-H "Authorization: token ${GH_TOKEN}" \
|
||||||
|
-H "Content-Type: application/octet-stream" \
|
||||||
|
--data-binary "@${FILE}" \
|
||||||
|
"${UPLOAD_URL}?name=${FILENAME}"
|
||||||
|
echo ""
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "GitHub release complete!"
|
||||||
|
|||||||
30
.gitea/workflows/clean.yml
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
name: Clean
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
clean:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
submodules: false
|
||||||
|
|
||||||
|
- name: Clean build artifacts
|
||||||
|
run: |
|
||||||
|
echo "Cleaning cinny dist..."
|
||||||
|
rm -rf cinny/dist
|
||||||
|
|
||||||
|
echo "Cleaning Android build outputs..."
|
||||||
|
rm -rf android/app/build
|
||||||
|
|
||||||
|
echo "Cleaning root node_modules..."
|
||||||
|
rm -rf node_modules
|
||||||
|
|
||||||
|
echo "Cleaning cinny node_modules..."
|
||||||
|
rm -rf cinny/node_modules
|
||||||
|
|
||||||
|
echo "Done."
|
||||||
3
.github/FUNDING.yml
vendored
@@ -1,3 +0,0 @@
|
|||||||
github: ajbura
|
|
||||||
liberapay: ajbura
|
|
||||||
open_collective: cinny
|
|
||||||
58
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
@@ -1,58 +0,0 @@
|
|||||||
name: 🐞 Bug Report
|
|
||||||
description: Report a bug
|
|
||||||
labels: 'type: bug'
|
|
||||||
|
|
||||||
body:
|
|
||||||
- type: markdown
|
|
||||||
attributes:
|
|
||||||
value: |
|
|
||||||
## First of all
|
|
||||||
1. Please search for [existing issues](https://github.com/cinnyapp/cinny-desktop/issues?q=is%3Aissue) about this problem first.
|
|
||||||
2. Make sure Cinny is up to date.
|
|
||||||
3. Make sure it's an issue with Cinny and not something else you are using.
|
|
||||||
4. Remember to be friendly.
|
|
||||||
|
|
||||||
- type: textarea
|
|
||||||
id: description
|
|
||||||
attributes:
|
|
||||||
label: Describe the bug
|
|
||||||
description: A clear description of what the bug is. Include screenshots if applicable.
|
|
||||||
placeholder: Bug description
|
|
||||||
validations:
|
|
||||||
required: true
|
|
||||||
|
|
||||||
- type: textarea
|
|
||||||
id: reproduction
|
|
||||||
attributes:
|
|
||||||
label: Reproduction
|
|
||||||
description: Steps to reproduce the behavior.
|
|
||||||
placeholder: |
|
|
||||||
1. Go to ...
|
|
||||||
2. Click on ...
|
|
||||||
3. See error
|
|
||||||
|
|
||||||
- type: textarea
|
|
||||||
id: expected-behavior
|
|
||||||
attributes:
|
|
||||||
label: Expected behavior
|
|
||||||
description: A clear description of what you expected to happen.
|
|
||||||
|
|
||||||
- type: textarea
|
|
||||||
id: info
|
|
||||||
attributes:
|
|
||||||
label: Platform and versions
|
|
||||||
description: "Provide OS, browser and Cinny version with your Homeserver."
|
|
||||||
placeholder: |
|
|
||||||
1. OS: [e.g. Windows 10, MacOS]
|
|
||||||
2. Cinny version: [e.g. 1.8.1]
|
|
||||||
3. Matrix homeserver: [e.g. matrix.org]
|
|
||||||
4. Downloaded from: [e.g. GitHub, Flatpak]
|
|
||||||
render: shell
|
|
||||||
validations:
|
|
||||||
required: true
|
|
||||||
|
|
||||||
- type: textarea
|
|
||||||
id: context
|
|
||||||
attributes:
|
|
||||||
label: Additional context
|
|
||||||
description: Add any other context about the problem here.
|
|
||||||
4
.github/ISSUE_TEMPLATE/config.yml
vendored
@@ -1,4 +0,0 @@
|
|||||||
contact_links:
|
|
||||||
- name: 💬 Matrix Chat
|
|
||||||
url: https://matrix.to/#/#cinny:matrix.org
|
|
||||||
about: Ask questions and talk to other Cinny users and the maintainers
|
|
||||||
34
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
@@ -1,34 +0,0 @@
|
|||||||
name: 💡 Feature Request
|
|
||||||
description: Suggest an idea
|
|
||||||
labels: 'type: feature'
|
|
||||||
|
|
||||||
body:
|
|
||||||
- type: textarea
|
|
||||||
id: problem
|
|
||||||
attributes:
|
|
||||||
label: Describe the problem
|
|
||||||
description: A clear description of the problem this feature would solve
|
|
||||||
placeholder: "I'm always frustrated when..."
|
|
||||||
validations:
|
|
||||||
required: true
|
|
||||||
|
|
||||||
- type: textarea
|
|
||||||
id: solution
|
|
||||||
attributes:
|
|
||||||
label: "Describe the solution you'd like"
|
|
||||||
description: A clear description of what change you would like
|
|
||||||
placeholder: "I would like to..."
|
|
||||||
validations:
|
|
||||||
required: true
|
|
||||||
|
|
||||||
- type: textarea
|
|
||||||
id: alternatives
|
|
||||||
attributes:
|
|
||||||
label: Alternatives considered
|
|
||||||
description: "Any alternative solutions you've considered"
|
|
||||||
|
|
||||||
- type: textarea
|
|
||||||
id: context
|
|
||||||
attributes:
|
|
||||||
label: Additional context
|
|
||||||
description: Add any other context about the problem here.
|
|
||||||
22
.github/PULL_REQUEST_TEMPLATE.md
vendored
@@ -1,22 +0,0 @@
|
|||||||
<!-- Please read https://github.com/cinnyapp/cinny/blob/dev/CONTRIBUTING.md before submitting your pull request -->
|
|
||||||
|
|
||||||
### Description
|
|
||||||
<!-- Please include a summary of the change. Please also include relevant motivation and context. List any dependencies that are required for this change. -->
|
|
||||||
|
|
||||||
|
|
||||||
Fixes #
|
|
||||||
|
|
||||||
#### Type of change
|
|
||||||
|
|
||||||
- [ ] Bug fix (non-breaking change which fixes an issue)
|
|
||||||
- [ ] New feature (non-breaking change which adds functionality)
|
|
||||||
- [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected)
|
|
||||||
- [ ] This change requires a documentation update
|
|
||||||
|
|
||||||
### Checklist:
|
|
||||||
|
|
||||||
- [ ] My code follows the style guidelines of this project
|
|
||||||
- [ ] I have performed a self-review of my own code
|
|
||||||
- [ ] I have commented my code, particularly in hard-to-understand areas
|
|
||||||
- [ ] I have made corresponding changes to the documentation
|
|
||||||
- [ ] My changes generate no new warnings
|
|
||||||
3
.github/SECURITY.md
vendored
@@ -1,3 +0,0 @@
|
|||||||
# Reporting a Vulnerability
|
|
||||||
|
|
||||||
**If you've found a security vulnerability, please report it to cinnyapp@gmail.com**
|
|
||||||
30
.github/dependabot.yml
vendored
@@ -1,30 +0,0 @@
|
|||||||
# Docs: <https://docs.github.com/en/code-security/supply-chain-security/keeping-your-dependencies-updated-automatically/customizing-dependency-updates>
|
|
||||||
|
|
||||||
version: 2
|
|
||||||
updates:
|
|
||||||
# - package-ecosystem: npm
|
|
||||||
# directory: /
|
|
||||||
# schedule:
|
|
||||||
# interval: weekly
|
|
||||||
# day: "tuesday"
|
|
||||||
# time: "01:00"
|
|
||||||
# timezone: "Asia/Kolkata"
|
|
||||||
# open-pull-requests-limit: 15
|
|
||||||
|
|
||||||
- package-ecosystem: github-actions
|
|
||||||
directory: /
|
|
||||||
schedule:
|
|
||||||
interval: weekly
|
|
||||||
day: "tuesday"
|
|
||||||
time: "01:00"
|
|
||||||
timezone: "Asia/Kolkata"
|
|
||||||
open-pull-requests-limit: 5
|
|
||||||
|
|
||||||
# - package-ecosystem: cargo
|
|
||||||
# directory: /src-tauri/
|
|
||||||
# schedule:
|
|
||||||
# interval: weekly
|
|
||||||
# day: "tuesday"
|
|
||||||
# time: "01:00"
|
|
||||||
# timezone: "Asia/Kolkata"
|
|
||||||
# open-pull-requests-limit: 5
|
|
||||||
21
.github/renovate.json
vendored
@@ -1,21 +0,0 @@
|
|||||||
{
|
|
||||||
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
|
|
||||||
"extends": [
|
|
||||||
"config:recommended",
|
|
||||||
":dependencyDashboardApproval"
|
|
||||||
],
|
|
||||||
"labels": [
|
|
||||||
"Dependencies"
|
|
||||||
],
|
|
||||||
"packageRules": [
|
|
||||||
{
|
|
||||||
"matchUpdateTypes": [
|
|
||||||
"lockFileMaintenance"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"lockFileMaintenance": {
|
|
||||||
"enabled": true
|
|
||||||
},
|
|
||||||
"dependencyDashboard": true
|
|
||||||
}
|
|
||||||
22
.github/workflows/archive.yml
vendored
@@ -1,22 +0,0 @@
|
|||||||
name: "Upload zip-archive"
|
|
||||||
on:
|
|
||||||
release:
|
|
||||||
types: [published]
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
zip-archive:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- name: Checkout repository
|
|
||||||
uses: actions/checkout@v4.2.0
|
|
||||||
with:
|
|
||||||
submodules: true
|
|
||||||
- name: Create zip including submodules
|
|
||||||
run: |
|
|
||||||
cd ..
|
|
||||||
zip ${{ github.event.repository.name }}/${{ github.event.repository.name }}-${{ github.ref_name }}.zip ${{ github.event.repository.name }} -r
|
|
||||||
- name: Upload zip to release
|
|
||||||
uses: softprops/action-gh-release@6cbd405e2c4e67a21c47fa9e383d020e4e28b836
|
|
||||||
with:
|
|
||||||
files: |
|
|
||||||
${{ github.event.repository.name }}-${{ github.ref_name }}.zip
|
|
||||||
36
.github/workflows/cla.yml
vendored
@@ -1,36 +0,0 @@
|
|||||||
name: 'CLA Assistant'
|
|
||||||
on:
|
|
||||||
issue_comment:
|
|
||||||
types: [created]
|
|
||||||
pull_request_target:
|
|
||||||
types: [opened, closed, synchronize]
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
CLAssistant:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- name: 'CLA Assistant'
|
|
||||||
if: (github.event.comment.body == 'recheck' || github.event.comment.body == 'I have read the CLA Document and I hereby sign the CLA') || github.event_name == 'pull_request_target'
|
|
||||||
# Beta Release
|
|
||||||
uses: cla-assistant/github-action@v2.6.1
|
|
||||||
env:
|
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
# the below token should have repo scope and must be manually added by you in the repository's secret
|
|
||||||
PERSONAL_ACCESS_TOKEN: ${{ secrets.CLA_PAT }}
|
|
||||||
with:
|
|
||||||
path-to-signatures: 'signatures.json'
|
|
||||||
path-to-document: 'https://github.com/cinnyapp/cla/blob/main/cla.md' # e.g. a CLA or a DCO document
|
|
||||||
# branch should not be protected
|
|
||||||
branch: 'main'
|
|
||||||
allowlist: ajbura,bot*
|
|
||||||
|
|
||||||
#below are the optional inputs - If the optional inputs are not given, then default values will be taken
|
|
||||||
remote-organization-name: cinnyapp
|
|
||||||
remote-repository-name: cla
|
|
||||||
#create-file-commit-message: 'For example: Creating file for storing CLA Signatures'
|
|
||||||
#signed-commit-message: 'For example: $contributorName has signed the CLA in #$pullRequestNo'
|
|
||||||
#custom-notsigned-prcomment: 'pull request comment with Introductory message to ask new contributors to sign'
|
|
||||||
#custom-pr-sign-comment: 'The signature to be committed in order to sign the CLA'
|
|
||||||
#custom-allsigned-prcomment: 'pull request comment when all contributors has signed, defaults to **CLA Assistant Lite bot** All Contributors have signed the CLA.'
|
|
||||||
#lock-pullrequest-aftermerge: false - if you don't want this bot to automatically lock the pull request after merging (default - true)
|
|
||||||
#use-dco-flag: true - If you are using DCO instead of CLA
|
|
||||||
26
.github/workflows/lockfile.yml
vendored
@@ -1,26 +0,0 @@
|
|||||||
name: NPM Lockfile Changes
|
|
||||||
|
|
||||||
on:
|
|
||||||
pull_request:
|
|
||||||
paths:
|
|
||||||
- 'package-lock.json'
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
lockfile_changes:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
# Permission overwrite is required for Dependabot PRs, see "Common issues" below.
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
pull-requests: write
|
|
||||||
steps:
|
|
||||||
- name: Checkout
|
|
||||||
uses: actions/checkout@v4.2.0
|
|
||||||
- name: NPM Lockfile Changes
|
|
||||||
uses: codepunkt/npm-lockfile-changes@b40543471c36394409466fdb277a73a0856d7891
|
|
||||||
with:
|
|
||||||
token: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
# Optional inputs, can be deleted safely if you are happy with default values.
|
|
||||||
collapsibleThreshold: 25
|
|
||||||
failOnDowngrade: false
|
|
||||||
path: package-lock.json
|
|
||||||
updateComment: true
|
|
||||||
40
.github/workflows/test.yml
vendored
@@ -1,40 +0,0 @@
|
|||||||
name: "Build pull request"
|
|
||||||
on:
|
|
||||||
#pull_request:
|
|
||||||
#types: ['opened', 'synchronize']
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
publish-tauri:
|
|
||||||
strategy:
|
|
||||||
fail-fast: false
|
|
||||||
matrix:
|
|
||||||
platform: [macos-latest, ubuntu-20.04, windows-latest]
|
|
||||||
|
|
||||||
runs-on: ${{ matrix.platform }}
|
|
||||||
steps:
|
|
||||||
- name: Checkout repository
|
|
||||||
uses: actions/checkout@v4.2.0
|
|
||||||
with:
|
|
||||||
submodules: true
|
|
||||||
- name: Setup node
|
|
||||||
uses: actions/setup-node@v4.4.0
|
|
||||||
with:
|
|
||||||
node-version: 20.12.2
|
|
||||||
cache: 'npm'
|
|
||||||
- name: Install Rust stable
|
|
||||||
uses: actions-rs/toolchain@v1.0.7
|
|
||||||
with:
|
|
||||||
toolchain: stable
|
|
||||||
- name: Install dependencies (ubuntu only)
|
|
||||||
if: matrix.platform == 'ubuntu-20.04'
|
|
||||||
run: |
|
|
||||||
sudo apt-get update
|
|
||||||
sudo apt-get install -y libgtk-3-dev webkit2gtk-4.0 libappindicator3-dev librsvg2-dev patchelf
|
|
||||||
- name: Install cinny dependencies
|
|
||||||
run: cd cinny && npm ci
|
|
||||||
- name: Install tauri dependencies
|
|
||||||
run: npm ci
|
|
||||||
- name: Build desktop app with Tauri
|
|
||||||
uses: tauri-apps/tauri-action@v0.5.14
|
|
||||||
env:
|
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
# Paarrot
|
# Paarrot
|
||||||
|
|
||||||
Paarrot is a Matrix client focusing primarily on simple, elegant and secure interface. The desktop app is built with Electron and based on Cinny.
|
Paarrot is a Matrix client focusing primarily on simple, elegant and secure interface. The desktop app is built with Electron and based on Cinny.
|
||||||
|
|
||||||
## Download
|
## Download
|
||||||
|
|
||||||
Installers for Windows and Linux can be downloaded from [releases](http://synbox.ruv.wtf:8418/litruv/cinny-desktop/releases).
|
Installers for Windows and Linux can be downloaded from [releases](http://synbox.ruv.wtf:8418/litruv/cinny-desktop/releases).
|
||||||
|
|||||||
4
android/.gitignore
vendored
@@ -53,9 +53,7 @@ captures/
|
|||||||
.idea/navEditor.xml
|
.idea/navEditor.xml
|
||||||
|
|
||||||
# Keystore files
|
# Keystore files
|
||||||
# Uncomment the following lines if you do not want to check your keystore files in.
|
*.jks
|
||||||
#*.jks
|
|
||||||
#*.keystore
|
|
||||||
|
|
||||||
# External native build folder generated in Android Studio 2.2 and later
|
# External native build folder generated in Android Studio 2.2 and later
|
||||||
.externalNativeBuild
|
.externalNativeBuild
|
||||||
|
|||||||
61
android/SIGNING_SETUP.md
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
# Android App Signing Setup
|
||||||
|
|
||||||
|
## Why This Matters
|
||||||
|
Android requires apps to be signed with the same key for updates to work. Without proper signing, users cannot update your app - they must uninstall and reinstall, losing all data.
|
||||||
|
|
||||||
|
## Setup Instructions
|
||||||
|
|
||||||
|
### 1. Create a Keystore (if you don't have one)
|
||||||
|
|
||||||
|
Run this command in the `android/` directory:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
keytool -genkey -v -keystore paarrot-release.keystore -alias paarrot -keyalg RSA -keysize 2048 -validity 10000
|
||||||
|
```
|
||||||
|
|
||||||
|
You'll be asked for:
|
||||||
|
- Keystore password (remember this!)
|
||||||
|
- Key password (remember this!)
|
||||||
|
- Your name, organization, etc.
|
||||||
|
|
||||||
|
**IMPORTANT**: Back up this keystore file and remember the passwords! If you lose either, you cannot update your app.
|
||||||
|
|
||||||
|
### 2. Create keystore.properties
|
||||||
|
|
||||||
|
Copy the example file:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp keystore.properties.example keystore.properties
|
||||||
|
```
|
||||||
|
|
||||||
|
Then edit `keystore.properties` and fill in:
|
||||||
|
- `storeFile`: path to your keystore (e.g., `paarrot-release.keystore`)
|
||||||
|
- `storePassword`: the keystore password you set
|
||||||
|
- `keyAlias`: the alias (e.g., `paarrot`)
|
||||||
|
- `keyPassword`: the key password you set
|
||||||
|
|
||||||
|
### 3. Verify
|
||||||
|
|
||||||
|
The file `keystore.properties` should be gitignored (check `.gitignore`).
|
||||||
|
|
||||||
|
### 4. Build
|
||||||
|
|
||||||
|
Now your release builds will be properly signed:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd android
|
||||||
|
./gradlew assembleRelease
|
||||||
|
```
|
||||||
|
|
||||||
|
The signed APK will be in: `app/build/outputs/apk/release/`
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### "Updates not installing"
|
||||||
|
- You're using a different keystore than the original APK
|
||||||
|
- Solution: Use the same keystore that was used for the first release
|
||||||
|
|
||||||
|
### "keystore.properties not found"
|
||||||
|
- Normal for debug builds
|
||||||
|
- Only needed for release builds
|
||||||
|
- Create the file following step 2 above
|
||||||
@@ -1,25 +1,61 @@
|
|||||||
apply plugin: 'com.android.application'
|
apply plugin: 'com.android.application'
|
||||||
|
apply plugin: 'kotlin-android'
|
||||||
|
|
||||||
|
// Load keystore properties
|
||||||
|
def keystorePropertiesFile = rootProject.file("keystore.properties")
|
||||||
|
def keystoreProperties = new Properties()
|
||||||
|
if (keystorePropertiesFile.exists()) {
|
||||||
|
keystoreProperties.load(new FileInputStream(keystorePropertiesFile))
|
||||||
|
}
|
||||||
|
|
||||||
android {
|
android {
|
||||||
namespace = "com.paarrot.app"
|
namespace = "com.paarrot.app"
|
||||||
compileSdk = rootProject.ext.compileSdkVersion
|
compileSdk = rootProject.ext.compileSdkVersion
|
||||||
|
|
||||||
|
signingConfigs {
|
||||||
|
release {
|
||||||
|
if (keystorePropertiesFile.exists()) {
|
||||||
|
storeFile file(keystoreProperties['storeFile'])
|
||||||
|
storePassword keystoreProperties['storePassword']
|
||||||
|
keyAlias keystoreProperties['keyAlias']
|
||||||
|
keyPassword keystoreProperties['keyPassword']
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
defaultConfig {
|
defaultConfig {
|
||||||
applicationId "com.paarrot.app"
|
applicationId "com.paarrot.app"
|
||||||
minSdkVersion rootProject.ext.minSdkVersion
|
minSdkVersion rootProject.ext.minSdkVersion
|
||||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||||
versionCode 1
|
versionCode 1783526741
|
||||||
versionName "1.0"
|
versionName "2026-07-08.160541.766"
|
||||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||||
aaptOptions {
|
aaptOptions {
|
||||||
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
|
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
|
||||||
// Default: https://android.googlesource.com/platform/frameworks/base/+/282e181b58cf72b6ca770dc7ca5f91f135444502/tools/aapt/AaptAssets.cpp#61
|
// Default: https://android.googlesource.com/platform/frameworks/base/+/282e181b58cf72b6ca770dc7ca5f91f135444502/tools/aapt/AaptAssets.cpp#61
|
||||||
ignoreAssetsPattern = '!.svn:!.git:!.ds_store:!*.scc:.*:!CVS:!thumbs.db:!picasa.ini:!*~'
|
ignoreAssetsPattern = '!.svn:!.git:!.ds_store:!*.scc:.*:!CVS:!thumbs.db:!picasa.ini:!*~'
|
||||||
}
|
}
|
||||||
|
if (keystorePropertiesFile.exists()) {
|
||||||
|
signingConfig signingConfigs.release
|
||||||
|
}
|
||||||
}
|
}
|
||||||
buildTypes {
|
buildTypes {
|
||||||
|
debug {
|
||||||
|
if (keystorePropertiesFile.exists()) {
|
||||||
|
signingConfig signingConfigs.release
|
||||||
|
}
|
||||||
|
}
|
||||||
release {
|
release {
|
||||||
minifyEnabled false
|
minifyEnabled false
|
||||||
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
|
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
|
||||||
|
if (keystorePropertiesFile.exists()) {
|
||||||
|
signingConfig signingConfigs.release
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
applicationVariants.all { variant ->
|
||||||
|
variant.outputs.all { output ->
|
||||||
|
outputFileName = "Paarrot-${variant.versionName}.apk"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -36,6 +72,8 @@ dependencies {
|
|||||||
implementation "androidx.coordinatorlayout:coordinatorlayout:$androidxCoordinatorLayoutVersion"
|
implementation "androidx.coordinatorlayout:coordinatorlayout:$androidxCoordinatorLayoutVersion"
|
||||||
implementation "androidx.core:core-splashscreen:$coreSplashScreenVersion"
|
implementation "androidx.core:core-splashscreen:$coreSplashScreenVersion"
|
||||||
implementation project(':capacitor-android')
|
implementation project(':capacitor-android')
|
||||||
|
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3'
|
||||||
|
implementation 'org.unifiedpush.android:connector:3.3.2'
|
||||||
testImplementation "junit:junit:$junitVersion"
|
testImplementation "junit:junit:$junitVersion"
|
||||||
androidTestImplementation "androidx.test.ext:junit:$androidxJunitVersion"
|
androidTestImplementation "androidx.test.ext:junit:$androidxJunitVersion"
|
||||||
androidTestImplementation "androidx.test.espresso:espresso-core:$androidxEspressoCoreVersion"
|
androidTestImplementation "androidx.test.espresso:espresso-core:$androidxEspressoCoreVersion"
|
||||||
|
|||||||
@@ -22,6 +22,24 @@
|
|||||||
<category android:name="android.intent.category.LAUNCHER" />
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
|
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.SEND" />
|
||||||
|
<category android:name="android.intent.category.DEFAULT" />
|
||||||
|
<data android:mimeType="text/*" />
|
||||||
|
</intent-filter>
|
||||||
|
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.SEND" />
|
||||||
|
<category android:name="android.intent.category.DEFAULT" />
|
||||||
|
<data android:mimeType="*/*" />
|
||||||
|
</intent-filter>
|
||||||
|
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.SEND_MULTIPLE" />
|
||||||
|
<category android:name="android.intent.category.DEFAULT" />
|
||||||
|
<data android:mimeType="*/*" />
|
||||||
|
</intent-filter>
|
||||||
|
|
||||||
</activity>
|
</activity>
|
||||||
|
|
||||||
<provider
|
<provider
|
||||||
@@ -33,11 +51,37 @@
|
|||||||
android:name="android.support.FILE_PROVIDER_PATHS"
|
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||||
android:resource="@xml/file_paths"></meta-data>
|
android:resource="@xml/file_paths"></meta-data>
|
||||||
</provider>
|
</provider>
|
||||||
|
|
||||||
|
<service
|
||||||
|
android:name=".MatrixSyncService"
|
||||||
|
android:foregroundServiceType="dataSync"
|
||||||
|
android:exported="false"
|
||||||
|
android:stopWithTask="false" />
|
||||||
|
|
||||||
|
<service
|
||||||
|
android:name=".UnifiedPushService"
|
||||||
|
android:exported="false">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="org.unifiedpush.android.connector.PUSH_EVENT" />
|
||||||
|
</intent-filter>
|
||||||
|
</service>
|
||||||
|
|
||||||
|
<receiver
|
||||||
|
android:name=".BootReceiver"
|
||||||
|
android:exported="true">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.BOOT_COMPLETED" />
|
||||||
|
</intent-filter>
|
||||||
|
</receiver>
|
||||||
</application>
|
</application>
|
||||||
|
|
||||||
<!-- Permissions -->
|
<!-- Permissions -->
|
||||||
|
|
||||||
<uses-permission android:name="android.permission.INTERNET" />
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
|
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||||
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||||
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
|
||||||
|
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
|
||||||
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
||||||
<uses-permission android:name="android.permission.CAMERA" />
|
<uses-permission android:name="android.permission.CAMERA" />
|
||||||
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
|
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
|
||||||
|
|||||||
21
android/app/src/main/java/com/paarrot/app/BootReceiver.kt
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
package com.paarrot.app
|
||||||
|
|
||||||
|
import android.content.BroadcastReceiver
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Re-registers UnifiedPush after boot when credentials still exist.
|
||||||
|
*/
|
||||||
|
class BootReceiver : BroadcastReceiver() {
|
||||||
|
|
||||||
|
override fun onReceive(context: Context, intent: Intent) {
|
||||||
|
if (intent.action != Intent.ACTION_BOOT_COMPLETED) return
|
||||||
|
|
||||||
|
val prefs = context.getSharedPreferences(SyncServicePlugin.PREFS, Context.MODE_PRIVATE)
|
||||||
|
prefs.getString(MatrixSyncService.EXTRA_HOMESERVER, null) ?: return
|
||||||
|
prefs.getString(MatrixSyncService.EXTRA_TOKEN, null) ?: return
|
||||||
|
|
||||||
|
UnifiedPushManager.register(context, activity = null)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,14 @@
|
|||||||
package com.paarrot.app;
|
package com.paarrot.app;
|
||||||
|
|
||||||
|
import android.os.Bundle;
|
||||||
import com.getcapacitor.BridgeActivity;
|
import com.getcapacitor.BridgeActivity;
|
||||||
|
|
||||||
public class MainActivity extends BridgeActivity {}
|
public class MainActivity extends BridgeActivity {
|
||||||
|
@Override
|
||||||
|
protected void onCreate(Bundle savedInstanceState) {
|
||||||
|
registerPlugin(SyncServicePlugin.class);
|
||||||
|
registerPlugin(ShareHandlerPlugin.class);
|
||||||
|
registerPlugin(PluginStoragePlugin.class);
|
||||||
|
super.onCreate(savedInstanceState);
|
||||||
|
}
|
||||||
|
}
|
||||||
423
android/app/src/main/java/com/paarrot/app/MatrixSyncService.kt
Normal file
@@ -0,0 +1,423 @@
|
|||||||
|
package com.paarrot.app
|
||||||
|
|
||||||
|
import android.app.Notification
|
||||||
|
import android.app.NotificationChannel
|
||||||
|
import android.app.NotificationManager
|
||||||
|
import android.app.PendingIntent
|
||||||
|
import android.app.Service
|
||||||
|
import android.media.AudioAttributes
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.net.Uri
|
||||||
|
import android.os.Build
|
||||||
|
import android.util.Log
|
||||||
|
import android.graphics.Bitmap
|
||||||
|
import android.graphics.BitmapFactory
|
||||||
|
import androidx.core.app.NotificationCompat
|
||||||
|
import kotlinx.coroutines.CancellationException
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.SupervisorJob
|
||||||
|
import kotlinx.coroutines.cancel
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import org.json.JSONObject
|
||||||
|
import java.net.HttpURLConnection
|
||||||
|
import java.net.URL
|
||||||
|
import java.net.URLEncoder
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ForegroundService that performs a short, one-shot Matrix /sync fetch after
|
||||||
|
* a wake ping from UnifiedPush or a manual diagnostic trigger.
|
||||||
|
*/
|
||||||
|
class MatrixSyncService : Service() {
|
||||||
|
|
||||||
|
private val job = SupervisorJob()
|
||||||
|
private val serviceScope = CoroutineScope(Dispatchers.IO + job)
|
||||||
|
|
||||||
|
/** Event IDs already shown as notifications this session — prevents duplicates on restart. */
|
||||||
|
private val shownEventIds = HashSet<String>(64)
|
||||||
|
|
||||||
|
/** In-memory cache of MXID → display name to avoid redundant API calls per service run. */
|
||||||
|
private val displayNameCache = HashMap<String, String>(16)
|
||||||
|
|
||||||
|
/** In-memory cache of MXID → avatar bitmap (null = no avatar or fetch failed). */
|
||||||
|
private val avatarCache = HashMap<String, Bitmap?>(16)
|
||||||
|
|
||||||
|
private data class UserProfile(val displayName: String, val avatar: Bitmap?)
|
||||||
|
|
||||||
|
override fun onBind(intent: Intent?) = null
|
||||||
|
|
||||||
|
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||||
|
val prefs = applicationContext.getSharedPreferences(SyncServicePlugin.PREFS, Context.MODE_PRIVATE)
|
||||||
|
|
||||||
|
val homeserver = intent?.getStringExtra(EXTRA_HOMESERVER)
|
||||||
|
?: prefs.getString(EXTRA_HOMESERVER, null)
|
||||||
|
val token = intent?.getStringExtra(EXTRA_TOKEN)
|
||||||
|
?: prefs.getString(EXTRA_TOKEN, null)
|
||||||
|
val userId = intent?.getStringExtra(EXTRA_USER_ID)
|
||||||
|
?: prefs.getString(EXTRA_USER_ID, null) ?: ""
|
||||||
|
|
||||||
|
if (homeserver == null || token == null) {
|
||||||
|
stopSelf()
|
||||||
|
return START_NOT_STICKY
|
||||||
|
}
|
||||||
|
|
||||||
|
val triggerReason = intent?.getStringExtra(EXTRA_TRIGGER_REASON) ?: MODE_ONE_SHOT
|
||||||
|
startForeground(NOTIF_ID_STATUS, buildStatusNotification())
|
||||||
|
|
||||||
|
serviceScope.launch {
|
||||||
|
try {
|
||||||
|
runSingleSyncFetch(homeserver, token, userId)
|
||||||
|
Log.d(TAG, "One-shot sync completed (reason=$triggerReason)")
|
||||||
|
} finally {
|
||||||
|
stopForegroundCompat()
|
||||||
|
stopSelf(startId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return START_NOT_STICKY
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDestroy() {
|
||||||
|
super.onDestroy()
|
||||||
|
job.cancel()
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun runSingleSyncFetch(homeserver: String, token: String, userId: String) {
|
||||||
|
val prefs = applicationContext.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
||||||
|
val since = prefs.getString(KEY_SINCE, null)
|
||||||
|
val isFirstSync = since == null
|
||||||
|
|
||||||
|
try {
|
||||||
|
val url = buildSyncUrl(homeserver.trimEnd('/'), since)
|
||||||
|
val (responseCode, body) = doHttpGet(url, token)
|
||||||
|
|
||||||
|
when (responseCode) {
|
||||||
|
200 -> {
|
||||||
|
val json = JSONObject(body ?: "{}")
|
||||||
|
val nextBatch = json.optString("next_batch").takeIf { it.isNotBlank() }
|
||||||
|
|
||||||
|
if (nextBatch != null) {
|
||||||
|
prefs.edit().putString(KEY_SINCE, nextBatch).apply()
|
||||||
|
|
||||||
|
if (!isFirstSync && !appInForeground) {
|
||||||
|
processRoomEvents(json, userId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
401, 403 -> {
|
||||||
|
Log.w(TAG, "Auth error $responseCode — clearing credentials")
|
||||||
|
applicationContext
|
||||||
|
.getSharedPreferences(SyncServicePlugin.PREFS, Context.MODE_PRIVATE)
|
||||||
|
.edit()
|
||||||
|
.clear()
|
||||||
|
.apply()
|
||||||
|
}
|
||||||
|
else -> {
|
||||||
|
Log.w(TAG, "Sync fetch failed with HTTP $responseCode")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e: CancellationException) {
|
||||||
|
throw e
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.w(TAG, "Sync error: ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun buildSyncUrl(base: String, since: String?): String {
|
||||||
|
val filter = """{"room":{"timeline":{"limit":10,"types":["m.room.message"]},"state":{"types":[]},"account_data":{"types":[]},"ephemeral":{"types":[]}},"account_data":{"types":[]},"presence":{"types":[]}}"""
|
||||||
|
val encodedFilter = URLEncoder.encode(filter, "UTF-8")
|
||||||
|
val sinceParam = if (since != null) "&since=${URLEncoder.encode(since, "UTF-8")}" else ""
|
||||||
|
return "$base/_matrix/client/v3/sync?timeout=12000&filter=$encodedFilter$sinceParam"
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun doHttpGet(urlString: String, token: String): Pair<Int, String?> =
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
val conn = URL(urlString).openConnection() as HttpURLConnection
|
||||||
|
try {
|
||||||
|
conn.requestMethod = "GET"
|
||||||
|
conn.setRequestProperty("Authorization", "Bearer $token")
|
||||||
|
conn.setRequestProperty("Accept", "application/json")
|
||||||
|
conn.connectTimeout = 5_000
|
||||||
|
conn.readTimeout = 35_000
|
||||||
|
val code = conn.responseCode
|
||||||
|
val body = if (code == 200) conn.inputStream.bufferedReader().readText() else null
|
||||||
|
Pair(code, body)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Pair(-1, null)
|
||||||
|
} finally {
|
||||||
|
conn.disconnect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun processRoomEvents(sync: JSONObject, myUserId: String) {
|
||||||
|
val prefs = applicationContext.getSharedPreferences(SyncServicePlugin.PREFS, Context.MODE_PRIVATE)
|
||||||
|
val homeserver = prefs.getString(EXTRA_HOMESERVER, null)?.trimEnd('/') ?: return
|
||||||
|
val token = prefs.getString(EXTRA_TOKEN, null) ?: return
|
||||||
|
val joinedRooms = sync.optJSONObject("rooms")?.optJSONObject("join") ?: return
|
||||||
|
val nm = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||||
|
ensureMessageChannel(nm)
|
||||||
|
|
||||||
|
val roomIds = joinedRooms.keys().asSequence().toList()
|
||||||
|
for (roomId in roomIds) {
|
||||||
|
val timeline = joinedRooms.optJSONObject(roomId)
|
||||||
|
?.optJSONObject("timeline") ?: continue
|
||||||
|
val events = timeline.optJSONArray("events") ?: continue
|
||||||
|
|
||||||
|
for (i in 0 until events.length()) {
|
||||||
|
val event = events.optJSONObject(i) ?: continue
|
||||||
|
val eventId = event.optString("event_id")
|
||||||
|
|
||||||
|
if (event.optString("type") != "m.room.message") continue
|
||||||
|
if (event.optString("sender") == myUserId) continue
|
||||||
|
if (eventId.isNotBlank() && !shownEventIds.add(eventId)) continue
|
||||||
|
|
||||||
|
val content = event.optJSONObject("content") ?: continue
|
||||||
|
val msgtype = content.optString("msgtype")
|
||||||
|
val rawBody = content.optString("body")
|
||||||
|
val body = when (msgtype) {
|
||||||
|
"m.image" -> if (rawBody.isNotBlank()) "📷 $rawBody" else "📷 Photo"
|
||||||
|
"m.video" -> if (rawBody.isNotBlank()) "🎥 $rawBody" else "🎥 Video"
|
||||||
|
"m.audio" -> if (rawBody.isNotBlank()) "🎵 $rawBody" else "🎵 Audio"
|
||||||
|
"m.file" -> if (rawBody.isNotBlank()) "📎 $rawBody" else "📎 File"
|
||||||
|
"m.sticker" -> if (rawBody.isNotBlank()) "🖼️ $rawBody" else "🖼️ Sticker"
|
||||||
|
else -> rawBody.takeIf { it.isNotBlank() } ?: continue
|
||||||
|
}
|
||||||
|
|
||||||
|
val sender = event.optString("sender")
|
||||||
|
val profile = resolveProfile(sender, homeserver, token)
|
||||||
|
|
||||||
|
// For unencrypted image/sticker messages, try to download a preview bitmap.
|
||||||
|
// Encrypted messages have a `file` object instead of a top-level `url`.
|
||||||
|
val inlineImage: Bitmap? = if (
|
||||||
|
(msgtype == "m.image" || msgtype == "m.sticker") && !content.has("file")
|
||||||
|
) {
|
||||||
|
content.optString("url").takeIf { it.startsWith("mxc://") }
|
||||||
|
?.let { mxcToDownloadUrl(it, homeserver) }
|
||||||
|
?.let { downloadBitmap(it, token) }
|
||||||
|
} else null
|
||||||
|
|
||||||
|
showMessageNotification(nm, profile.displayName, body, profile.avatar, inlineImage)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun resolveProfile(mxid: String, homeserver: String, token: String): UserProfile {
|
||||||
|
val cachedName = displayNameCache[mxid]
|
||||||
|
if (cachedName != null) return UserProfile(cachedName, avatarCache[mxid])
|
||||||
|
|
||||||
|
val fallback = mxid.substringAfter("@").substringBefore(":")
|
||||||
|
return try {
|
||||||
|
val encodedId = URLEncoder.encode(mxid, "UTF-8")
|
||||||
|
val url = "$homeserver/_matrix/client/v3/profile/$encodedId"
|
||||||
|
val conn = URL(url).openConnection() as HttpURLConnection
|
||||||
|
val (displayName, avatarMxc) = try {
|
||||||
|
conn.requestMethod = "GET"
|
||||||
|
conn.setRequestProperty("Authorization", "Bearer $token")
|
||||||
|
conn.setRequestProperty("Accept", "application/json")
|
||||||
|
conn.connectTimeout = 4_000
|
||||||
|
conn.readTimeout = 4_000
|
||||||
|
if (conn.responseCode == 200) {
|
||||||
|
val body = conn.inputStream.bufferedReader().readText()
|
||||||
|
val obj = JSONObject(body)
|
||||||
|
val name = obj.optString("displayname").takeIf { it.isNotBlank() } ?: fallback
|
||||||
|
val mxc = obj.optString("avatar_url").takeIf { it.startsWith("mxc://") }
|
||||||
|
Pair(name, mxc)
|
||||||
|
} else {
|
||||||
|
Pair(fallback, null)
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
conn.disconnect()
|
||||||
|
}
|
||||||
|
displayNameCache[mxid] = displayName
|
||||||
|
val avatar = avatarMxc
|
||||||
|
?.let { mxcToThumbnailUrl(it, homeserver, size = 96) }
|
||||||
|
?.let { downloadBitmap(it, token) }
|
||||||
|
avatarCache[mxid] = avatar
|
||||||
|
UserProfile(displayName, avatar)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.w(TAG, "Could not resolve profile for $mxid: ${e.message}")
|
||||||
|
displayNameCache[mxid] = fallback
|
||||||
|
avatarCache[mxid] = null
|
||||||
|
UserProfile(fallback, null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun mxcToThumbnailUrl(mxcUrl: String, homeserver: String, size: Int): String? {
|
||||||
|
val withoutScheme = mxcUrl.removePrefix("mxc://")
|
||||||
|
val slash = withoutScheme.indexOf('/')
|
||||||
|
if (slash < 0) return null
|
||||||
|
val serverName = withoutScheme.substring(0, slash)
|
||||||
|
val mediaId = withoutScheme.substring(slash + 1)
|
||||||
|
return "$homeserver/_matrix/media/v3/thumbnail/$serverName/$mediaId?width=$size&height=$size&method=crop"
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun mxcToDownloadUrl(mxcUrl: String, homeserver: String): String? {
|
||||||
|
val withoutScheme = mxcUrl.removePrefix("mxc://")
|
||||||
|
val slash = withoutScheme.indexOf('/')
|
||||||
|
if (slash < 0) return null
|
||||||
|
val serverName = withoutScheme.substring(0, slash)
|
||||||
|
val mediaId = withoutScheme.substring(slash + 1)
|
||||||
|
return "$homeserver/_matrix/media/v3/download/$serverName/$mediaId"
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun downloadBitmap(urlString: String, token: String): Bitmap? {
|
||||||
|
return try {
|
||||||
|
val conn = URL(urlString).openConnection() as HttpURLConnection
|
||||||
|
try {
|
||||||
|
conn.requestMethod = "GET"
|
||||||
|
conn.setRequestProperty("Authorization", "Bearer $token")
|
||||||
|
conn.connectTimeout = 5_000
|
||||||
|
conn.readTimeout = 10_000
|
||||||
|
if (conn.responseCode == 200) BitmapFactory.decodeStream(conn.inputStream) else null
|
||||||
|
} finally {
|
||||||
|
conn.disconnect()
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.w(TAG, "Failed to download bitmap from $urlString: ${e.message}")
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun showMessageNotification(
|
||||||
|
nm: NotificationManager,
|
||||||
|
sender: String,
|
||||||
|
body: String,
|
||||||
|
largeIcon: Bitmap? = null,
|
||||||
|
inlineImage: Bitmap? = null,
|
||||||
|
) {
|
||||||
|
val launchIntent = packageManager.getLaunchIntentForPackage(packageName)
|
||||||
|
?: Intent(this, MainActivity::class.java)
|
||||||
|
val pi = PendingIntent.getActivity(
|
||||||
|
this, 0, launchIntent,
|
||||||
|
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
||||||
|
)
|
||||||
|
|
||||||
|
val builder = NotificationCompat.Builder(this, CHANNEL_MESSAGES)
|
||||||
|
.setSmallIcon(R.drawable.ic_stat_paarrot)
|
||||||
|
.setContentTitle(sender)
|
||||||
|
.setContentText(body)
|
||||||
|
.setAutoCancel(true)
|
||||||
|
.setContentIntent(pi)
|
||||||
|
.setPriority(NotificationCompat.PRIORITY_HIGH)
|
||||||
|
|
||||||
|
if (largeIcon != null) builder.setLargeIcon(largeIcon)
|
||||||
|
|
||||||
|
if (inlineImage != null) {
|
||||||
|
builder.setStyle(
|
||||||
|
NotificationCompat.BigPictureStyle()
|
||||||
|
.bigPicture(inlineImage)
|
||||||
|
.bigLargeIcon(null as Bitmap?)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
nm.notify(System.currentTimeMillis().toInt(), builder.build())
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun buildStatusNotification(): Notification {
|
||||||
|
val nm = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||||
|
val channel = NotificationChannel(
|
||||||
|
CHANNEL_STATUS, "Sync Status",
|
||||||
|
NotificationManager.IMPORTANCE_MIN,
|
||||||
|
).apply {
|
||||||
|
description = "Paarrot background sync status"
|
||||||
|
setShowBadge(false)
|
||||||
|
}
|
||||||
|
nm.createNotificationChannel(channel)
|
||||||
|
}
|
||||||
|
|
||||||
|
return NotificationCompat.Builder(this, CHANNEL_STATUS)
|
||||||
|
.setSmallIcon(R.drawable.ic_stat_paarrot)
|
||||||
|
.setContentTitle("Paarrot")
|
||||||
|
.setContentText("Checking for new messages")
|
||||||
|
.setPriority(NotificationCompat.PRIORITY_MIN)
|
||||||
|
.setOngoing(true)
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun stopForegroundCompat() {
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
|
||||||
|
stopForeground(STOP_FOREGROUND_REMOVE)
|
||||||
|
} else {
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
stopForeground(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun ensureMessageChannel(nm: NotificationManager) {
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||||
|
val soundUri = Uri.parse("android.resource://$packageName/${R.raw.paarrot_notification}")
|
||||||
|
val channel = NotificationChannel(
|
||||||
|
CHANNEL_MESSAGES, "Messages",
|
||||||
|
NotificationManager.IMPORTANCE_HIGH,
|
||||||
|
).apply {
|
||||||
|
description = "Matrix message notifications"
|
||||||
|
setSound(
|
||||||
|
soundUri,
|
||||||
|
AudioAttributes.Builder()
|
||||||
|
.setUsage(AudioAttributes.USAGE_NOTIFICATION)
|
||||||
|
.setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION)
|
||||||
|
.build(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
nm.createNotificationChannel(channel)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val TAG = "MatrixSyncService"
|
||||||
|
const val EXTRA_HOMESERVER = "homeserver_url"
|
||||||
|
const val EXTRA_TOKEN = "access_token"
|
||||||
|
const val EXTRA_USER_ID = "user_id"
|
||||||
|
const val EXTRA_DEVICE_ID = "device_id"
|
||||||
|
const val EXTRA_TRIGGER_REASON = "trigger_reason"
|
||||||
|
const val PREFS = "matrix_sync_prefs"
|
||||||
|
const val KEY_SINCE = "since_token"
|
||||||
|
private const val KEY_LAST_WAKE_MS = "last_wake_ms"
|
||||||
|
private const val NOTIF_ID_STATUS = 1001
|
||||||
|
private const val CHANNEL_STATUS = "sync_status"
|
||||||
|
private const val CHANNEL_MESSAGES = "messages_paarrot"
|
||||||
|
private const val MIN_WAKE_INTERVAL_MS = 7_500L
|
||||||
|
const val MODE_ONE_SHOT = "one_shot"
|
||||||
|
|
||||||
|
/** Set by [SyncServicePlugin] — true when the Capacitor WebView UI is visible. */
|
||||||
|
@Volatile
|
||||||
|
var appInForeground = false
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Starts a one-shot sync fetch if credentials are available and the call is not rate-limited.
|
||||||
|
*/
|
||||||
|
fun requestSyncFetch(context: Context, reason: String) {
|
||||||
|
val credsPrefs = context.getSharedPreferences(SyncServicePlugin.PREFS, Context.MODE_PRIVATE)
|
||||||
|
val homeserver = credsPrefs.getString(EXTRA_HOMESERVER, null) ?: return
|
||||||
|
val token = credsPrefs.getString(EXTRA_TOKEN, null) ?: return
|
||||||
|
val userId = credsPrefs.getString(EXTRA_USER_ID, null) ?: ""
|
||||||
|
val deviceId = credsPrefs.getString(EXTRA_DEVICE_ID, null) ?: ""
|
||||||
|
|
||||||
|
val syncPrefs = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
||||||
|
val now = System.currentTimeMillis()
|
||||||
|
val lastWake = syncPrefs.getLong(KEY_LAST_WAKE_MS, 0L)
|
||||||
|
if (now - lastWake < MIN_WAKE_INTERVAL_MS) {
|
||||||
|
Log.d(TAG, "Skipping wake: rate-limited ($reason)")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
syncPrefs.edit().putLong(KEY_LAST_WAKE_MS, now).apply()
|
||||||
|
|
||||||
|
val intent = Intent(context, MatrixSyncService::class.java).apply {
|
||||||
|
putExtra(EXTRA_HOMESERVER, homeserver)
|
||||||
|
putExtra(EXTRA_TOKEN, token)
|
||||||
|
putExtra(EXTRA_USER_ID, userId)
|
||||||
|
putExtra(EXTRA_DEVICE_ID, deviceId)
|
||||||
|
putExtra(EXTRA_TRIGGER_REASON, reason)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||||
|
context.startForegroundService(intent)
|
||||||
|
} else {
|
||||||
|
context.startService(intent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
250
android/app/src/main/java/com/paarrot/app/PluginStoragePlugin.kt
Normal file
@@ -0,0 +1,250 @@
|
|||||||
|
package com.paarrot.app
|
||||||
|
|
||||||
|
import com.getcapacitor.JSArray
|
||||||
|
import com.getcapacitor.JSObject
|
||||||
|
import com.getcapacitor.Plugin
|
||||||
|
import com.getcapacitor.PluginCall
|
||||||
|
import com.getcapacitor.PluginMethod
|
||||||
|
import com.getcapacitor.annotation.CapacitorPlugin
|
||||||
|
import org.json.JSONArray
|
||||||
|
import org.json.JSONObject
|
||||||
|
import java.io.File
|
||||||
|
import java.io.FileOutputStream
|
||||||
|
import java.net.HttpURLConnection
|
||||||
|
import java.net.URL
|
||||||
|
import java.text.SimpleDateFormat
|
||||||
|
import java.util.Date
|
||||||
|
import java.util.Locale
|
||||||
|
import java.util.zip.ZipInputStream
|
||||||
|
|
||||||
|
/** Capacitor plugin that stores and manages user extension plugins on Android. */
|
||||||
|
@CapacitorPlugin(name = "PluginStorage")
|
||||||
|
class PluginStoragePlugin : Plugin() {
|
||||||
|
|
||||||
|
private fun pluginsDir(): File {
|
||||||
|
val dir = File(context.filesDir, "plugins")
|
||||||
|
dir.mkdirs()
|
||||||
|
return dir
|
||||||
|
}
|
||||||
|
|
||||||
|
@PluginMethod
|
||||||
|
fun getPath(call: PluginCall) {
|
||||||
|
val result = JSObject()
|
||||||
|
result.put("success", true)
|
||||||
|
result.put("data", pluginsDir().absolutePath)
|
||||||
|
call.resolve(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
@PluginMethod
|
||||||
|
fun list(call: PluginCall) {
|
||||||
|
try {
|
||||||
|
val plugins = pluginsDir().listFiles()?.filter { it.isDirectory } ?: emptyList()
|
||||||
|
val array = JSArray()
|
||||||
|
|
||||||
|
for (dir in plugins) {
|
||||||
|
val metadata = readMetadata(dir)
|
||||||
|
val obj = JSObject()
|
||||||
|
obj.put("id", dir.name)
|
||||||
|
obj.put("name", metadata?.optString("name", dir.name) ?: dir.name)
|
||||||
|
obj.put("version", metadata?.optString("version", "") ?: "")
|
||||||
|
obj.put("description", metadata?.optString("description", "") ?: "")
|
||||||
|
obj.put("author", metadata?.optString("author", "") ?: "")
|
||||||
|
obj.put("repository", metadata?.optString("repository", "") ?: "")
|
||||||
|
obj.put("thumbnail", metadata?.optString("thumbnail", "") ?: "")
|
||||||
|
obj.put("homepage", metadata?.optString("homepage", "") ?: "")
|
||||||
|
obj.put("tags", metadataTagsToJsArray(metadata))
|
||||||
|
obj.put("installedDate", formatInstalledDate(dir.lastModified()))
|
||||||
|
obj.put("path", dir.absolutePath)
|
||||||
|
array.put(obj)
|
||||||
|
}
|
||||||
|
|
||||||
|
val result = JSObject()
|
||||||
|
result.put("success", true)
|
||||||
|
result.put("data", array)
|
||||||
|
call.resolve(result)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
val result = JSObject()
|
||||||
|
result.put("success", false)
|
||||||
|
result.put("error", e.message ?: "Failed to list plugins")
|
||||||
|
call.resolve(result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@PluginMethod
|
||||||
|
fun download(call: PluginCall) {
|
||||||
|
val pluginId = call.getString("pluginId")
|
||||||
|
?: return call.reject("pluginId required")
|
||||||
|
val downloadUrl = call.getString("downloadUrl")
|
||||||
|
?: return call.reject("downloadUrl required")
|
||||||
|
val name = call.getString("name") ?: pluginId
|
||||||
|
|
||||||
|
Thread {
|
||||||
|
try {
|
||||||
|
val destDir = File(pluginsDir(), pluginId)
|
||||||
|
if (destDir.exists()) {
|
||||||
|
destDir.deleteRecursively()
|
||||||
|
}
|
||||||
|
destDir.mkdirs()
|
||||||
|
|
||||||
|
val tempZip = File(context.cacheDir, "plugin-download-$pluginId.zip")
|
||||||
|
downloadFile(downloadUrl, tempZip)
|
||||||
|
extractZip(tempZip, destDir)
|
||||||
|
tempZip.delete()
|
||||||
|
|
||||||
|
normalisePluginLayout(destDir)
|
||||||
|
|
||||||
|
if (!File(destDir, "index.js").exists()) {
|
||||||
|
val result = JSObject()
|
||||||
|
result.put("success", false)
|
||||||
|
result.put("error", "Downloaded plugin is missing index.js")
|
||||||
|
call.resolve(result)
|
||||||
|
return@Thread
|
||||||
|
}
|
||||||
|
|
||||||
|
val result = JSObject()
|
||||||
|
result.put("success", true)
|
||||||
|
val data = JSObject()
|
||||||
|
data.put("path", destDir.absolutePath)
|
||||||
|
result.put("data", data)
|
||||||
|
call.resolve(result)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
val result = JSObject()
|
||||||
|
result.put("success", false)
|
||||||
|
result.put("error", e.message ?: "Failed to download plugin: $name")
|
||||||
|
call.resolve(result)
|
||||||
|
}
|
||||||
|
}.start()
|
||||||
|
}
|
||||||
|
|
||||||
|
@PluginMethod
|
||||||
|
fun uninstall(call: PluginCall) {
|
||||||
|
val pluginId = call.getString("pluginId")
|
||||||
|
?: return call.reject("pluginId required")
|
||||||
|
|
||||||
|
try {
|
||||||
|
val destDir = File(pluginsDir(), pluginId)
|
||||||
|
if (destDir.exists()) {
|
||||||
|
destDir.deleteRecursively()
|
||||||
|
}
|
||||||
|
|
||||||
|
val result = JSObject()
|
||||||
|
result.put("success", true)
|
||||||
|
call.resolve(result)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
val result = JSObject()
|
||||||
|
result.put("success", false)
|
||||||
|
result.put("error", e.message ?: "Failed to uninstall plugin")
|
||||||
|
call.resolve(result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@PluginMethod
|
||||||
|
fun readPluginCode(call: PluginCall) {
|
||||||
|
val pluginId = call.getString("pluginId")
|
||||||
|
?: return call.reject("pluginId required")
|
||||||
|
|
||||||
|
val indexFile = File(pluginsDir(), "$pluginId/index.js")
|
||||||
|
if (!indexFile.exists()) {
|
||||||
|
val result = JSObject()
|
||||||
|
result.put("success", false)
|
||||||
|
result.put("error", "Plugin not found: $pluginId")
|
||||||
|
call.resolve(result)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val result = JSObject()
|
||||||
|
result.put("success", true)
|
||||||
|
result.put("data", indexFile.readText())
|
||||||
|
call.resolve(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun readMetadata(pluginDir: File): JSONObject? {
|
||||||
|
val metadataFile = File(pluginDir, "plugin-metadata.json")
|
||||||
|
if (!metadataFile.exists()) return null
|
||||||
|
|
||||||
|
return try {
|
||||||
|
JSONObject(metadataFile.readText())
|
||||||
|
} catch (_: Exception) {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun metadataTagsToJsArray(metadata: JSONObject?): JSArray {
|
||||||
|
val tags = JSArray()
|
||||||
|
val jsonTags = metadata?.optJSONArray("tags") ?: return tags
|
||||||
|
for (i in 0 until jsonTags.length()) {
|
||||||
|
tags.put(jsonTags.optString(i))
|
||||||
|
}
|
||||||
|
return tags
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun formatInstalledDate(timestamp: Long): String {
|
||||||
|
val formatter = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", Locale.US)
|
||||||
|
return formatter.format(Date(timestamp))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun downloadFile(downloadUrl: String, destFile: File) {
|
||||||
|
val connection = (URL(downloadUrl).openConnection() as HttpURLConnection).apply {
|
||||||
|
connectTimeout = 30_000
|
||||||
|
readTimeout = 60_000
|
||||||
|
instanceFollowRedirects = true
|
||||||
|
requestMethod = "GET"
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
connection.connect()
|
||||||
|
if (connection.responseCode !in 200..299) {
|
||||||
|
throw IllegalStateException("HTTP ${connection.responseCode}")
|
||||||
|
}
|
||||||
|
|
||||||
|
connection.inputStream.use { input ->
|
||||||
|
FileOutputStream(destFile).use { output ->
|
||||||
|
input.copyTo(output)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
connection.disconnect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun extractZip(zipFile: File, destDir: File) {
|
||||||
|
destDir.mkdirs()
|
||||||
|
val destPath = destDir.canonicalPath + File.separator
|
||||||
|
|
||||||
|
ZipInputStream(zipFile.inputStream()).use { zipInput ->
|
||||||
|
var entry = zipInput.nextEntry
|
||||||
|
while (entry != null) {
|
||||||
|
val outFile = File(destDir, entry.name)
|
||||||
|
if (!outFile.canonicalPath.startsWith(destPath)) {
|
||||||
|
throw SecurityException("Zip entry outside target directory")
|
||||||
|
}
|
||||||
|
|
||||||
|
if (entry.isDirectory) {
|
||||||
|
outFile.mkdirs()
|
||||||
|
} else {
|
||||||
|
outFile.parentFile?.mkdirs()
|
||||||
|
FileOutputStream(outFile).use { output ->
|
||||||
|
zipInput.copyTo(output)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
zipInput.closeEntry()
|
||||||
|
entry = zipInput.nextEntry
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Flatten single-root zip layouts so index.js ends up directly under the plugin dir. */
|
||||||
|
private fun normalisePluginLayout(destDir: File) {
|
||||||
|
if (File(destDir, "index.js").exists()) return
|
||||||
|
|
||||||
|
val children = destDir.listFiles()?.filter { it.isDirectory } ?: return
|
||||||
|
if (children.size != 1) return
|
||||||
|
|
||||||
|
val nested = children[0]
|
||||||
|
nested.listFiles()?.forEach { child ->
|
||||||
|
child.renameTo(File(destDir, child.name))
|
||||||
|
}
|
||||||
|
nested.deleteRecursively()
|
||||||
|
}
|
||||||
|
}
|
||||||
196
android/app/src/main/java/com/paarrot/app/ShareHandlerPlugin.kt
Normal file
@@ -0,0 +1,196 @@
|
|||||||
|
package com.paarrot.app
|
||||||
|
|
||||||
|
import android.app.Activity
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.net.Uri
|
||||||
|
import android.os.Build
|
||||||
|
import com.getcapacitor.JSArray
|
||||||
|
import com.getcapacitor.JSObject
|
||||||
|
import com.getcapacitor.Plugin
|
||||||
|
import com.getcapacitor.PluginCall
|
||||||
|
import com.getcapacitor.PluginMethod
|
||||||
|
import com.getcapacitor.annotation.CapacitorPlugin
|
||||||
|
import java.io.File
|
||||||
|
import java.io.FileOutputStream
|
||||||
|
|
||||||
|
/** Payload for a single shared file. */
|
||||||
|
data class SharedFilePayload(
|
||||||
|
val path: String,
|
||||||
|
val name: String,
|
||||||
|
val mimeType: String,
|
||||||
|
val size: Long,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Full payload delivered to JS when a share intent is received. */
|
||||||
|
data class SharePayload(
|
||||||
|
val text: String?,
|
||||||
|
val subject: String?,
|
||||||
|
val files: List<SharedFilePayload>,
|
||||||
|
val receivedAt: Long,
|
||||||
|
) {
|
||||||
|
/** Serialize to a [JSObject] for the Capacitor bridge. */
|
||||||
|
fun toJsObject(): JSObject {
|
||||||
|
val obj = JSObject()
|
||||||
|
obj.put("text", text)
|
||||||
|
obj.put("subject", subject)
|
||||||
|
|
||||||
|
val filesArray = JSArray()
|
||||||
|
for (f in files) {
|
||||||
|
val fileObj = JSObject()
|
||||||
|
fileObj.put("path", f.path)
|
||||||
|
fileObj.put("name", f.name)
|
||||||
|
fileObj.put("mimeType", f.mimeType)
|
||||||
|
fileObj.put("size", f.size)
|
||||||
|
filesArray.put(fileObj)
|
||||||
|
}
|
||||||
|
obj.put("files", filesArray)
|
||||||
|
obj.put("receivedAt", receivedAt)
|
||||||
|
return obj
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Singleton that bridges between Android activity lifecycle and the Capacitor plugin. */
|
||||||
|
object ShareIntentStore {
|
||||||
|
var plugin: ShareHandlerPlugin? = null
|
||||||
|
var pendingShare: SharePayload? = null
|
||||||
|
|
||||||
|
/** Parse [intent] and, if it is a share action, store the payload and notify JS. */
|
||||||
|
fun handleIntent(context: Context, intent: Intent?) {
|
||||||
|
if (intent == null) return
|
||||||
|
val action = intent.action ?: return
|
||||||
|
|
||||||
|
if (action != Intent.ACTION_SEND && action != Intent.ACTION_SEND_MULTIPLE) return
|
||||||
|
|
||||||
|
val receivedAt = System.currentTimeMillis()
|
||||||
|
val text = intent.getStringExtra(Intent.EXTRA_TEXT)
|
||||||
|
val subject = intent.getStringExtra(Intent.EXTRA_SUBJECT)
|
||||||
|
|
||||||
|
val uris = mutableListOf<Uri>()
|
||||||
|
if (action == Intent.ACTION_SEND_MULTIPLE) {
|
||||||
|
@Suppress("UNCHECKED_CAST")
|
||||||
|
val list = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||||
|
intent.getParcelableArrayListExtra(Intent.EXTRA_STREAM, Uri::class.java)
|
||||||
|
} else {
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
intent.getParcelableArrayListExtra(Intent.EXTRA_STREAM)
|
||||||
|
}
|
||||||
|
list?.let { uris.addAll(it) }
|
||||||
|
} else {
|
||||||
|
val uri = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||||
|
intent.getParcelableExtra(Intent.EXTRA_STREAM, Uri::class.java)
|
||||||
|
} else {
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
intent.getParcelableExtra(Intent.EXTRA_STREAM)
|
||||||
|
}
|
||||||
|
uri?.let { uris.add(it) }
|
||||||
|
}
|
||||||
|
|
||||||
|
val copiedFiles = uris.mapNotNull { copySharedUri(context, it, receivedAt) }
|
||||||
|
|
||||||
|
val payload = SharePayload(
|
||||||
|
text = text,
|
||||||
|
subject = subject,
|
||||||
|
files = copiedFiles,
|
||||||
|
receivedAt = receivedAt,
|
||||||
|
)
|
||||||
|
|
||||||
|
pendingShare = payload
|
||||||
|
plugin?.emitShare(payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun copySharedUri(context: Context, uri: Uri, receivedAt: Long): SharedFilePayload? {
|
||||||
|
return try {
|
||||||
|
val resolver = context.contentResolver
|
||||||
|
val mimeType = resolver.getType(uri) ?: "application/octet-stream"
|
||||||
|
|
||||||
|
val originalName = resolver.query(uri, arrayOf("_display_name"), null, null, null)
|
||||||
|
?.use { cursor ->
|
||||||
|
if (cursor.moveToFirst()) cursor.getString(0) else null
|
||||||
|
} ?: uri.lastPathSegment ?: "shared_file"
|
||||||
|
|
||||||
|
val safeName = sanitizeFileName(originalName)
|
||||||
|
val destDir = File(context.cacheDir, "shared-intents/$receivedAt")
|
||||||
|
destDir.mkdirs()
|
||||||
|
|
||||||
|
var destFile = File(destDir, safeName)
|
||||||
|
var counter = 1
|
||||||
|
while (destFile.exists()) {
|
||||||
|
val dot = safeName.lastIndexOf('.')
|
||||||
|
val base = if (dot >= 0) safeName.substring(0, dot) else safeName
|
||||||
|
val ext = if (dot >= 0) safeName.substring(dot) else ""
|
||||||
|
destFile = File(destDir, "${base}_$counter$ext")
|
||||||
|
counter++
|
||||||
|
}
|
||||||
|
|
||||||
|
resolver.openInputStream(uri)?.use { input ->
|
||||||
|
FileOutputStream(destFile).use { output -> input.copyTo(output) }
|
||||||
|
}
|
||||||
|
|
||||||
|
SharedFilePayload(
|
||||||
|
path = destFile.absolutePath,
|
||||||
|
name = destFile.name,
|
||||||
|
mimeType = mimeType,
|
||||||
|
size = destFile.length(),
|
||||||
|
)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
android.util.Log.e("ShareHandlerPlugin", "Failed to copy shared URI: $uri", e)
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun sanitizeFileName(name: String): String {
|
||||||
|
return name.replace(Regex("[^a-zA-Z0-9._\\-]"), "_").take(128).ifEmpty { "file" }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Remove cached files from a previous share and clear the stored payload. */
|
||||||
|
fun clearIntent(receivedAt: Long?) {
|
||||||
|
if (receivedAt != null) {
|
||||||
|
val dir = plugin?.context?.let { File(it.cacheDir, "shared-intents/$receivedAt") }
|
||||||
|
dir?.deleteRecursively()
|
||||||
|
}
|
||||||
|
pendingShare = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Capacitor plugin that exposes Android share intents to JavaScript. */
|
||||||
|
@CapacitorPlugin(name = "AndroidShareHandler")
|
||||||
|
class ShareHandlerPlugin : Plugin() {
|
||||||
|
|
||||||
|
override fun load() {
|
||||||
|
ShareIntentStore.plugin = this
|
||||||
|
ShareIntentStore.handleIntent(context, activity.intent)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun handleOnNewIntent(intent: Intent?) {
|
||||||
|
super.handleOnNewIntent(intent)
|
||||||
|
ShareIntentStore.handleIntent(context, intent)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fire the `shareReceived` event toward the JS layer. */
|
||||||
|
fun emitShare(payload: SharePayload) {
|
||||||
|
notifyListeners("shareReceived", payload.toJsObject())
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Returns the pending share payload (if any) as a JS object. */
|
||||||
|
@PluginMethod
|
||||||
|
fun getPendingShare(call: PluginCall) {
|
||||||
|
val pending = ShareIntentStore.pendingShare
|
||||||
|
if (pending == null) {
|
||||||
|
val result = JSObject()
|
||||||
|
result.put("share", JSObject.NULL)
|
||||||
|
call.resolve(result)
|
||||||
|
} else {
|
||||||
|
val result = JSObject()
|
||||||
|
result.put("share", pending.toJsObject())
|
||||||
|
call.resolve(result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Clears the pending share and removes any copied temp files. */
|
||||||
|
@PluginMethod
|
||||||
|
fun clearPendingShare(call: PluginCall) {
|
||||||
|
ShareIntentStore.clearIntent(ShareIntentStore.pendingShare?.receivedAt)
|
||||||
|
call.resolve()
|
||||||
|
}
|
||||||
|
}
|
||||||
103
android/app/src/main/java/com/paarrot/app/SyncServicePlugin.kt
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
package com.paarrot.app
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import com.getcapacitor.JSObject
|
||||||
|
import com.getcapacitor.Plugin
|
||||||
|
import com.getcapacitor.PluginCall
|
||||||
|
import com.getcapacitor.PluginMethod
|
||||||
|
import com.getcapacitor.annotation.CapacitorPlugin
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Capacitor plugin that controls [MatrixSyncService] from JS.
|
||||||
|
*
|
||||||
|
* JS API:
|
||||||
|
* - `start({ homeserverUrl, accessToken, userId, deviceId })` — persist credentials and register UnifiedPush
|
||||||
|
* - `triggerPing({ reason })` — force a one-shot fetch (for testing/manual wake)
|
||||||
|
* - `stop()` — clear persisted credentials and unregister UnifiedPush
|
||||||
|
* - `setAppForeground({ foreground })` — tell the service whether the app UI is visible
|
||||||
|
* - `getStatus()` — returns current fetch and UnifiedPush state
|
||||||
|
*/
|
||||||
|
@CapacitorPlugin(name = "MatrixBackgroundSync")
|
||||||
|
class SyncServicePlugin : Plugin() {
|
||||||
|
|
||||||
|
fun emitUnifiedPushEvent(eventName: String, payload: JSObject) {
|
||||||
|
notifyListeners(eventName, payload, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun load() {
|
||||||
|
super.load()
|
||||||
|
UnifiedPushManager.setPlugin(this)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun handleOnDestroy() {
|
||||||
|
UnifiedPushManager.clearPlugin(this)
|
||||||
|
super.handleOnDestroy()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Persists Matrix credentials and starts UnifiedPush registration. */
|
||||||
|
@PluginMethod
|
||||||
|
fun start(call: PluginCall) {
|
||||||
|
val homeserver = call.getString("homeserverUrl")
|
||||||
|
?: return call.reject("homeserverUrl required")
|
||||||
|
val token = call.getString("accessToken")
|
||||||
|
?: return call.reject("accessToken required")
|
||||||
|
val userId = call.getString("userId") ?: ""
|
||||||
|
val deviceId = call.getString("deviceId") ?: ""
|
||||||
|
|
||||||
|
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit()
|
||||||
|
.putString(MatrixSyncService.EXTRA_HOMESERVER, homeserver)
|
||||||
|
.putString(MatrixSyncService.EXTRA_TOKEN, token)
|
||||||
|
.putString(MatrixSyncService.EXTRA_USER_ID, userId)
|
||||||
|
.putString(MatrixSyncService.EXTRA_DEVICE_ID, deviceId)
|
||||||
|
.apply()
|
||||||
|
|
||||||
|
UnifiedPushManager.register(context, activity)
|
||||||
|
|
||||||
|
call.resolve()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Manually triggers a one-shot sync fetch (mainly for diagnostics/testing). */
|
||||||
|
@PluginMethod
|
||||||
|
fun triggerPing(call: PluginCall) {
|
||||||
|
val reason = call.getString("reason") ?: "manual_plugin_ping"
|
||||||
|
MatrixSyncService.requestSyncFetch(context, reason)
|
||||||
|
call.resolve()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Stops the sync service and erases persisted credentials. */
|
||||||
|
@PluginMethod
|
||||||
|
fun stop(call: PluginCall) {
|
||||||
|
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit().clear().apply()
|
||||||
|
context.stopService(Intent(context, MatrixSyncService::class.java))
|
||||||
|
UnifiedPushManager.unregister(context)
|
||||||
|
call.resolve()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Notifies the service whether the Capacitor WebView UI is currently visible.
|
||||||
|
* When `foreground == true` the service skips firing notifications because
|
||||||
|
* the JS layer handles them directly via LocalNotifications.
|
||||||
|
*/
|
||||||
|
@PluginMethod
|
||||||
|
fun setAppForeground(call: PluginCall) {
|
||||||
|
MatrixSyncService.appInForeground = call.getBoolean("foreground", false) ?: false
|
||||||
|
call.resolve()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Returns whether the sync service is currently running. */
|
||||||
|
@PluginMethod
|
||||||
|
fun getStatus(call: PluginCall) {
|
||||||
|
val am = context.getSystemService(Context.ACTIVITY_SERVICE)
|
||||||
|
as android.app.ActivityManager
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
val running = am.getRunningServices(Int.MAX_VALUE)
|
||||||
|
.any { it.service.className == MatrixSyncService::class.java.name }
|
||||||
|
val result = UnifiedPushManager.getStatus(context).apply { put("running", running) }
|
||||||
|
call.resolve(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
const val PREFS = "sync_service_prefs"
|
||||||
|
}
|
||||||
|
}
|
||||||
149
android/app/src/main/java/com/paarrot/app/UnifiedPushManager.kt
Normal file
@@ -0,0 +1,149 @@
|
|||||||
|
package com.paarrot.app
|
||||||
|
|
||||||
|
import android.app.Activity
|
||||||
|
import android.content.Context
|
||||||
|
import android.util.Log
|
||||||
|
import com.getcapacitor.JSObject
|
||||||
|
import org.unifiedpush.android.connector.FailedReason
|
||||||
|
import org.unifiedpush.android.connector.INSTANCE_DEFAULT
|
||||||
|
import org.unifiedpush.android.connector.UnifiedPush
|
||||||
|
|
||||||
|
/** Coordinates UnifiedPush registration state and bridges events back to JS. */
|
||||||
|
object UnifiedPushManager {
|
||||||
|
private const val TAG = "UnifiedPushManager"
|
||||||
|
private const val PREFS = "unifiedpush_prefs"
|
||||||
|
private const val KEY_ENDPOINT = "endpoint"
|
||||||
|
private const val KEY_INSTANCE = "instance"
|
||||||
|
private const val EVENT_NEW_ENDPOINT = "unifiedPushNewEndpoint"
|
||||||
|
private const val EVENT_UNREGISTERED = "unifiedPushUnregistered"
|
||||||
|
private const val EVENT_REGISTRATION_FAILED = "unifiedPushRegistrationFailed"
|
||||||
|
private const val DEFAULT_MESSAGE = "Paarrot notifications"
|
||||||
|
|
||||||
|
@Volatile
|
||||||
|
private var plugin: SyncServicePlugin? = null
|
||||||
|
|
||||||
|
fun setPlugin(plugin: SyncServicePlugin) {
|
||||||
|
this.plugin = plugin
|
||||||
|
}
|
||||||
|
|
||||||
|
fun clearPlugin(plugin: SyncServicePlugin) {
|
||||||
|
if (this.plugin === plugin) {
|
||||||
|
this.plugin = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun register(context: Context, activity: Activity?) {
|
||||||
|
val savedDistributor = runCatching { UnifiedPush.getSavedDistributor(context) }.getOrNull().orEmpty()
|
||||||
|
if (savedDistributor.isNotBlank()) {
|
||||||
|
requestRegistration(context)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (activity == null) {
|
||||||
|
dispatchRegistrationFailed(FailedReason.ACTION_REQUIRED.name, INSTANCE_DEFAULT)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
UnifiedPush.tryUseCurrentOrDefaultDistributor(activity) { success ->
|
||||||
|
if (success) {
|
||||||
|
requestRegistration(context)
|
||||||
|
} else {
|
||||||
|
dispatchRegistrationFailed(FailedReason.ACTION_REQUIRED.name, INSTANCE_DEFAULT)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun unregister(context: Context) {
|
||||||
|
runCatching {
|
||||||
|
UnifiedPush.unregister(context, INSTANCE_DEFAULT)
|
||||||
|
}.onFailure {
|
||||||
|
Log.w(TAG, "Failed to unregister UnifiedPush: ${it.message}")
|
||||||
|
}
|
||||||
|
val previousEndpoint = getEndpoint(context)
|
||||||
|
clearEndpoint(context)
|
||||||
|
dispatchUnregistered(previousEndpoint, INSTANCE_DEFAULT)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getStatus(context: Context): JSObject {
|
||||||
|
val status = JSObject()
|
||||||
|
status.put("endpoint", getEndpoint(context) ?: "")
|
||||||
|
status.put("instance", getInstance(context) ?: INSTANCE_DEFAULT)
|
||||||
|
status.put("registered", !getEndpoint(context).isNullOrBlank())
|
||||||
|
status.put("distributor", runCatching { UnifiedPush.getSavedDistributor(context) }.getOrDefault(""))
|
||||||
|
status.put(
|
||||||
|
"distributors",
|
||||||
|
runCatching { UnifiedPush.getDistributors(context) }.getOrDefault(emptyList<String>())
|
||||||
|
)
|
||||||
|
return status
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onNewEndpoint(context: Context, endpoint: String, instance: String) {
|
||||||
|
val previousEndpoint = getEndpoint(context)
|
||||||
|
persistEndpoint(context, endpoint, instance)
|
||||||
|
val payload = JSObject().apply {
|
||||||
|
put("endpoint", endpoint)
|
||||||
|
put("instance", instance)
|
||||||
|
put("previousEndpoint", previousEndpoint ?: "")
|
||||||
|
}
|
||||||
|
dispatch(EVENT_NEW_ENDPOINT, payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onRegistrationFailed(reason: String, instance: String) {
|
||||||
|
dispatchRegistrationFailed(reason, instance)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onUnregistered(context: Context, instance: String) {
|
||||||
|
val previousEndpoint = getEndpoint(context)
|
||||||
|
clearEndpoint(context)
|
||||||
|
dispatchUnregistered(previousEndpoint, instance)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun requestRegistration(context: Context) {
|
||||||
|
runCatching {
|
||||||
|
UnifiedPush.register(context, INSTANCE_DEFAULT, DEFAULT_MESSAGE, null)
|
||||||
|
}.onFailure {
|
||||||
|
Log.w(TAG, "UnifiedPush register failed: ${it.message}")
|
||||||
|
dispatchRegistrationFailed(FailedReason.INTERNAL_ERROR.name, INSTANCE_DEFAULT)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun dispatchUnregistered(previousEndpoint: String?, instance: String) {
|
||||||
|
val payload = JSObject().apply {
|
||||||
|
put("previousEndpoint", previousEndpoint ?: "")
|
||||||
|
put("instance", instance)
|
||||||
|
}
|
||||||
|
dispatch(EVENT_UNREGISTERED, payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun dispatchRegistrationFailed(reason: String, instance: String) {
|
||||||
|
val payload = JSObject().apply {
|
||||||
|
put("reason", reason)
|
||||||
|
put("instance", instance)
|
||||||
|
}
|
||||||
|
dispatch(EVENT_REGISTRATION_FAILED, payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun dispatch(eventName: String, payload: JSObject) {
|
||||||
|
plugin?.emitUnifiedPushEvent(eventName, payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun persistEndpoint(context: Context, endpoint: String, instance: String) {
|
||||||
|
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit()
|
||||||
|
.putString(KEY_ENDPOINT, endpoint)
|
||||||
|
.putString(KEY_INSTANCE, instance)
|
||||||
|
.apply()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun clearEndpoint(context: Context) {
|
||||||
|
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit()
|
||||||
|
.remove(KEY_ENDPOINT)
|
||||||
|
.remove(KEY_INSTANCE)
|
||||||
|
.apply()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getEndpoint(context: Context): String? =
|
||||||
|
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).getString(KEY_ENDPOINT, null)
|
||||||
|
|
||||||
|
private fun getInstance(context: Context): String? =
|
||||||
|
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).getString(KEY_INSTANCE, null)
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
package com.paarrot.app
|
||||||
|
|
||||||
|
import android.util.Log
|
||||||
|
import org.unifiedpush.android.connector.FailedReason
|
||||||
|
import org.unifiedpush.android.connector.PushService
|
||||||
|
import org.unifiedpush.android.connector.data.PushEndpoint
|
||||||
|
import org.unifiedpush.android.connector.data.PushMessage
|
||||||
|
|
||||||
|
/** Receives UnifiedPush events and turns push messages into Matrix fetch wakes. */
|
||||||
|
class UnifiedPushService : PushService() {
|
||||||
|
|
||||||
|
override fun onNewEndpoint(endpoint: PushEndpoint, instance: String) {
|
||||||
|
Log.i(TAG, "UnifiedPush endpoint updated for instance=$instance")
|
||||||
|
UnifiedPushManager.onNewEndpoint(applicationContext, endpoint.url, instance)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onMessage(message: PushMessage, instance: String) {
|
||||||
|
Log.d(TAG, "UnifiedPush wake ping received for instance=$instance, bytes=${message.content.size}")
|
||||||
|
MatrixSyncService.requestSyncFetch(applicationContext, "unifiedpush_message")
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onRegistrationFailed(reason: FailedReason, instance: String) {
|
||||||
|
Log.w(TAG, "UnifiedPush registration failed: $reason")
|
||||||
|
UnifiedPushManager.onRegistrationFailed(reason.name, instance)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onUnregistered(instance: String) {
|
||||||
|
Log.i(TAG, "UnifiedPush unregistered for instance=$instance")
|
||||||
|
UnifiedPushManager.onUnregistered(applicationContext, instance)
|
||||||
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
const val TAG = "UnifiedPushService"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,15 +2,30 @@
|
|||||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
android:width="24dp"
|
android:width="24dp"
|
||||||
android:height="24dp"
|
android:height="24dp"
|
||||||
android:viewportWidth="24"
|
android:viewportWidth="18"
|
||||||
android:viewportHeight="24">
|
android:viewportHeight="18">
|
||||||
<path
|
<!--
|
||||||
android:fillColor="#FFFFFFFF"
|
Converted from public/res/svg/notification.svg.
|
||||||
android:pathData="M2,9.1C3.8,5.7 7.2,4 11.6,4c4.4,0 7.8,1.7 10.4,5.1l-2,1.6c-1.7,-1.5 -3.8,-2.4 -6.4,-2.7c-0.2,1.6 -1.6,2.9 -3.3,2.9c-1.7,0 -3.1,-1.2 -3.3,-2.8C5.1,8.5 3.5,9.1 2,10z" />
|
Transforms flattened: outer(1.05922,0,0,1.05922,-0.5628,-0.4805)
|
||||||
<path
|
x inner(1.6762,0,0,1.6762,-4.5071,-10.7901)
|
||||||
android:fillColor="#FFFFFFFF"
|
= scale 1.7754, translate(-5.337,-11.908)
|
||||||
android:pathData="M8.7,11.5c-2.8,0 -5.1,2.3 -5.1,5.1c0,3 2.4,5.4 5.5,5.4c2.9,0 5.4,-1.3 7,-3.7c-0.5,0.1 -0.9,0.2 -1.4,0.2c-3.5,0 -6.4,-2.8 -6.4,-6.3c0,-0.2 0,-0.4 0,-0.7z" />
|
-->
|
||||||
<path
|
<group
|
||||||
android:fillColor="#FFFFFFFF"
|
android:scaleX="1.7754"
|
||||||
android:pathData="M14.7,12.3c3,0.1 5.3,1.6 7.3,4.2c-1.8,1.5 -4.1,2.4 -6.9,2.8c1,-1 1.6,-2.3 1.6,-3.8c0,-1.2 -0.3,-2.3 -0.9,-3.2z" />
|
android:scaleY="1.7754"
|
||||||
|
android:translateX="-5.337"
|
||||||
|
android:translateY="-11.908">
|
||||||
|
<path
|
||||||
|
android:fillColor="#FFFFFFFF"
|
||||||
|
android:fillType="evenOdd"
|
||||||
|
android:pathData="M4.624,14.382C4.624,14.382 1.358,10.79 5.108,8.336C5.501,8.316 7.14,8.899 7.18,10.368C7.22,11.836 5.641,11.172 5.108,11.856C4.575,12.54 4.454,14.121 4.624,14.382Z" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#FFFFFFFF"
|
||||||
|
android:fillType="evenOdd"
|
||||||
|
android:pathData="M5.08,13.547C5.08,13.547 7.28,14.842 7.291,10.74C7.592,10.971 8.588,12.107 7.713,13.777C6.838,15.446 5.392,14.322 5.08,13.547Z" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#FFFFFFFF"
|
||||||
|
android:fillType="evenOdd"
|
||||||
|
android:pathData="M5.921,14.763C5.584,14.449 6.374,15.403 6.704,16.642C6.873,16.232 8.282,17.562 11.166,14.247C12.324,12.581 13.244,11.279 11.249,8.874C9.254,6.469 6.507,6.615 5.469,7.581C4.431,8.546 5.35,7.566 6.653,8.667C7.52,9.399 7.444,10.431 7.444,10.431C7.774,10.551 9.12,12.332 7.935,14.099C7.205,15.187 6.259,15.076 5.921,14.763Z" />
|
||||||
|
</group>
|
||||||
</vector>
|
</vector>
|
||||||
|
Before Width: | Height: | Size: 8.8 KiB After Width: | Height: | Size: 9.0 KiB |
|
Before Width: | Height: | Size: 8.8 KiB After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 8.8 KiB After Width: | Height: | Size: 9.0 KiB |
|
Before Width: | Height: | Size: 4.4 KiB After Width: | Height: | Size: 4.6 KiB |
|
Before Width: | Height: | Size: 4.4 KiB After Width: | Height: | Size: 9.2 KiB |
|
Before Width: | Height: | Size: 4.4 KiB After Width: | Height: | Size: 4.6 KiB |
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 30 KiB |
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 28 KiB After Width: | Height: | Size: 30 KiB |
|
Before Width: | Height: | Size: 28 KiB After Width: | Height: | Size: 63 KiB |
|
Before Width: | Height: | Size: 28 KiB After Width: | Height: | Size: 30 KiB |
|
Before Width: | Height: | Size: 47 KiB After Width: | Height: | Size: 49 KiB |
|
Before Width: | Height: | Size: 47 KiB After Width: | Height: | Size: 106 KiB |
|
Before Width: | Height: | Size: 47 KiB After Width: | Height: | Size: 49 KiB |
BIN
android/app/src/main/res/raw/paarrot_notification.ogg
Normal file
@@ -1,4 +1,4 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<resources>
|
<resources>
|
||||||
<color name="ic_launcher_background">#FFFFFF</color>
|
<color name="ic_launcher_background">#00000000</color>
|
||||||
</resources>
|
</resources>
|
||||||
@@ -1,7 +1,10 @@
|
|||||||
// Top-level build file where you can add configuration options common to all sub-projects/modules.
|
// Top-level build file where you can add configuration options common to all sub-projects/modules.
|
||||||
|
|
||||||
buildscript {
|
buildscript {
|
||||||
|
ext {
|
||||||
|
kotlin_version = '2.3.0'
|
||||||
|
}
|
||||||
|
|
||||||
repositories {
|
repositories {
|
||||||
google()
|
google()
|
||||||
mavenCentral()
|
mavenCentral()
|
||||||
@@ -9,6 +12,7 @@ buildscript {
|
|||||||
dependencies {
|
dependencies {
|
||||||
classpath 'com.android.tools.build:gradle:8.13.0'
|
classpath 'com.android.tools.build:gradle:8.13.0'
|
||||||
classpath 'com.google.gms:google-services:4.4.4'
|
classpath 'com.google.gms:google-services:4.4.4'
|
||||||
|
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
|
||||||
|
|
||||||
// NOTE: Do not place your application dependencies here; they belong
|
// NOTE: Do not place your application dependencies here; they belong
|
||||||
// in the individual module build.gradle files
|
// in the individual module build.gradle files
|
||||||
|
|||||||
4
android/keystore.properties
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
storeFile=../paarrot-release.keystore
|
||||||
|
storePassword=paarrot2026
|
||||||
|
keyAlias=paarrot
|
||||||
|
keyPassword=paarrot2026
|
||||||
14
android/keystore.properties.example
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
# Copy this file to keystore.properties and fill in your actual values
|
||||||
|
# NEVER commit keystore.properties to git!
|
||||||
|
|
||||||
|
# Path to your keystore file (relative to android/ directory)
|
||||||
|
storeFile=paarrot-release.keystore
|
||||||
|
|
||||||
|
# Keystore password
|
||||||
|
storePassword=your_keystore_password_here
|
||||||
|
|
||||||
|
# Key alias
|
||||||
|
keyAlias=paarrot
|
||||||
|
|
||||||
|
# Key password
|
||||||
|
keyPassword=your_key_password_here
|
||||||
BIN
android/paarrot-release.keystore
Normal file
2
cinny
BIN
full_logcat.txt
Normal file
BIN
icons/1024x1024.png
Normal file
|
After Width: | Height: | Size: 1.0 MiB |
BIN
icons/128x128.png
Normal file
|
After Width: | Height: | Size: 24 KiB |
BIN
icons/16x16.png
Normal file
|
After Width: | Height: | Size: 839 B |
BIN
icons/24x24.png
Normal file
|
After Width: | Height: | Size: 1.5 KiB |
BIN
icons/256x256.png
Normal file
|
After Width: | Height: | Size: 81 KiB |
BIN
icons/32x32.png
Normal file
|
After Width: | Height: | Size: 2.3 KiB |
BIN
icons/48x48.png
Normal file
|
After Width: | Height: | Size: 4.6 KiB |
BIN
icons/512x512.png
Normal file
|
After Width: | Height: | Size: 295 KiB |
BIN
icons/64x64.png
Normal file
|
After Width: | Height: | Size: 7.4 KiB |
BIN
icons/icon.icns
BIN
icons/icon.ico
|
Before Width: | Height: | Size: 151 KiB After Width: | Height: | Size: 138 KiB |
@@ -3,5 +3,6 @@
|
|||||||
"url": "http://synbox.ruv.wtf:8418/litruv/cinny-mobile",
|
"url": "http://synbox.ruv.wtf:8418/litruv/cinny-mobile",
|
||||||
"author": "litruv",
|
"author": "litruv",
|
||||||
"name": "Paarrot",
|
"name": "Paarrot",
|
||||||
|
"overrideSource": "Forgejo (Codeberg)",
|
||||||
"additionalSettings": "{\"apkFilterRegEx\":\"Paarrot\\\\.apk\",\"invertAPKFilter\":false,\"autoApkFilterByArch\":true,\"releaseIdSkipPatterns\":[],\"sortMethodChoice\":0}"
|
"additionalSettings": "{\"apkFilterRegEx\":\"Paarrot\\\\.apk\",\"invertAPKFilter\":false,\"autoApkFilterByArch\":true,\"releaseIdSkipPatterns\":[],\"sortMethodChoice\":0}"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,3 @@
|
|||||||
{
|
{
|
||||||
"dependencies": {
|
"dependencies": {}
|
||||||
"@capacitor/android": "8.3.0",
|
|
||||||
"@capacitor/cli": "8.3.0",
|
|
||||||
"@capacitor/core": "8.3.0",
|
|
||||||
"@capacitor/local-notifications": "8.0.2"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
287
overlay/src/app/components/editor/Editor.tsx
Normal file
@@ -0,0 +1,287 @@
|
|||||||
|
/* eslint-disable no-param-reassign */
|
||||||
|
import React, {
|
||||||
|
ClipboardEventHandler,
|
||||||
|
KeyboardEventHandler,
|
||||||
|
ReactNode,
|
||||||
|
forwardRef,
|
||||||
|
useCallback,
|
||||||
|
useState,
|
||||||
|
useEffect,
|
||||||
|
useRef,
|
||||||
|
} from 'react';
|
||||||
|
import { Box, Scroll, Text } from 'folds';
|
||||||
|
import { Descendant, Editor, createEditor, Transforms, Range, Element as SlateElement, Text as SlateText, Point } from 'slate';
|
||||||
|
import {
|
||||||
|
Slate,
|
||||||
|
Editable,
|
||||||
|
withReact,
|
||||||
|
RenderLeafProps,
|
||||||
|
RenderElementProps,
|
||||||
|
RenderPlaceholderProps,
|
||||||
|
ReactEditor,
|
||||||
|
} from 'slate-react';
|
||||||
|
import { withHistory } from 'slate-history';
|
||||||
|
import { BlockType } from './types';
|
||||||
|
import { RenderElement, RenderLeaf } from './Elements';
|
||||||
|
import { CustomElement } from './slate';
|
||||||
|
import * as css from './Editor.css';
|
||||||
|
import { toggleKeyboardShortcut } from './keyboard';
|
||||||
|
import { createCommandElement } from './utils';
|
||||||
|
|
||||||
|
const initialValue: CustomElement[] = [
|
||||||
|
{
|
||||||
|
type: BlockType.Paragraph,
|
||||||
|
children: [{ text: '' }],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const withInline = (editor: Editor): Editor => {
|
||||||
|
const { isInline } = editor;
|
||||||
|
|
||||||
|
editor.isInline = (element) =>
|
||||||
|
[BlockType.Mention, BlockType.Emoticon, BlockType.Link, BlockType.Command].includes(
|
||||||
|
element.type
|
||||||
|
) || isInline(element);
|
||||||
|
|
||||||
|
return editor;
|
||||||
|
};
|
||||||
|
|
||||||
|
const withVoid = (editor: Editor): Editor => {
|
||||||
|
const { isVoid } = editor;
|
||||||
|
|
||||||
|
editor.isVoid = (element) =>
|
||||||
|
[BlockType.Mention, BlockType.Emoticon, BlockType.Command].includes(element.type) ||
|
||||||
|
isVoid(element);
|
||||||
|
|
||||||
|
return editor;
|
||||||
|
};
|
||||||
|
|
||||||
|
type CommandValidatorFn = (commandName: string) => boolean;
|
||||||
|
|
||||||
|
let commandValidator: CommandValidatorFn | null = null;
|
||||||
|
|
||||||
|
export const setCommandValidator = (validator: CommandValidatorFn) => {
|
||||||
|
commandValidator = validator;
|
||||||
|
};
|
||||||
|
|
||||||
|
const withCommandAutoConvert = (editor: Editor): Editor => {
|
||||||
|
// Removed auto-conversion on space to prevent focus issues
|
||||||
|
// Commands are now highlighted visually via decorations
|
||||||
|
// and executed when Enter is pressed
|
||||||
|
return editor;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useEditor = (): Editor => {
|
||||||
|
const [editor] = useState(() =>
|
||||||
|
withCommandAutoConvert(withInline(withVoid(withReact(withHistory(createEditor())))))
|
||||||
|
);
|
||||||
|
return editor;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type EditorChangeHandler = (value: Descendant[]) => void;
|
||||||
|
type CustomEditorProps = {
|
||||||
|
editableName?: string;
|
||||||
|
top?: ReactNode;
|
||||||
|
bottom?: ReactNode;
|
||||||
|
before?: ReactNode;
|
||||||
|
after?: ReactNode;
|
||||||
|
maxHeight?: string;
|
||||||
|
editor: Editor;
|
||||||
|
placeholder?: string;
|
||||||
|
onKeyDown?: KeyboardEventHandler;
|
||||||
|
onKeyUp?: KeyboardEventHandler;
|
||||||
|
onChange?: EditorChangeHandler;
|
||||||
|
onPaste?: ClipboardEventHandler;
|
||||||
|
};
|
||||||
|
export const CustomEditor = forwardRef<HTMLDivElement, CustomEditorProps>(
|
||||||
|
(
|
||||||
|
{
|
||||||
|
editableName,
|
||||||
|
top,
|
||||||
|
bottom,
|
||||||
|
before,
|
||||||
|
after,
|
||||||
|
maxHeight = '50vh',
|
||||||
|
editor,
|
||||||
|
placeholder,
|
||||||
|
onKeyDown,
|
||||||
|
onKeyUp,
|
||||||
|
onChange,
|
||||||
|
onPaste,
|
||||||
|
},
|
||||||
|
ref
|
||||||
|
) => {
|
||||||
|
const renderElement = useCallback(
|
||||||
|
(props: RenderElementProps) => <RenderElement {...props} />,
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
const renderLeaf = useCallback((props: RenderLeafProps) => <RenderLeaf {...props} />, []);
|
||||||
|
|
||||||
|
const decorate = useCallback(([node, path]: [any, number[]]) => {
|
||||||
|
const ranges: any[] = [];
|
||||||
|
|
||||||
|
// Only decorate text nodes in the first paragraph
|
||||||
|
if (
|
||||||
|
path.length === 2 &&
|
||||||
|
path[0] === 0 &&
|
||||||
|
path[1] === 0 &&
|
||||||
|
SlateText.isText(node)
|
||||||
|
) {
|
||||||
|
const firstChild = editor.children[0];
|
||||||
|
|
||||||
|
if (SlateElement.isElement(firstChild) && firstChild.type === BlockType.Paragraph) {
|
||||||
|
const [firstInline, secondInline] = firstChild.children;
|
||||||
|
|
||||||
|
// Don't decorate if we already have a CommandElement
|
||||||
|
if (SlateElement.isElement(secondInline) && secondInline.type === BlockType.Command) {
|
||||||
|
return ranges;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if the text matches /command pattern
|
||||||
|
const text = node.text;
|
||||||
|
const match = text.match(/^(\s*\/\w+)/);
|
||||||
|
|
||||||
|
if (match && commandValidator) {
|
||||||
|
const commandText = match[1].trim();
|
||||||
|
const commandName = commandText.substring(1);
|
||||||
|
|
||||||
|
if (commandValidator(commandName)) {
|
||||||
|
// Create decoration for the command text
|
||||||
|
ranges.push({
|
||||||
|
anchor: { path, offset: 0 },
|
||||||
|
focus: { path, offset: match[1].length },
|
||||||
|
pendingCommand: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ranges;
|
||||||
|
}, [editor]);
|
||||||
|
|
||||||
|
const handleKeydown: KeyboardEventHandler = useCallback(
|
||||||
|
(evt) => {
|
||||||
|
onKeyDown?.(evt);
|
||||||
|
const shortcutToggled = toggleKeyboardShortcut(editor, evt);
|
||||||
|
if (shortcutToggled) evt.preventDefault();
|
||||||
|
},
|
||||||
|
[editor, onKeyDown]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleBeforeInput = useCallback(
|
||||||
|
(event: Event) => {
|
||||||
|
const inputEvent = event as InputEvent;
|
||||||
|
|
||||||
|
// Handle mobile autocorrect replacement that causes text duplication in Slate
|
||||||
|
if (inputEvent.inputType === 'insertReplacementText') {
|
||||||
|
const data = inputEvent.data || inputEvent.dataTransfer?.getData('text/plain');
|
||||||
|
if (!data) return;
|
||||||
|
|
||||||
|
event.preventDefault();
|
||||||
|
|
||||||
|
const domRanges = inputEvent.getTargetRanges?.() ?? [];
|
||||||
|
if (domRanges.length > 0) {
|
||||||
|
const slateRange = ReactEditor.toSlateRange(editor, domRanges[0], {
|
||||||
|
exactMatch: false,
|
||||||
|
suppressThrow: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (slateRange) {
|
||||||
|
Transforms.select(editor, slateRange);
|
||||||
|
Transforms.delete(editor);
|
||||||
|
editor.insertText(data);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const { selection } = editor;
|
||||||
|
if (!selection) return;
|
||||||
|
|
||||||
|
if (!Range.isCollapsed(selection)) {
|
||||||
|
Transforms.delete(editor, { at: selection });
|
||||||
|
} else {
|
||||||
|
const wordBefore = Editor.before(editor, selection.anchor, { unit: 'word' });
|
||||||
|
if (wordBefore) {
|
||||||
|
const wordRange = { anchor: wordBefore, focus: selection.anchor };
|
||||||
|
Transforms.select(editor, wordRange);
|
||||||
|
Transforms.delete(editor);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
editor.insertText(data);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[editor]
|
||||||
|
);
|
||||||
|
|
||||||
|
const editableRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const editableElement = editableRef.current?.querySelector('[data-slate-editor="true"]');
|
||||||
|
if (!editableElement) return;
|
||||||
|
|
||||||
|
editableElement.addEventListener('beforeinput', handleBeforeInput, { capture: true });
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
editableElement.removeEventListener('beforeinput', handleBeforeInput, { capture: true });
|
||||||
|
};
|
||||||
|
}, [handleBeforeInput]);
|
||||||
|
|
||||||
|
const renderPlaceholder = useCallback(
|
||||||
|
({ attributes, children }: RenderPlaceholderProps) => (
|
||||||
|
<span {...attributes} className={css.EditorPlaceholderContainer}>
|
||||||
|
{/* Inner component to style the actual text position and appearance */}
|
||||||
|
<Text as="span" className={css.EditorPlaceholderTextVisual} truncate>
|
||||||
|
{children}
|
||||||
|
</Text>
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={css.Editor} ref={ref}>
|
||||||
|
<Slate editor={editor} initialValue={initialValue} onChange={onChange}>
|
||||||
|
{top}
|
||||||
|
<Box alignItems="Start">
|
||||||
|
{before && (
|
||||||
|
<Box className={css.EditorOptions} alignItems="Center" gap="100" shrink="No">
|
||||||
|
{before}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
<Scroll
|
||||||
|
className={css.EditorTextareaScroll}
|
||||||
|
variant="SurfaceVariant"
|
||||||
|
style={{ maxHeight }}
|
||||||
|
size="300"
|
||||||
|
visibility="Hover"
|
||||||
|
hideTrack
|
||||||
|
ref={editableRef}
|
||||||
|
>
|
||||||
|
<Editable
|
||||||
|
data-editable-name={editableName}
|
||||||
|
className={css.EditorTextarea}
|
||||||
|
placeholder={placeholder}
|
||||||
|
renderPlaceholder={renderPlaceholder}
|
||||||
|
renderElement={renderElement}
|
||||||
|
renderLeaf={renderLeaf}
|
||||||
|
decorate={decorate}
|
||||||
|
onKeyDown={handleKeydown}
|
||||||
|
onKeyUp={onKeyUp}
|
||||||
|
onPaste={onPaste}
|
||||||
|
/>
|
||||||
|
</Scroll>
|
||||||
|
{after && (
|
||||||
|
<Box className={css.EditorOptions} alignItems="Center" gap="100" shrink="No">
|
||||||
|
{after}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
{bottom}
|
||||||
|
</Slate>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
316
overlay/src/app/features/ShareRoomPicker.tsx
Normal file
@@ -0,0 +1,316 @@
|
|||||||
|
import { useAtomValue } from 'jotai';
|
||||||
|
import React, { ChangeEventHandler, useCallback, useMemo, useRef, useState } from 'react';
|
||||||
|
import { useVirtualizer } from '@tanstack/react-virtual';
|
||||||
|
import FocusTrap from 'focus-trap-react';
|
||||||
|
import {
|
||||||
|
Avatar,
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
Header,
|
||||||
|
Icon,
|
||||||
|
IconButton,
|
||||||
|
Icons,
|
||||||
|
Input,
|
||||||
|
MenuItem,
|
||||||
|
Modal,
|
||||||
|
Overlay,
|
||||||
|
OverlayBackdrop,
|
||||||
|
OverlayCenter,
|
||||||
|
Scroll,
|
||||||
|
Text,
|
||||||
|
config,
|
||||||
|
} from 'folds';
|
||||||
|
import { useMatrixClient } from '../hooks/useMatrixClient';
|
||||||
|
import { useAllJoinedRoomsSet, useGetRoom } from '../hooks/useGetRoom';
|
||||||
|
import { allRoomsAtom } from '../state/room-list/roomList';
|
||||||
|
import { mDirectAtom } from '../state/mDirectList';
|
||||||
|
import { useDirects, useRooms } from '../state/hooks/roomList';
|
||||||
|
import { getDirectRoomAvatarUrl, getRoomAvatarUrl } from '../utils/room';
|
||||||
|
import { RoomAvatar, RoomIcon } from '../components/room-avatar';
|
||||||
|
import { nameInitials } from '../utils/common';
|
||||||
|
import { useMediaAuthentication } from '../hooks/useMediaAuthentication';
|
||||||
|
import { factoryRoomIdByActivity } from '../utils/sort';
|
||||||
|
import {
|
||||||
|
SearchItemStrGetter,
|
||||||
|
useAsyncSearch,
|
||||||
|
UseAsyncSearchOptions,
|
||||||
|
} from '../hooks/useAsyncSearch';
|
||||||
|
import { VirtualTile } from '../components/virtualizer';
|
||||||
|
import { AndroidSharePayload } from '../utils/androidShare';
|
||||||
|
import { stopPropagation } from '../utils/keyboard';
|
||||||
|
|
||||||
|
const SEARCH_OPTS: UseAsyncSearchOptions = {
|
||||||
|
limit: 200,
|
||||||
|
matchOptions: {
|
||||||
|
contain: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
type ShareRoomPickerProps = {
|
||||||
|
/** The pending share payload to display a preview of. */
|
||||||
|
share: AndroidSharePayload;
|
||||||
|
/** Called when the user picks a room. */
|
||||||
|
onPick: (roomId: string) => void;
|
||||||
|
/** Called when the user dismisses without picking. */
|
||||||
|
onDismiss: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Full-screen modal that lets the user pick a room to send
|
||||||
|
* an incoming Android share intent into.
|
||||||
|
*/
|
||||||
|
export function ShareRoomPicker({ share, onPick, onDismiss }: ShareRoomPickerProps) {
|
||||||
|
const mx = useMatrixClient();
|
||||||
|
const useAuthentication = useMediaAuthentication();
|
||||||
|
const scrollRef = useRef<HTMLDivElement>(null);
|
||||||
|
const [confirmRoomId, setConfirmRoomId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const mDirects = useAtomValue(mDirectAtom);
|
||||||
|
const allRoomsSet = useAllJoinedRoomsSet();
|
||||||
|
const getRoom = useGetRoom(allRoomsSet);
|
||||||
|
const rooms = useRooms(mx, allRoomsAtom, mDirects);
|
||||||
|
const directs = useDirects(mx, allRoomsAtom, mDirects);
|
||||||
|
|
||||||
|
const allItems = useMemo(
|
||||||
|
() => [...rooms, ...directs].sort(factoryRoomIdByActivity(mx)),
|
||||||
|
[mx, rooms, directs]
|
||||||
|
);
|
||||||
|
|
||||||
|
const getRoomNameStr: SearchItemStrGetter<string> = useCallback(
|
||||||
|
(rId) => getRoom(rId)?.name ?? rId,
|
||||||
|
[getRoom]
|
||||||
|
);
|
||||||
|
|
||||||
|
const [searchResult, searchRoom, resetSearch] = useAsyncSearch(
|
||||||
|
allItems,
|
||||||
|
getRoomNameStr,
|
||||||
|
SEARCH_OPTS
|
||||||
|
);
|
||||||
|
|
||||||
|
const items = searchResult ? searchResult.items : allItems;
|
||||||
|
|
||||||
|
const virtualizer = useVirtualizer({
|
||||||
|
count: items.length,
|
||||||
|
getScrollElement: () => scrollRef.current,
|
||||||
|
estimateSize: () => 48,
|
||||||
|
overscan: 8,
|
||||||
|
});
|
||||||
|
const vItems = virtualizer.getVirtualItems();
|
||||||
|
|
||||||
|
const handleSearchChange: ChangeEventHandler<HTMLInputElement> = (evt) => {
|
||||||
|
const value = evt.currentTarget.value.trim();
|
||||||
|
if (!value) {
|
||||||
|
resetSearch();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
searchRoom(value);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRoomClick: React.MouseEventHandler<HTMLButtonElement> = (evt) => {
|
||||||
|
const roomId = evt.currentTarget.getAttribute('data-room-id');
|
||||||
|
if (roomId) {
|
||||||
|
setConfirmRoomId(roomId);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleConfirm = useCallback(() => {
|
||||||
|
if (confirmRoomId) {
|
||||||
|
onPick(confirmRoomId);
|
||||||
|
}
|
||||||
|
}, [confirmRoomId, onPick]);
|
||||||
|
|
||||||
|
const handleCancelConfirm = useCallback(() => {
|
||||||
|
setConfirmRoomId(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const previewText = share.text?.slice(0, 80) ?? share.subject?.slice(0, 80);
|
||||||
|
const fileCount = share.files.length;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Overlay open>
|
||||||
|
<FocusTrap
|
||||||
|
focusTrapOptions={{
|
||||||
|
initialFocus: false,
|
||||||
|
clickOutsideDeactivates: true,
|
||||||
|
onDeactivate: onDismiss,
|
||||||
|
escapeDeactivates: stopPropagation,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box
|
||||||
|
grow="Yes"
|
||||||
|
direction="Column"
|
||||||
|
style={{
|
||||||
|
position: 'fixed',
|
||||||
|
inset: 0,
|
||||||
|
zIndex: 999,
|
||||||
|
backgroundColor: '#1a1d23',
|
||||||
|
paddingTop: '60px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Header
|
||||||
|
size="500"
|
||||||
|
style={{ padding: config.space.S400 }}
|
||||||
|
>
|
||||||
|
<Box grow="Yes" direction="Column" gap="100" style={{ minWidth: 0, maxWidth: '80%' }}>
|
||||||
|
<Text size="H4">Share to Room</Text>
|
||||||
|
{(previewText || fileCount > 0) && (
|
||||||
|
<Text size="T200" priority="300" style={{ opacity: 0.7, wordBreak: 'break-word', whiteSpace: 'normal' }}>
|
||||||
|
{previewText
|
||||||
|
? `"${previewText}${(share.text?.length ?? 0) > 80 ? '…' : ''}"`
|
||||||
|
: `${fileCount} file${fileCount !== 1 ? 's' : ''}`}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
<Box shrink="No">
|
||||||
|
<IconButton size="300" radii="300" onClick={onDismiss}>
|
||||||
|
<Icon src={Icons.Cross} />
|
||||||
|
</IconButton>
|
||||||
|
</Box>
|
||||||
|
</Header>
|
||||||
|
|
||||||
|
<Box
|
||||||
|
style={{
|
||||||
|
padding: config.space.S200,
|
||||||
|
paddingTop: config.space.S300,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
onChange={handleSearchChange}
|
||||||
|
before={<Icon size="200" src={Icons.Search} />}
|
||||||
|
placeholder="Search rooms…"
|
||||||
|
size="400"
|
||||||
|
variant="Background"
|
||||||
|
outlined
|
||||||
|
autoFocus
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Scroll ref={scrollRef} size="300" hideTrack style={{ flex: 1, minHeight: 0 }}>
|
||||||
|
<Box style={{ padding: config.space.S300 }} direction="Column">
|
||||||
|
{vItems.length === 0 && (
|
||||||
|
<Box
|
||||||
|
style={{ padding: `${config.space.S700} 0` }}
|
||||||
|
alignItems="Center"
|
||||||
|
justifyContent="Center"
|
||||||
|
direction="Column"
|
||||||
|
gap="100"
|
||||||
|
>
|
||||||
|
<Text size="H6" align="Center">
|
||||||
|
{searchResult ? 'No Match Found' : 'No Rooms'}
|
||||||
|
</Text>
|
||||||
|
<Text size="T200" align="Center" priority="300">
|
||||||
|
{searchResult
|
||||||
|
? `No rooms found for "${searchResult.query}".`
|
||||||
|
: 'You have no rooms to share into.'}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
<Box style={{ position: 'relative', height: virtualizer.getTotalSize() }}>
|
||||||
|
{vItems.map((vItem) => {
|
||||||
|
const roomId = items[vItem.index];
|
||||||
|
const room = getRoom(roomId);
|
||||||
|
if (!room) return null;
|
||||||
|
const isDm = mDirects.has(roomId);
|
||||||
|
|
||||||
|
const avatarSrc = isDm
|
||||||
|
? getDirectRoomAvatarUrl(mx, room, 96, useAuthentication)
|
||||||
|
: getRoomAvatarUrl(mx, room, 96, useAuthentication);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<VirtualTile
|
||||||
|
virtualItem={vItem}
|
||||||
|
style={{ paddingBottom: config.space.S100 }}
|
||||||
|
ref={virtualizer.measureElement}
|
||||||
|
key={roomId}
|
||||||
|
>
|
||||||
|
<MenuItem
|
||||||
|
data-room-id={roomId}
|
||||||
|
onClick={handleRoomClick}
|
||||||
|
variant="Surface"
|
||||||
|
size="400"
|
||||||
|
radii="400"
|
||||||
|
before={
|
||||||
|
<Avatar size="300" radii={isDm ? '400' : '300'}>
|
||||||
|
{avatarSrc ? (
|
||||||
|
<RoomAvatar
|
||||||
|
roomId={roomId}
|
||||||
|
src={avatarSrc}
|
||||||
|
alt={room.name}
|
||||||
|
renderFallback={() => (
|
||||||
|
<Text as="span" size="H6">
|
||||||
|
{nameInitials(room.name)}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<RoomIcon room={room} size="200" filled />
|
||||||
|
)}
|
||||||
|
</Avatar>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Box grow="Yes" direction="Column">
|
||||||
|
<Text size="B400" truncate>
|
||||||
|
{room.name}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
</MenuItem>
|
||||||
|
</VirtualTile>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</Scroll>
|
||||||
|
</Box>
|
||||||
|
</FocusTrap>
|
||||||
|
</Overlay>
|
||||||
|
|
||||||
|
{confirmRoomId && (() => {
|
||||||
|
const room = getRoom(confirmRoomId);
|
||||||
|
const roomName = room?.name ?? confirmRoomId;
|
||||||
|
const isDm = mDirects.has(confirmRoomId);
|
||||||
|
return (
|
||||||
|
<Overlay open backdrop={<OverlayBackdrop />}>
|
||||||
|
<OverlayCenter>
|
||||||
|
<FocusTrap
|
||||||
|
focusTrapOptions={{
|
||||||
|
initialFocus: false,
|
||||||
|
onDeactivate: handleCancelConfirm,
|
||||||
|
escapeDeactivates: stopPropagation,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Modal style={{ maxWidth: '280px' }}>
|
||||||
|
<Box direction="Column" gap="100" style={{ padding: config.space.S300 }}>
|
||||||
|
<Box direction="Column" gap="100">
|
||||||
|
<Text size="H4">Share to {isDm ? 'Person' : 'Room'}?</Text>
|
||||||
|
<Text size="T300" priority="300">
|
||||||
|
Send to <strong>{roomName}</strong>?
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
<Box gap="200" justifyContent="End">
|
||||||
|
<Button
|
||||||
|
onClick={handleCancelConfirm}
|
||||||
|
variant="Secondary"
|
||||||
|
size="400"
|
||||||
|
>
|
||||||
|
<Text size="B400">Cancel</Text>
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={handleConfirm}
|
||||||
|
variant="Primary"
|
||||||
|
size="400"
|
||||||
|
>
|
||||||
|
<Text size="B400">Share</Text>
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</Modal>
|
||||||
|
</FocusTrap>
|
||||||
|
</OverlayCenter>
|
||||||
|
</Overlay>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { useCallback } from 'react';
|
import React, { useCallback, useEffect, useState } from 'react';
|
||||||
import { Box, Text, Switch, Button, color, Spinner } from 'folds';
|
import { Box, Text, Switch, Button, color, Spinner } from 'folds';
|
||||||
import { IPusherRequest } from 'matrix-js-sdk';
|
import { IPusherRequest } from 'matrix-js-sdk';
|
||||||
import { SequenceCard } from '../../../components/sequence-card';
|
import { SequenceCard } from '../../../components/sequence-card';
|
||||||
@@ -11,6 +11,12 @@ import { useEmailNotifications } from '../../../hooks/useEmailNotifications';
|
|||||||
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
|
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
|
||||||
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
||||||
import { isCapacitorNative, requestSystemNotificationPermission } from '../../../utils/tauri';
|
import { isCapacitorNative, requestSystemNotificationPermission } from '../../../utils/tauri';
|
||||||
|
import {
|
||||||
|
isBackgroundSyncSupported,
|
||||||
|
getBackgroundSyncStatus,
|
||||||
|
requestResetPushRegistration,
|
||||||
|
triggerBackgroundSyncPing,
|
||||||
|
} from '../../../utils/backgroundSync';
|
||||||
|
|
||||||
function EmailNotification() {
|
function EmailNotification() {
|
||||||
const mx = useMatrixClient();
|
const mx = useMatrixClient();
|
||||||
@@ -147,6 +153,7 @@ export function SystemNotification() {
|
|||||||
after={<Switch value={isNotificationSounds} onChange={setIsNotificationSounds} />}
|
after={<Switch value={isNotificationSounds} onChange={setIsNotificationSounds} />}
|
||||||
/>
|
/>
|
||||||
</SequenceCard>
|
</SequenceCard>
|
||||||
|
{isBackgroundSyncSupported() && <AndroidPushNotifications />}
|
||||||
<SequenceCard
|
<SequenceCard
|
||||||
className={SequenceCardStyle}
|
className={SequenceCardStyle}
|
||||||
variant="SurfaceVariant"
|
variant="SurfaceVariant"
|
||||||
@@ -158,3 +165,126 @@ export function SystemNotification() {
|
|||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type PushStatus = {
|
||||||
|
registered: boolean;
|
||||||
|
distributor: string;
|
||||||
|
endpoint: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Android-only section showing UnifiedPush registration status and controls. */
|
||||||
|
function AndroidPushNotifications() {
|
||||||
|
const [status, setStatus] = useState<PushStatus | undefined>(undefined);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
const refresh = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
const s = await getBackgroundSyncStatus();
|
||||||
|
setStatus(
|
||||||
|
s
|
||||||
|
? {
|
||||||
|
registered: s.registered,
|
||||||
|
distributor: s.distributor || '',
|
||||||
|
endpoint: s.endpoint || '',
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
);
|
||||||
|
setLoading(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void refresh();
|
||||||
|
}, [refresh]);
|
||||||
|
|
||||||
|
const [resetState, reset] = useAsyncCallback(
|
||||||
|
useCallback(async () => {
|
||||||
|
await requestResetPushRegistration();
|
||||||
|
await refresh();
|
||||||
|
}, [refresh])
|
||||||
|
);
|
||||||
|
|
||||||
|
const [pingState, ping] = useAsyncCallback(
|
||||||
|
useCallback(async () => {
|
||||||
|
await triggerBackgroundSyncPing('manual-test');
|
||||||
|
}, [])
|
||||||
|
);
|
||||||
|
|
||||||
|
const isBusy =
|
||||||
|
loading ||
|
||||||
|
resetState.status === AsyncStatus.Loading ||
|
||||||
|
pingState.status === AsyncStatus.Loading;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box direction="Column" gap="100">
|
||||||
|
<Text size="L400">Android Push (UnifiedPush)</Text>
|
||||||
|
<SequenceCard
|
||||||
|
className={SequenceCardStyle}
|
||||||
|
variant="SurfaceVariant"
|
||||||
|
direction="Column"
|
||||||
|
gap="400"
|
||||||
|
>
|
||||||
|
<SettingTile
|
||||||
|
title="Background Notifications"
|
||||||
|
description={
|
||||||
|
loading ? (
|
||||||
|
'Loading status…'
|
||||||
|
) : status === undefined ? (
|
||||||
|
<Text as="span" style={{ color: color.Critical.Main }} size="T200">
|
||||||
|
Failed to read push status.
|
||||||
|
</Text>
|
||||||
|
) : status.registered ? (
|
||||||
|
<>
|
||||||
|
<Text as="span" size="T200">
|
||||||
|
{`Distributor: ${status.distributor || 'unknown'}`}
|
||||||
|
</Text>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<Text as="span" style={{ color: color.Warning?.Main ?? color.Critical.Main }} size="T200">
|
||||||
|
Not registered. Tap "Reset" to choose a distributor app.
|
||||||
|
</Text>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
after={loading ? <Spinner variant="Secondary" /> : undefined}
|
||||||
|
/>
|
||||||
|
<SettingTile
|
||||||
|
title="Change Distributor"
|
||||||
|
description="Re-open the UnifiedPush distributor selection dialog."
|
||||||
|
after={
|
||||||
|
<Button
|
||||||
|
size="300"
|
||||||
|
radii="300"
|
||||||
|
variant="Secondary"
|
||||||
|
disabled={isBusy}
|
||||||
|
onClick={() => void reset()}
|
||||||
|
>
|
||||||
|
{resetState.status === AsyncStatus.Loading ? (
|
||||||
|
<Spinner variant="Secondary" size="200" />
|
||||||
|
) : (
|
||||||
|
<Text size="B300">Reset</Text>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<SettingTile
|
||||||
|
title="Test Notification"
|
||||||
|
description="Trigger a test push ping to verify the pipeline works."
|
||||||
|
after={
|
||||||
|
<Button
|
||||||
|
size="300"
|
||||||
|
radii="300"
|
||||||
|
variant="Secondary"
|
||||||
|
disabled={isBusy}
|
||||||
|
onClick={() => void ping()}
|
||||||
|
>
|
||||||
|
{pingState.status === AsyncStatus.Loading ? (
|
||||||
|
<Spinner variant="Secondary" size="200" />
|
||||||
|
) : (
|
||||||
|
<Text size="B300">Send Test</Text>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</SequenceCard>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
149
overlay/src/app/features/settings/plugins/PluginAPI.ts
Normal file
@@ -0,0 +1,149 @@
|
|||||||
|
/**
|
||||||
|
* Re-exports the full @paarrot/plugin-manager API and creates the shared
|
||||||
|
* singleton PluginRegistry instance for this application.
|
||||||
|
*
|
||||||
|
* All other modules in this folder should import from here rather than
|
||||||
|
* directly from the package so the singleton is guaranteed to be shared.
|
||||||
|
*/
|
||||||
|
export {
|
||||||
|
PluginMarketplaceClient,
|
||||||
|
PluginMarketplaceManager,
|
||||||
|
PluginRegistry,
|
||||||
|
createPluginContext,
|
||||||
|
generateThemeCSS,
|
||||||
|
MemoryStorage,
|
||||||
|
} from '@paarrot/plugin-manager';
|
||||||
|
|
||||||
|
export type {
|
||||||
|
IPluginStorage,
|
||||||
|
IPluginEventClient,
|
||||||
|
PluginMarketplaceClientOptions,
|
||||||
|
PluginMarketplaceHostAdapter,
|
||||||
|
PluginMarketplaceInstalledRecord,
|
||||||
|
PluginMarketplaceManagerOptions,
|
||||||
|
PluginRegistryOptions,
|
||||||
|
PluginContextOptions,
|
||||||
|
PluginMetadata,
|
||||||
|
InstalledPlugin,
|
||||||
|
PluginIndex,
|
||||||
|
ThemeColorGroup,
|
||||||
|
ThemePaletteGroup,
|
||||||
|
PluginThemeColors,
|
||||||
|
PluginTheme,
|
||||||
|
CommandArg,
|
||||||
|
PluginCommand,
|
||||||
|
MessageInterceptor,
|
||||||
|
MessageContext,
|
||||||
|
CustomRenderer,
|
||||||
|
SettingDefinition,
|
||||||
|
SettingsSchema,
|
||||||
|
NotificationOptions,
|
||||||
|
PluginContext,
|
||||||
|
PluginSettingsSection,
|
||||||
|
UILocation,
|
||||||
|
UIButtonDefinition,
|
||||||
|
UIButtonPosition,
|
||||||
|
Plugin,
|
||||||
|
PluginLogEntry,
|
||||||
|
} from '@paarrot/plugin-manager';
|
||||||
|
|
||||||
|
export { PluginTab } from '@paarrot/plugin-manager';
|
||||||
|
|
||||||
|
import {
|
||||||
|
PluginMarketplaceClient,
|
||||||
|
PluginMarketplaceManager,
|
||||||
|
PluginRegistry,
|
||||||
|
} from '@paarrot/plugin-manager';
|
||||||
|
import { getPluginHost } from '../../../utils/pluginHost';
|
||||||
|
|
||||||
|
const PLUGIN_INDEX_URL =
|
||||||
|
import.meta.env.VITE_PLUGIN_INDEX_URL ||
|
||||||
|
`https://raw.githubusercontent.com/Paarrot/Plugin-Directory/refs/heads/main/plugins/index.json?cache_burst=${(Math.random()*256).toString()}`;
|
||||||
|
const PLUGIN_BASE_URL =
|
||||||
|
import.meta.env.VITE_PLUGIN_BASE_URL ||
|
||||||
|
'https://raw.githubusercontent.com/Paarrot/Plugin-Directory/refs/heads/main/plugins/';
|
||||||
|
|
||||||
|
function injectThemeStyle(styleId: string, css: string): void {
|
||||||
|
let el = document.getElementById(styleId) as HTMLStyleElement | null;
|
||||||
|
if (!el) {
|
||||||
|
el = document.createElement('style');
|
||||||
|
el.id = styleId;
|
||||||
|
document.head.appendChild(el);
|
||||||
|
}
|
||||||
|
el.textContent = css;
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeThemeStyle(styleId: string): void {
|
||||||
|
document.getElementById(styleId)?.remove();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Shared singleton registry for the entire application. */
|
||||||
|
export const pluginRegistry = new PluginRegistry({
|
||||||
|
storage: localStorage,
|
||||||
|
onThemeRegistered: (themeId, _className, css) =>
|
||||||
|
injectThemeStyle(`plugin-theme-${themeId}`, css),
|
||||||
|
onThemeUnregistered: (themeId) => removeThemeStyle(`plugin-theme-${themeId}`),
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Shared marketplace client for remote plugin directory reads. */
|
||||||
|
export const pluginMarketplaceClient = new PluginMarketplaceClient({
|
||||||
|
indexUrl: PLUGIN_INDEX_URL,
|
||||||
|
baseUrl: PLUGIN_BASE_URL,
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shared marketplace manager for install state, directory fetch, and enable flags.
|
||||||
|
*/
|
||||||
|
export const pluginMarketplaceManager = new PluginMarketplaceManager({
|
||||||
|
client: pluginMarketplaceClient,
|
||||||
|
storage: localStorage,
|
||||||
|
registry: pluginRegistry,
|
||||||
|
host: {
|
||||||
|
listInstalledPlugins: async () => {
|
||||||
|
const host = getPluginHost();
|
||||||
|
if (!host) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await host.list();
|
||||||
|
if (!result.success || !result.data) {
|
||||||
|
throw new Error(result.error ?? 'Failed to list installed plugins');
|
||||||
|
}
|
||||||
|
|
||||||
|
return result.data.map((plugin) => ({
|
||||||
|
id: plugin.id,
|
||||||
|
name: plugin.name,
|
||||||
|
version: plugin.version,
|
||||||
|
description: plugin.description,
|
||||||
|
author: plugin.author,
|
||||||
|
repository: plugin.repository,
|
||||||
|
thumbnail: plugin.thumbnail,
|
||||||
|
homepage: plugin.homepage,
|
||||||
|
tags: plugin.tags,
|
||||||
|
installedDate: plugin.installedDate,
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
installPlugin: async (plugin) => {
|
||||||
|
const host = getPluginHost();
|
||||||
|
if (!host) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await host.download(plugin.id, plugin.downloadUrl, plugin.name);
|
||||||
|
if (!result.success) {
|
||||||
|
throw new Error(result.error ?? 'Failed to install plugin');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
uninstallPlugin: async (pluginId) => {
|
||||||
|
const host = getPluginHost();
|
||||||
|
if (!host) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await host.uninstall(pluginId);
|
||||||
|
if (!result.success) {
|
||||||
|
throw new Error(result.error ?? 'Failed to uninstall plugin');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
190
overlay/src/app/features/settings/plugins/PluginLoader.tsx
Normal file
@@ -0,0 +1,190 @@
|
|||||||
|
import React, { useEffect } from 'react';
|
||||||
|
import { MatrixClient } from 'matrix-js-sdk';
|
||||||
|
import { Plugin, createPluginContext } from '@paarrot/plugin-manager';
|
||||||
|
import { pluginMarketplaceManager, pluginRegistry } from './PluginAPI';
|
||||||
|
import { sendNotification } from '../../../utils/tauri';
|
||||||
|
import { getPluginHost } from '../../../utils/pluginHost';
|
||||||
|
import { dispatchPluginButtonsChanged } from './PluginButtonSlot';
|
||||||
|
|
||||||
|
interface PluginLoaderProps {
|
||||||
|
matrixClient: MatrixClient;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Component that loads and initializes enabled plugins.
|
||||||
|
* Should be placed high in the component tree after MatrixClient is available.
|
||||||
|
*/
|
||||||
|
export function PluginLoader({ matrixClient, children }: PluginLoaderProps) {
|
||||||
|
console.log('[PluginLoader] Component rendering, matrixClient:', !!matrixClient);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const loadPlugins = async () => {
|
||||||
|
try {
|
||||||
|
const pluginHost = getPluginHost();
|
||||||
|
console.log('[PluginLoader] Loading plugins...');
|
||||||
|
console.log('[PluginLoader] plugin host available:', !!pluginHost);
|
||||||
|
|
||||||
|
if (!pluginHost) {
|
||||||
|
console.log('[PluginLoader] Running in web mode, plugins not supported');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const pluginsToLoad = await pluginMarketplaceManager.listEnabledPlugins();
|
||||||
|
|
||||||
|
console.log('[PluginLoader] Plugins to load:', pluginsToLoad.length);
|
||||||
|
console.log('[PluginLoader] Plugin list:', pluginsToLoad.map((p) => p.id));
|
||||||
|
|
||||||
|
for (const installedPlugin of pluginsToLoad) {
|
||||||
|
try {
|
||||||
|
console.log(`[PluginLoader] Loading plugin: ${installedPlugin.id}`);
|
||||||
|
|
||||||
|
const pluginModule = await loadPluginModule(installedPlugin.id);
|
||||||
|
const plugin = normalisePlugin(pluginModule);
|
||||||
|
|
||||||
|
if (!plugin || typeof plugin !== 'object') {
|
||||||
|
console.error(
|
||||||
|
`[PluginLoader] Plugin ${installedPlugin.id} does not export a valid plugin object`
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof plugin.onLoad !== 'function') {
|
||||||
|
console.error(
|
||||||
|
`[PluginLoader] Plugin ${installedPlugin.id} does not have an onLoad function`
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const context = createPluginContext(
|
||||||
|
{
|
||||||
|
pluginId: installedPlugin.id,
|
||||||
|
eventClient: matrixClient as any,
|
||||||
|
onNotify: async (opts) => {
|
||||||
|
await sendNotification({
|
||||||
|
title: opts.title,
|
||||||
|
body: opts.body,
|
||||||
|
path: undefined,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
pluginRegistry
|
||||||
|
);
|
||||||
|
|
||||||
|
const compatContext = Object.assign(context as Record<string, unknown>, {
|
||||||
|
matrixClient,
|
||||||
|
matrix: {
|
||||||
|
on: (eventType: string, handler: (...args: unknown[]) => void) =>
|
||||||
|
matrixClient.on(eventType as any, handler as any),
|
||||||
|
off: (eventType: string, handler: (...args: unknown[]) => void) =>
|
||||||
|
matrixClient.off(eventType as any, handler as any),
|
||||||
|
},
|
||||||
|
React,
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('[PluginLoader] compatContext.React:', !!compatContext.React);
|
||||||
|
console.log('[PluginLoader] compatContext.ui:', !!compatContext.ui);
|
||||||
|
console.log('[PluginLoader] About to register plugin and call onLoad...');
|
||||||
|
|
||||||
|
pluginRegistry.registerPlugin(installedPlugin.id, plugin, context);
|
||||||
|
await plugin.onLoad(compatContext as any);
|
||||||
|
console.log(`[PluginLoader] Successfully loaded plugin: ${installedPlugin.id}`);
|
||||||
|
dispatchPluginButtonsChanged();
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`[PluginLoader] Failed to load plugin ${installedPlugin.id}:`, error);
|
||||||
|
pluginRegistry.addLog(installedPlugin.id, 'error', ['Failed to load:', error]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[PluginLoader] Error loading plugins:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
loadPlugins();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
console.log('[PluginLoader] Cleaning up plugins');
|
||||||
|
pluginRegistry.clear();
|
||||||
|
dispatchPluginButtonsChanged();
|
||||||
|
};
|
||||||
|
}, [matrixClient]);
|
||||||
|
|
||||||
|
return <>{children}</>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reads and evaluates a plugin's JS module from the native filesystem host.
|
||||||
|
* Supports both CommonJS and ESM-style exports.
|
||||||
|
*/
|
||||||
|
async function loadPluginModule(pluginId: string): Promise<any> {
|
||||||
|
const pluginHost = getPluginHost();
|
||||||
|
if (!pluginHost) {
|
||||||
|
throw new Error('Plugins are only supported in the desktop or Android app');
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await pluginHost.readPluginCode(pluginId);
|
||||||
|
if (!response.success || !response.data) {
|
||||||
|
throw new Error(response.error ?? 'Failed to read plugin code');
|
||||||
|
}
|
||||||
|
|
||||||
|
const pluginExports: any = {};
|
||||||
|
const mod = { exports: pluginExports };
|
||||||
|
|
||||||
|
try {
|
||||||
|
// eslint-disable-next-line no-new-func
|
||||||
|
const pluginFunction = new Function('module', 'exports', response.data);
|
||||||
|
pluginFunction(mod, pluginExports);
|
||||||
|
} catch (error) {
|
||||||
|
if (!(error instanceof SyntaxError)) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
const dataUrl = `data:text/javascript;charset=utf-8,${encodeURIComponent(response.data)}`;
|
||||||
|
const importedModule = await import(/* @vite-ignore */ dataUrl);
|
||||||
|
return importedModule;
|
||||||
|
}
|
||||||
|
|
||||||
|
return mod.exports;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalizes raw plugin exports to current plugin interface.
|
||||||
|
* Supports native `onLoad` and legacy `activate` / `deactivate` plugins.
|
||||||
|
*/
|
||||||
|
function normalisePlugin(raw: Record<string, unknown>): Plugin {
|
||||||
|
if (typeof raw.onLoad === 'function') {
|
||||||
|
return raw as unknown as Plugin;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof raw.activate === 'function') {
|
||||||
|
const activate = raw.activate as (ctx: unknown) => void | Promise<void>;
|
||||||
|
const deactivate = raw.deactivate as ((ctx?: unknown) => void | Promise<void>) | undefined;
|
||||||
|
|
||||||
|
return {
|
||||||
|
name: typeof raw.name === 'string' ? raw.name : undefined,
|
||||||
|
version: typeof raw.version === 'string' ? raw.version : undefined,
|
||||||
|
onLoad: async (ctx) => {
|
||||||
|
const legacyCtx = Object.assign(Object.create(ctx as object), {
|
||||||
|
registerHook: (name: string) =>
|
||||||
|
ctx.log(`[compat] registerHook("${name}") - not supported`),
|
||||||
|
runHook: (name: string) => {
|
||||||
|
ctx.log(`[compat] runHook("${name}")`);
|
||||||
|
return Promise.resolve([]);
|
||||||
|
},
|
||||||
|
getConfig: (key: string) => ctx.settings.get(key),
|
||||||
|
on: (event: string, handler: (...args: unknown[]) => void) =>
|
||||||
|
ctx.events.on(event, handler),
|
||||||
|
});
|
||||||
|
|
||||||
|
await activate(legacyCtx);
|
||||||
|
},
|
||||||
|
onUnload: deactivate
|
||||||
|
? async () => {
|
||||||
|
await deactivate();
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return raw as unknown as Plugin;
|
||||||
|
}
|
||||||
159
overlay/src/app/pages/auth/ServerPicker.tsx
Normal file
@@ -0,0 +1,159 @@
|
|||||||
|
import React, {
|
||||||
|
ChangeEventHandler,
|
||||||
|
FocusEventHandler,
|
||||||
|
KeyboardEventHandler,
|
||||||
|
MouseEventHandler,
|
||||||
|
useEffect,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
} from 'react';
|
||||||
|
import {
|
||||||
|
Header,
|
||||||
|
Icon,
|
||||||
|
IconButton,
|
||||||
|
Icons,
|
||||||
|
Input,
|
||||||
|
Menu,
|
||||||
|
MenuItem,
|
||||||
|
PopOut,
|
||||||
|
RectCords,
|
||||||
|
Text,
|
||||||
|
config,
|
||||||
|
} from 'folds';
|
||||||
|
import FocusTrap from 'focus-trap-react';
|
||||||
|
|
||||||
|
import { useDebounce } from '../../hooks/useDebounce';
|
||||||
|
import { stopPropagation } from '../../utils/keyboard';
|
||||||
|
|
||||||
|
export function ServerPicker({
|
||||||
|
server,
|
||||||
|
serverList,
|
||||||
|
allowCustomServer,
|
||||||
|
onServerChange,
|
||||||
|
}: {
|
||||||
|
server: string;
|
||||||
|
serverList: string[];
|
||||||
|
allowCustomServer?: boolean;
|
||||||
|
onServerChange: (server: string) => void;
|
||||||
|
}) {
|
||||||
|
const [serverMenuAnchor, setServerMenuAnchor] = useState<RectCords>();
|
||||||
|
const serverInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Only sync input when server changes externally (e.g., from menu selection)
|
||||||
|
// and input is not focused to avoid cursor jumping during typing
|
||||||
|
if (
|
||||||
|
serverInputRef.current &&
|
||||||
|
serverInputRef.current.value !== server &&
|
||||||
|
document.activeElement !== serverInputRef.current
|
||||||
|
) {
|
||||||
|
serverInputRef.current.value = server;
|
||||||
|
}
|
||||||
|
}, [server]);
|
||||||
|
|
||||||
|
const debounceServerSelect = useDebounce(onServerChange, { wait: 700 });
|
||||||
|
|
||||||
|
const handleServerChange: ChangeEventHandler<HTMLInputElement> = (evt) => {
|
||||||
|
const inputServer = evt.target.value.trim();
|
||||||
|
if (inputServer) debounceServerSelect(inputServer);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleBlur: FocusEventHandler<HTMLInputElement> = (evt) => {
|
||||||
|
const inputServer = evt.target.value.trim();
|
||||||
|
if (inputServer && inputServer !== server) {
|
||||||
|
onServerChange(inputServer);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleKeyDown: KeyboardEventHandler<HTMLInputElement> = (evt) => {
|
||||||
|
if (evt.key === 'ArrowDown') {
|
||||||
|
evt.preventDefault();
|
||||||
|
setServerMenuAnchor(undefined);
|
||||||
|
}
|
||||||
|
if (evt.key === 'Enter') {
|
||||||
|
evt.preventDefault();
|
||||||
|
const inputServer = evt.currentTarget.value.trim();
|
||||||
|
if (inputServer) onServerChange(inputServer);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleServerSelect: MouseEventHandler<HTMLButtonElement> = (evt) => {
|
||||||
|
const selectedServer = evt.currentTarget.getAttribute('data-server');
|
||||||
|
if (selectedServer) {
|
||||||
|
onServerChange(selectedServer);
|
||||||
|
}
|
||||||
|
setServerMenuAnchor(undefined);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleOpenServerMenu: MouseEventHandler<HTMLElement> = (evt) => {
|
||||||
|
const target = evt.currentTarget.parentElement ?? evt.currentTarget;
|
||||||
|
setServerMenuAnchor(target.getBoundingClientRect());
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Input
|
||||||
|
ref={serverInputRef}
|
||||||
|
style={{ paddingRight: config.space.S200 }}
|
||||||
|
variant={allowCustomServer ? 'Background' : 'Surface'}
|
||||||
|
outlined
|
||||||
|
defaultValue={server}
|
||||||
|
onChange={handleServerChange}
|
||||||
|
onBlur={handleBlur}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
size="500"
|
||||||
|
readOnly={!allowCustomServer}
|
||||||
|
onClick={allowCustomServer ? undefined : handleOpenServerMenu}
|
||||||
|
after={
|
||||||
|
serverList.length === 0 || (serverList.length === 1 && !allowCustomServer) ? undefined : (
|
||||||
|
<PopOut
|
||||||
|
anchor={serverMenuAnchor}
|
||||||
|
position="Bottom"
|
||||||
|
align="End"
|
||||||
|
offset={4}
|
||||||
|
content={
|
||||||
|
<FocusTrap
|
||||||
|
focusTrapOptions={{
|
||||||
|
initialFocus: false,
|
||||||
|
onDeactivate: () => setServerMenuAnchor(undefined),
|
||||||
|
clickOutsideDeactivates: true,
|
||||||
|
isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown',
|
||||||
|
isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp',
|
||||||
|
escapeDeactivates: stopPropagation,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Menu>
|
||||||
|
<Header size="300" style={{ padding: `0 ${config.space.S200}` }}>
|
||||||
|
<Text size="L400">Homeserver List</Text>
|
||||||
|
</Header>
|
||||||
|
<div style={{ padding: config.space.S100, paddingTop: 0 }}>
|
||||||
|
{serverList?.map((serverName) => (
|
||||||
|
<MenuItem
|
||||||
|
key={serverName}
|
||||||
|
radii="300"
|
||||||
|
aria-pressed={serverName === server}
|
||||||
|
data-server={serverName}
|
||||||
|
onClick={handleServerSelect}
|
||||||
|
>
|
||||||
|
<Text>{serverName}</Text>
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Menu>
|
||||||
|
</FocusTrap>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<IconButton
|
||||||
|
onClick={handleOpenServerMenu}
|
||||||
|
variant={allowCustomServer ? 'Background' : 'Surface'}
|
||||||
|
size="300"
|
||||||
|
aria-pressed={!!serverMenuAnchor}
|
||||||
|
radii="300"
|
||||||
|
>
|
||||||
|
<Icon src={Icons.ChevronBottom} />
|
||||||
|
</IconButton>
|
||||||
|
</PopOut>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useAtomValue } from 'jotai';
|
import { useAtomValue } from 'jotai';
|
||||||
import React, { ReactNode, useCallback, useEffect, useRef } from 'react';
|
import { useAtom, useSetAtom } from 'jotai';
|
||||||
|
import React, { ReactNode, useCallback, useEffect, useRef, useState, Fragment } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { RoomEvent, RoomEventHandlerMap } from 'matrix-js-sdk';
|
import { RoomEvent, RoomEventHandlerMap } from 'matrix-js-sdk';
|
||||||
import { roomToUnreadAtom, unreadEqual, unreadInfoToUnread } from '../../state/room/roomToUnread';
|
import { roomToUnreadAtom, unreadEqual, unreadInfoToUnread } from '../../state/room/roomToUnread';
|
||||||
@@ -28,14 +29,37 @@ import { roomToParentsAtom } from '../../state/room/roomToParents';
|
|||||||
import { useSelectedRoom } from '../../hooks/router/useSelectedRoom';
|
import { useSelectedRoom } from '../../hooks/router/useSelectedRoom';
|
||||||
import { useInboxNotificationsSelected } from '../../hooks/router/useInbox';
|
import { useInboxNotificationsSelected } from '../../hooks/router/useInbox';
|
||||||
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
|
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
|
||||||
|
import { plainToEditorInput, toPlainText } from '../../components/editor';
|
||||||
|
import { ShareRoomPicker } from '../../features/ShareRoomPicker';
|
||||||
import {
|
import {
|
||||||
isTauri,
|
isTauri,
|
||||||
isElectron,
|
isElectron,
|
||||||
isCapacitorNative,
|
isCapacitorNative,
|
||||||
|
applyAndroidTypographyFix,
|
||||||
sendNotification,
|
sendNotification,
|
||||||
setupNotificationTapListener,
|
setupNotificationTapListener,
|
||||||
} from '../../utils/tauri';
|
} from '../../utils/tauri';
|
||||||
|
import '../../styles/android-typography.css';
|
||||||
import { setPaarrotNavigate, initPaarrotAPI } from '../../paarrot-api';
|
import { setPaarrotNavigate, initPaarrotAPI } from '../../paarrot-api';
|
||||||
|
import {
|
||||||
|
startBackgroundSync,
|
||||||
|
stopBackgroundSync,
|
||||||
|
setAppForegroundState,
|
||||||
|
} from '../../utils/backgroundSync';
|
||||||
|
import {
|
||||||
|
TUploadItem,
|
||||||
|
roomIdToMsgDraftAtomFamily,
|
||||||
|
roomIdToUploadItemsAtomFamily,
|
||||||
|
} from '../../state/room/roomInputDrafts';
|
||||||
|
import { encryptFile } from '../../utils/matrix';
|
||||||
|
import {
|
||||||
|
AndroidSharePayload,
|
||||||
|
clearPendingAndroidShare,
|
||||||
|
getPendingAndroidShare,
|
||||||
|
isAndroidShareSupported,
|
||||||
|
listenForAndroidShares,
|
||||||
|
materializeSharedFile,
|
||||||
|
} from '../../utils/androidShare';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Applies the selected emoji style font to the document.
|
* Applies the selected emoji style font to the document.
|
||||||
@@ -62,6 +86,14 @@ function EmojiStyleFeature() {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function AndroidTypographyFeature() {
|
||||||
|
useEffect(() => {
|
||||||
|
applyAndroidTypographyFix();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
function PageZoomFeature() {
|
function PageZoomFeature() {
|
||||||
const [pageZoom] = useSetting(settingsAtom, 'pageZoom');
|
const [pageZoom] = useSetting(settingsAtom, 'pageZoom');
|
||||||
|
|
||||||
@@ -413,6 +445,36 @@ function MessageNotifications() {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Configures native Android push-ping background sync on login, keeps it
|
||||||
|
* informed of foreground state so it doesn't double-fire notifications,
|
||||||
|
* and clears it cleanly on unmount (logout).
|
||||||
|
* Only active on Android Capacitor builds.
|
||||||
|
*/
|
||||||
|
function BackgroundSyncSetup() {
|
||||||
|
const mx = useMatrixClient();
|
||||||
|
// Try to make a simple fetch to verify component is rendering
|
||||||
|
try {
|
||||||
|
fetch('/_matrix/client/v3/sync', { method: 'HEAD' }).catch(() => {});
|
||||||
|
} catch {}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
console.log('BackgroundSyncSetup: Starting background sync for', mx.getUserId());
|
||||||
|
startBackgroundSync(mx);
|
||||||
|
|
||||||
|
const onVisibility = () => setAppForegroundState(!document.hidden);
|
||||||
|
document.addEventListener('visibilitychange', onVisibility);
|
||||||
|
setAppForegroundState(!document.hidden);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('visibilitychange', onVisibility);
|
||||||
|
stopBackgroundSync();
|
||||||
|
};
|
||||||
|
}, [mx]);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Initializes the Paarrot API for Electron integration
|
* Initializes the Paarrot API for Electron integration
|
||||||
* Registers the navigate function and sets up IPC handlers
|
* Registers the navigate function and sets up IPC handlers
|
||||||
@@ -463,6 +525,167 @@ function TaskbarFlashStopper() {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function AndroidShareIntentHandler() {
|
||||||
|
const mx = useMatrixClient();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [pendingShare, setPendingShare] = useState<AndroidSharePayload | null>(null);
|
||||||
|
const mDirects = useAtomValue(mDirectAtom);
|
||||||
|
const roomToParents = useAtomValue(roomToParentsAtom);
|
||||||
|
|
||||||
|
const applyPendingShare = useCallback(
|
||||||
|
async (share: AndroidSharePayload, roomId: string) => {
|
||||||
|
const room = mx.getRoom(roomId);
|
||||||
|
if (!room) return false;
|
||||||
|
|
||||||
|
// Send text message if present
|
||||||
|
const nextParts = [share.subject?.trim(), share.text?.trim()].filter(
|
||||||
|
(value): value is string => !!value
|
||||||
|
);
|
||||||
|
if (nextParts.length > 0) {
|
||||||
|
const textContent = {
|
||||||
|
msgtype: 'm.text' as const,
|
||||||
|
body: nextParts.join('\n'),
|
||||||
|
};
|
||||||
|
await mx.sendMessage(roomId, textContent);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Upload and send files if present
|
||||||
|
if (share.files.length > 0) {
|
||||||
|
for (const sharedFile of share.files) {
|
||||||
|
const originalFile = await materializeSharedFile(sharedFile, share.receivedAt);
|
||||||
|
|
||||||
|
let fileToUpload = originalFile;
|
||||||
|
let encInfo: any = undefined;
|
||||||
|
|
||||||
|
if (room.hasEncryptionStateEvent()) {
|
||||||
|
const encrypted = await encryptFile(originalFile);
|
||||||
|
fileToUpload = encrypted.file;
|
||||||
|
encInfo = encrypted.encInfo;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Upload file
|
||||||
|
const uploadResult = await mx.uploadContent(fileToUpload);
|
||||||
|
const mxc = uploadResult?.content_uri;
|
||||||
|
if (!mxc) continue;
|
||||||
|
|
||||||
|
// Determine message type and send
|
||||||
|
const fileType = originalFile.type;
|
||||||
|
let msgtype = 'm.file' as const;
|
||||||
|
if (fileType.startsWith('image/')) msgtype = 'm.image' as const;
|
||||||
|
else if (fileType.startsWith('video/')) msgtype = 'm.video' as const;
|
||||||
|
else if (fileType.startsWith('audio/')) msgtype = 'm.audio' as const;
|
||||||
|
|
||||||
|
const fileContent: any = {
|
||||||
|
msgtype,
|
||||||
|
body: originalFile.name,
|
||||||
|
filename: originalFile.name,
|
||||||
|
info: {
|
||||||
|
mimetype: originalFile.type,
|
||||||
|
size: originalFile.size,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
if (encInfo) {
|
||||||
|
fileContent.file = {
|
||||||
|
...encInfo,
|
||||||
|
url: mxc,
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
fileContent.url = mxc;
|
||||||
|
}
|
||||||
|
|
||||||
|
await mx.sendMessage(roomId, fileContent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await clearPendingAndroidShare();
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
[mx]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handlePickRoom = useCallback(
|
||||||
|
(roomId: string) => {
|
||||||
|
if (!pendingShare) return;
|
||||||
|
|
||||||
|
applyPendingShare(pendingShare, roomId)
|
||||||
|
.then((applied) => {
|
||||||
|
if (applied) {
|
||||||
|
setPendingShare(null);
|
||||||
|
|
||||||
|
// Navigate to the selected room
|
||||||
|
const isDirect = mDirects.has(roomId);
|
||||||
|
if (isDirect) {
|
||||||
|
navigate(getDirectRoomPath(roomId));
|
||||||
|
} else {
|
||||||
|
const parents = roomToParents.get(roomId);
|
||||||
|
const parent = parents && parents.length > 0 ? parents[0] : undefined;
|
||||||
|
if (parent) {
|
||||||
|
navigate(getSpaceRoomPath(parent, roomId));
|
||||||
|
} else {
|
||||||
|
navigate(getHomeRoomPath(roomId));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
console.error('[AndroidShare] Failed to apply share after pick:', err);
|
||||||
|
setPendingShare(null);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[applyPendingShare, pendingShare, navigate, mDirects, roomToParents]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleDismiss = useCallback(() => {
|
||||||
|
clearPendingAndroidShare().catch(() => {});
|
||||||
|
setPendingShare(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isAndroidShareSupported()) return;
|
||||||
|
|
||||||
|
let mounted = true;
|
||||||
|
let listenerHandle: { remove: () => Promise<void> } | undefined;
|
||||||
|
|
||||||
|
getPendingAndroidShare()
|
||||||
|
.then((share) => {
|
||||||
|
if (mounted && share) {
|
||||||
|
setPendingShare(share);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
console.error('[AndroidShare] Failed to get pending share:', err);
|
||||||
|
});
|
||||||
|
|
||||||
|
listenForAndroidShares((share) => {
|
||||||
|
if (mounted) {
|
||||||
|
setPendingShare(share);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.then((handle) => {
|
||||||
|
listenerHandle = handle;
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
console.error('[AndroidShare] Failed to listen for shares:', err);
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
mounted = false;
|
||||||
|
void listenerHandle?.remove();
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (!pendingShare) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ShareRoomPicker
|
||||||
|
share={pendingShare}
|
||||||
|
onPick={handlePickRoom}
|
||||||
|
onDismiss={handleDismiss}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
type ClientNonUIFeaturesProps = {
|
type ClientNonUIFeaturesProps = {
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
};
|
};
|
||||||
@@ -471,12 +694,15 @@ export function ClientNonUIFeatures({ children }: ClientNonUIFeaturesProps) {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<EmojiStyleFeature />
|
<EmojiStyleFeature />
|
||||||
|
<AndroidTypographyFeature />
|
||||||
<PageZoomFeature />
|
<PageZoomFeature />
|
||||||
<FaviconUpdater />
|
<FaviconUpdater />
|
||||||
<InviteNotifications />
|
<InviteNotifications />
|
||||||
<MessageNotifications />
|
<MessageNotifications />
|
||||||
|
<BackgroundSyncSetup />
|
||||||
<PaarrotAPIInitializer />
|
<PaarrotAPIInitializer />
|
||||||
<TaskbarFlashStopper />
|
<TaskbarFlashStopper />
|
||||||
|
<AndroidShareIntentHandler />
|
||||||
{children}
|
{children}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
8
overlay/src/app/styles/android-typography.css
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
/*
|
||||||
|
* Android WebView renders Inter Variable with broken word/letter spacing.
|
||||||
|
* Applied when html.android-capacitor is set (see applyAndroidTypographyFix).
|
||||||
|
*/
|
||||||
|
html.android-capacitor body {
|
||||||
|
letter-spacing: normal;
|
||||||
|
word-spacing: normal;
|
||||||
|
}
|
||||||
73
overlay/src/app/utils/androidShare.ts
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
import { Capacitor, registerPlugin, type PluginListenerHandle } from '@capacitor/core';
|
||||||
|
|
||||||
|
/** Metadata for a file received from an Android share intent. */
|
||||||
|
export type AndroidSharedFile = {
|
||||||
|
path: string;
|
||||||
|
name: string;
|
||||||
|
mimeType: string;
|
||||||
|
size: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Payload persisted by the native Android share handler. */
|
||||||
|
export type AndroidSharePayload = {
|
||||||
|
text?: string;
|
||||||
|
subject?: string;
|
||||||
|
files: AndroidSharedFile[];
|
||||||
|
receivedAt: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
interface AndroidShareHandlerPlugin {
|
||||||
|
/** Returns the last pending share payload, if one exists. */
|
||||||
|
getPendingShare(): Promise<{ share: AndroidSharePayload | null }>;
|
||||||
|
/** Clears the last pending share payload after it has been consumed. */
|
||||||
|
clearPendingShare(): Promise<void>;
|
||||||
|
addListener(
|
||||||
|
eventName: 'shareReceived',
|
||||||
|
listenerFunc: (payload: AndroidSharePayload) => void
|
||||||
|
): Promise<PluginListenerHandle>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const AndroidShareHandler = registerPlugin<AndroidShareHandlerPlugin>('AndroidShareHandler');
|
||||||
|
|
||||||
|
/** Returns true when the Android share bridge is available. */
|
||||||
|
export const isAndroidShareSupported = (): boolean =>
|
||||||
|
Capacitor.isNativePlatform() && Capacitor.getPlatform() === 'android';
|
||||||
|
|
||||||
|
/** Fetches the current pending native share payload. */
|
||||||
|
export const getPendingAndroidShare = async (): Promise<AndroidSharePayload | null> => {
|
||||||
|
if (!isAndroidShareSupported()) return null;
|
||||||
|
const result = await AndroidShareHandler.getPendingShare();
|
||||||
|
return result.share;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Clears the native pending share payload. */
|
||||||
|
export const clearPendingAndroidShare = async (): Promise<void> => {
|
||||||
|
if (!isAndroidShareSupported()) return;
|
||||||
|
await AndroidShareHandler.clearPendingShare();
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Subscribes to new incoming native share payloads. */
|
||||||
|
export const listenForAndroidShares = async (
|
||||||
|
listener: (payload: AndroidSharePayload) => void
|
||||||
|
): Promise<PluginListenerHandle | undefined> => {
|
||||||
|
if (!isAndroidShareSupported()) return undefined;
|
||||||
|
return AndroidShareHandler.addListener('shareReceived', listener);
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Converts a cached native shared file into a browser File for upload. */
|
||||||
|
export const materializeSharedFile = async (
|
||||||
|
sharedFile: AndroidSharedFile,
|
||||||
|
receivedAt: number
|
||||||
|
): Promise<File> => {
|
||||||
|
const fileUrl = Capacitor.convertFileSrc(sharedFile.path);
|
||||||
|
const response = await fetch(fileUrl);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Failed to read shared file: ${sharedFile.name}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const blob = await response.blob();
|
||||||
|
return new File([blob], sharedFile.name, {
|
||||||
|
type: sharedFile.mimeType || blob.type,
|
||||||
|
lastModified: receivedAt,
|
||||||
|
});
|
||||||
|
};
|
||||||
518
overlay/src/app/utils/backgroundSync.ts
Normal file
@@ -0,0 +1,518 @@
|
|||||||
|
import { Capacitor, registerPlugin, type PluginListenerHandle } from '@capacitor/core';
|
||||||
|
import type { IPusherRequest, MatrixClient } from 'matrix-js-sdk';
|
||||||
|
|
||||||
|
type UnifiedPushStatus = {
|
||||||
|
running: boolean;
|
||||||
|
endpoint: string;
|
||||||
|
instance: string;
|
||||||
|
registered: boolean;
|
||||||
|
distributor: string;
|
||||||
|
distributors: string[] | string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type UnifiedPushEndpointEvent = {
|
||||||
|
endpoint: string;
|
||||||
|
previousEndpoint: string;
|
||||||
|
instance: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type UnifiedPushUnregisteredEvent = {
|
||||||
|
previousEndpoint: string;
|
||||||
|
instance: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type UnifiedPushRegistrationFailedEvent = {
|
||||||
|
reason: string;
|
||||||
|
instance: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
interface MatrixBackgroundSyncPlugin {
|
||||||
|
/** Persist credentials and request UnifiedPush registration. */
|
||||||
|
start(options: {
|
||||||
|
homeserverUrl: string;
|
||||||
|
accessToken: string;
|
||||||
|
userId: string;
|
||||||
|
deviceId: string;
|
||||||
|
}): Promise<void>;
|
||||||
|
/** Trigger a one-shot fetch as if a push ping arrived. */
|
||||||
|
triggerPing(options: { reason?: string }): Promise<void>;
|
||||||
|
/** Re-open distributor setup flow and retry registration. */
|
||||||
|
requestDistributorSetup(): Promise<{ success: boolean }>;
|
||||||
|
/** Stop any in-flight fetch, clear credentials, and unregister UnifiedPush. */
|
||||||
|
stop(): Promise<void>;
|
||||||
|
/**
|
||||||
|
* Notify the service whether the app UI is visible.
|
||||||
|
* When foreground is true, the service suppresses native notifications
|
||||||
|
* because the JS layer handles them via LocalNotifications.
|
||||||
|
*/
|
||||||
|
setAppForeground(options: { foreground: boolean }): Promise<void>;
|
||||||
|
/** Returns fetch state and current UnifiedPush registration details. */
|
||||||
|
getStatus(): Promise<UnifiedPushStatus>;
|
||||||
|
addListener(
|
||||||
|
eventName: 'unifiedPushNewEndpoint',
|
||||||
|
listenerFunc: (event: UnifiedPushEndpointEvent) => void
|
||||||
|
): Promise<PluginListenerHandle>;
|
||||||
|
addListener(
|
||||||
|
eventName: 'unifiedPushUnregistered',
|
||||||
|
listenerFunc: (event: UnifiedPushUnregisteredEvent) => void
|
||||||
|
): Promise<PluginListenerHandle>;
|
||||||
|
addListener(
|
||||||
|
eventName: 'unifiedPushRegistrationFailed',
|
||||||
|
listenerFunc: (event: UnifiedPushRegistrationFailedEvent) => void
|
||||||
|
): Promise<PluginListenerHandle>;
|
||||||
|
}
|
||||||
|
|
||||||
|
type StoredPusherState = {
|
||||||
|
endpoint: string;
|
||||||
|
appId: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const MatrixBackgroundSync = registerPlugin<MatrixBackgroundSyncPlugin>('MatrixBackgroundSync');
|
||||||
|
const DEFAULT_UNIFIED_PUSH_GATEWAY = 'https://matrix.gateway.unifiedpush.org/_matrix/push/v1/notify';
|
||||||
|
const PUSHER_APP_ID_BASE = 'com.paarrot.app.android';
|
||||||
|
const PUSHER_STORAGE_PREFIX = 'paarrot.unifiedpush';
|
||||||
|
|
||||||
|
/** Returns true when the current platform is Android Capacitor. */
|
||||||
|
export const isBackgroundSyncSupported = (): boolean =>
|
||||||
|
Capacitor.isNativePlatform() && Capacitor.getPlatform() === 'android';
|
||||||
|
|
||||||
|
const getStoredPusherKey = (userId: string | null, deviceId: string | null): string =>
|
||||||
|
`${PUSHER_STORAGE_PREFIX}:${userId ?? 'unknown'}:${deviceId ?? 'unknown'}`;
|
||||||
|
|
||||||
|
const buildPusherAppId = (deviceId: string | null): string => {
|
||||||
|
const raw = deviceId ? `${PUSHER_APP_ID_BASE}.${deviceId}` : PUSHER_APP_ID_BASE;
|
||||||
|
return raw.length > 64 ? raw.slice(0, 64) : raw;
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadStoredPusherState = (
|
||||||
|
userId: string | null,
|
||||||
|
deviceId: string | null
|
||||||
|
): StoredPusherState | undefined => {
|
||||||
|
if (typeof window === 'undefined' || !window.localStorage) return undefined;
|
||||||
|
|
||||||
|
const raw = window.localStorage.getItem(getStoredPusherKey(userId, deviceId));
|
||||||
|
if (!raw) return undefined;
|
||||||
|
|
||||||
|
try {
|
||||||
|
return JSON.parse(raw) as StoredPusherState;
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const saveStoredPusherState = (
|
||||||
|
userId: string | null,
|
||||||
|
deviceId: string | null,
|
||||||
|
state: StoredPusherState
|
||||||
|
): void => {
|
||||||
|
if (typeof window === 'undefined' || !window.localStorage) return;
|
||||||
|
window.localStorage.setItem(getStoredPusherKey(userId, deviceId), JSON.stringify(state));
|
||||||
|
};
|
||||||
|
|
||||||
|
const clearStoredPusherState = (userId: string | null, deviceId: string | null): void => {
|
||||||
|
if (typeof window === 'undefined' || !window.localStorage) return;
|
||||||
|
window.localStorage.removeItem(getStoredPusherKey(userId, deviceId));
|
||||||
|
};
|
||||||
|
|
||||||
|
const normalizeDistributors = (raw: UnifiedPushStatus['distributors']): string[] => {
|
||||||
|
if (Array.isArray(raw)) return raw;
|
||||||
|
if (typeof raw !== 'string') return [];
|
||||||
|
|
||||||
|
const trimmed = raw.trim();
|
||||||
|
if (!trimmed || trimmed === '[]') return [];
|
||||||
|
|
||||||
|
if (trimmed.startsWith('[') && trimmed.endsWith(']')) {
|
||||||
|
return trimmed
|
||||||
|
.slice(1, -1)
|
||||||
|
.split(',')
|
||||||
|
.map((entry) => entry.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
return [trimmed];
|
||||||
|
};
|
||||||
|
|
||||||
|
const resolveUnifiedPushGateway = async (endpoint: string): Promise<string> => {
|
||||||
|
try {
|
||||||
|
const discoveryUrl = new URL(endpoint);
|
||||||
|
discoveryUrl.pathname = '/_matrix/push/v1/notify';
|
||||||
|
discoveryUrl.search = '';
|
||||||
|
|
||||||
|
const response = await fetch(discoveryUrl.toString(), { method: 'GET' });
|
||||||
|
if (!response.ok) return DEFAULT_UNIFIED_PUSH_GATEWAY;
|
||||||
|
|
||||||
|
const body = (await response.json()) as {
|
||||||
|
gateway?: string;
|
||||||
|
unifiedpush?: { gateway?: string };
|
||||||
|
};
|
||||||
|
|
||||||
|
if (body.gateway === 'matrix' || body.unifiedpush?.gateway === 'matrix') {
|
||||||
|
return discoveryUrl.toString();
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('[BackgroundSync] UnifiedPush gateway discovery failed:', err);
|
||||||
|
}
|
||||||
|
|
||||||
|
return DEFAULT_UNIFIED_PUSH_GATEWAY;
|
||||||
|
};
|
||||||
|
|
||||||
|
class AndroidUnifiedPushManager {
|
||||||
|
private client: MatrixClient | undefined;
|
||||||
|
|
||||||
|
private listenerHandles: PluginListenerHandle[] = [];
|
||||||
|
|
||||||
|
private listenersReady = false;
|
||||||
|
|
||||||
|
private distributorSetupAttempted = false;
|
||||||
|
|
||||||
|
private distributorPromptShown = false;
|
||||||
|
|
||||||
|
/** Start native UnifiedPush registration and synchronize the Matrix pusher. */
|
||||||
|
async start(mx: MatrixClient): Promise<void> {
|
||||||
|
console.log('[BackgroundSync] start() called, platform:', Capacitor.getPlatform(), 'isNative:', Capacitor.isNativePlatform());
|
||||||
|
|
||||||
|
if (!isBackgroundSyncSupported()) {
|
||||||
|
console.log('[BackgroundSync] Background sync not supported on this platform');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const homeserverUrl = mx.getHomeserverUrl();
|
||||||
|
const accessToken = mx.getAccessToken();
|
||||||
|
const userId = mx.getUserId();
|
||||||
|
const deviceId = mx.getDeviceId();
|
||||||
|
|
||||||
|
console.log('[BackgroundSync] Credentials check:', { userId, deviceId, hasToken: !!accessToken, hasUrl: !!homeserverUrl });
|
||||||
|
|
||||||
|
if (!homeserverUrl || !accessToken || !userId) {
|
||||||
|
console.warn('[BackgroundSync] Missing credentials, not starting');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.client = mx;
|
||||||
|
this.distributorSetupAttempted = false;
|
||||||
|
this.distributorPromptShown = false;
|
||||||
|
await this.ensureListeners();
|
||||||
|
|
||||||
|
try {
|
||||||
|
console.log('[BackgroundSync] Calling plugin.start()...');
|
||||||
|
await MatrixBackgroundSync.start({
|
||||||
|
homeserverUrl,
|
||||||
|
accessToken,
|
||||||
|
userId,
|
||||||
|
deviceId: deviceId ?? '',
|
||||||
|
});
|
||||||
|
console.log('[BackgroundSync] plugin.start() completed');
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[BackgroundSync] plugin.start() failed:', err);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.syncExistingEndpoint();
|
||||||
|
await this.ensureDistributorPromptFromStatus();
|
||||||
|
console.log('[BackgroundSync] UnifiedPush registration requested');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Stop native UnifiedPush integration and remove the Matrix pusher for this device. */
|
||||||
|
async stop(): Promise<void> {
|
||||||
|
if (!isBackgroundSyncSupported()) return;
|
||||||
|
|
||||||
|
const client = this.client;
|
||||||
|
if (client) {
|
||||||
|
const deviceId = client.getDeviceId();
|
||||||
|
const userId = client.getUserId();
|
||||||
|
const status = await this.safeGetStatus();
|
||||||
|
const stored = loadStoredPusherState(userId, deviceId);
|
||||||
|
const endpoint = status?.endpoint || stored?.endpoint;
|
||||||
|
const appId = stored?.appId ?? buildPusherAppId(deviceId);
|
||||||
|
|
||||||
|
if (endpoint) {
|
||||||
|
await this.removePusher(client, endpoint, appId);
|
||||||
|
}
|
||||||
|
clearStoredPusherState(userId, deviceId);
|
||||||
|
}
|
||||||
|
|
||||||
|
await MatrixBackgroundSync.stop();
|
||||||
|
await this.disposeListeners();
|
||||||
|
this.client = undefined;
|
||||||
|
console.log('[BackgroundSync] UnifiedPush stopped');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Update the Matrix pusher when a new native endpoint is published. */
|
||||||
|
private async handleNewEndpoint(event: UnifiedPushEndpointEvent): Promise<void> {
|
||||||
|
const client = this.client;
|
||||||
|
if (!client) return;
|
||||||
|
|
||||||
|
if (event.previousEndpoint) {
|
||||||
|
await this.removePusher(client, event.previousEndpoint);
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.upsertPusher(client, event.endpoint);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Remove the Matrix pusher when UnifiedPush unregisters this instance. */
|
||||||
|
private async handleUnregistered(event: UnifiedPushUnregisteredEvent): Promise<void> {
|
||||||
|
const client = this.client;
|
||||||
|
if (!client) return;
|
||||||
|
|
||||||
|
const stored = loadStoredPusherState(client.getUserId(), client.getDeviceId());
|
||||||
|
const endpoint = event.previousEndpoint || stored?.endpoint;
|
||||||
|
const appId = stored?.appId ?? buildPusherAppId(client.getDeviceId());
|
||||||
|
|
||||||
|
if (endpoint) {
|
||||||
|
await this.removePusher(client, endpoint, appId);
|
||||||
|
}
|
||||||
|
|
||||||
|
clearStoredPusherState(client.getUserId(), client.getDeviceId());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Log native registration failures so the missing distributor path is visible. */
|
||||||
|
private handleRegistrationFailed(event: UnifiedPushRegistrationFailedEvent): void {
|
||||||
|
console.warn('[BackgroundSync] UnifiedPush registration failed:', event.reason);
|
||||||
|
|
||||||
|
if (event.reason !== 'ACTION_REQUIRED' || this.distributorSetupAttempted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.distributorSetupAttempted = true;
|
||||||
|
void this.tryRequestDistributorSetup();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Re-opens distributor selection once after ACTION_REQUIRED and logs actionable status. */
|
||||||
|
private async tryRequestDistributorSetup(): Promise<void> {
|
||||||
|
try {
|
||||||
|
const setupResult = await MatrixBackgroundSync.requestDistributorSetup();
|
||||||
|
console.warn('[BackgroundSync] requestDistributorSetup result:', setupResult);
|
||||||
|
const status = await this.safeGetStatus();
|
||||||
|
console.warn('[BackgroundSync] UnifiedPush status after setup attempt:', status);
|
||||||
|
const distributors = normalizeDistributors(status?.distributors ?? []);
|
||||||
|
if (distributors.length === 0) {
|
||||||
|
console.error(
|
||||||
|
'[BackgroundSync] No UnifiedPush distributor installed. Install one (for example ntfy) to enable Android background notifications.'
|
||||||
|
);
|
||||||
|
this.showNoDistributorPrompt();
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('[BackgroundSync] requestDistributorSetup failed:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fallback check so users still get prompted even when ACTION_REQUIRED event is missed. */
|
||||||
|
private async ensureDistributorPromptFromStatus(): Promise<void> {
|
||||||
|
const status = await this.safeGetStatus();
|
||||||
|
if (!status) return;
|
||||||
|
|
||||||
|
const distributors = normalizeDistributors(status.distributors);
|
||||||
|
if (!status.registered && distributors.length === 0) {
|
||||||
|
console.error(
|
||||||
|
'[BackgroundSync] No UnifiedPush distributor installed. Install one (for example ntfy) to enable Android background notifications.'
|
||||||
|
);
|
||||||
|
this.showNoDistributorPrompt();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Show a one-time actionable prompt when no distributor app is installed. */
|
||||||
|
private showNoDistributorPrompt(): void {
|
||||||
|
if (this.distributorPromptShown) return;
|
||||||
|
this.distributorPromptShown = true;
|
||||||
|
|
||||||
|
const message =
|
||||||
|
'Android background notifications need a UnifiedPush distributor app. Install one (for example ntfy), then reopen Paarrot.';
|
||||||
|
const docsUrl = 'https://unifiedpush.org/users/distributors/';
|
||||||
|
|
||||||
|
if (typeof window === 'undefined') return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const openDocs = window.confirm(`${message}\n\nOpen distributor list now?`);
|
||||||
|
if (openDocs) {
|
||||||
|
window.open(docsUrl, '_blank', 'noopener,noreferrer');
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
window.alert(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Install plugin listeners once for the active Matrix client. */
|
||||||
|
private async ensureListeners(): Promise<void> {
|
||||||
|
if (this.listenersReady) return;
|
||||||
|
|
||||||
|
this.listenerHandles = [
|
||||||
|
await MatrixBackgroundSync.addListener('unifiedPushNewEndpoint', (event) => {
|
||||||
|
void this.handleNewEndpoint(event).catch((err) => {
|
||||||
|
console.error('[BackgroundSync] handleNewEndpoint failed:', err);
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
await MatrixBackgroundSync.addListener('unifiedPushUnregistered', (event) => {
|
||||||
|
void this.handleUnregistered(event);
|
||||||
|
}),
|
||||||
|
await MatrixBackgroundSync.addListener('unifiedPushRegistrationFailed', (event) => {
|
||||||
|
this.handleRegistrationFailed(event);
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
this.listenersReady = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Remove all plugin listeners when the manager stops. */
|
||||||
|
private async disposeListeners(): Promise<void> {
|
||||||
|
await Promise.all(this.listenerHandles.map((handle) => handle.remove()));
|
||||||
|
this.listenerHandles = [];
|
||||||
|
this.listenersReady = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reconcile an already-persisted native endpoint after app startup. */
|
||||||
|
private async syncExistingEndpoint(): Promise<void> {
|
||||||
|
const client = this.client;
|
||||||
|
if (!client) return;
|
||||||
|
|
||||||
|
const status = await this.safeGetStatus();
|
||||||
|
console.log('[BackgroundSync] Native status before pusher sync:', status);
|
||||||
|
if (status?.registered && status.endpoint) {
|
||||||
|
await this.upsertPusher(client, status.endpoint);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Create or refresh the Matrix HTTP pusher for the current device. */
|
||||||
|
private async upsertPusher(mx: MatrixClient, endpoint: string): Promise<void> {
|
||||||
|
const deviceId = mx.getDeviceId();
|
||||||
|
const userId = mx.getUserId();
|
||||||
|
const appId = buildPusherAppId(deviceId);
|
||||||
|
const gatewayUrl = await resolveUnifiedPushGateway(endpoint);
|
||||||
|
|
||||||
|
console.log('[BackgroundSync] Upserting Matrix pusher', {
|
||||||
|
appId,
|
||||||
|
endpoint,
|
||||||
|
gatewayUrl,
|
||||||
|
deviceId,
|
||||||
|
userId,
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
await mx.setPusher({
|
||||||
|
kind: 'http',
|
||||||
|
app_id: appId,
|
||||||
|
pushkey: endpoint,
|
||||||
|
app_display_name: 'Paarrot',
|
||||||
|
device_display_name: deviceId ?? 'Android',
|
||||||
|
lang: 'en',
|
||||||
|
data: {
|
||||||
|
url: gatewayUrl,
|
||||||
|
format: 'event_id_only',
|
||||||
|
},
|
||||||
|
append: false,
|
||||||
|
device_id: deviceId ?? undefined,
|
||||||
|
} as unknown as IPusherRequest);
|
||||||
|
|
||||||
|
const pushers = (await mx.getPushers())?.pushers ?? [];
|
||||||
|
const thisPusher = pushers.find((p) => p.pushkey === endpoint && p.app_id === appId);
|
||||||
|
console.log('[BackgroundSync] Matrix pusher upserted successfully', {
|
||||||
|
totalPushers: pushers.length,
|
||||||
|
foundThisPusher: Boolean(thisPusher),
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[BackgroundSync] Matrix pusher upsert failed:', err);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
|
||||||
|
saveStoredPusherState(userId, deviceId, { endpoint, appId });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Remove a previously-registered Matrix HTTP pusher for this device. */
|
||||||
|
private async removePusher(
|
||||||
|
mx: MatrixClient,
|
||||||
|
endpoint: string,
|
||||||
|
appId = buildPusherAppId(mx.getDeviceId())
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
await mx.setPusher({
|
||||||
|
pushkey: endpoint,
|
||||||
|
app_id: appId,
|
||||||
|
kind: null,
|
||||||
|
} as unknown as IPusherRequest);
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('[BackgroundSync] Failed to remove UnifiedPush pusher:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Read native registration state without failing the caller. */
|
||||||
|
private async safeGetStatus(): Promise<UnifiedPushStatus | undefined> {
|
||||||
|
try {
|
||||||
|
return await MatrixBackgroundSync.getStatus();
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('[BackgroundSync] Failed to read UnifiedPush status:', err);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const unifiedPushManager = new AndroidUnifiedPushManager();
|
||||||
|
|
||||||
|
/** Start native UnifiedPush registration and sync the Matrix pusher. */
|
||||||
|
export const startBackgroundSync = async (mx: MatrixClient): Promise<void> => {
|
||||||
|
await unifiedPushManager.start(mx);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Manually triggers a one-shot native fetch.
|
||||||
|
* Useful for diagnostics and bridge testing.
|
||||||
|
*/
|
||||||
|
export const triggerBackgroundSyncPing = async (reason?: string): Promise<void> => {
|
||||||
|
if (!isBackgroundSyncSupported()) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await MatrixBackgroundSync.triggerPing({ reason });
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('[BackgroundSync] triggerPing failed:', err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Stop UnifiedPush integration and remove the Matrix pusher for this session. */
|
||||||
|
export const stopBackgroundSync = async (): Promise<void> => {
|
||||||
|
await unifiedPushManager.stop();
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tells the native service whether the app UI is in the foreground.
|
||||||
|
* Call when document visibility changes so the service can suppress
|
||||||
|
* duplicate notifications while the JS layer is active.
|
||||||
|
* @param foreground true if the WebView UI is currently visible
|
||||||
|
*/
|
||||||
|
export const setAppForegroundState = async (foreground: boolean): Promise<void> => {
|
||||||
|
if (!isBackgroundSyncSupported()) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await MatrixBackgroundSync.setAppForeground({ foreground });
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('[BackgroundSync] setAppForeground failed:', err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Re-opens the UnifiedPush distributor selection dialog.
|
||||||
|
* Allows users to re-select or change their push notification distributor without terminal access.
|
||||||
|
* Useful when the current endpoint becomes unavailable.
|
||||||
|
*/
|
||||||
|
export const requestResetPushRegistration = async (): Promise<{ success: boolean }> => {
|
||||||
|
if (!isBackgroundSyncSupported()) {
|
||||||
|
console.warn('[BackgroundSync] Background sync not supported on this platform');
|
||||||
|
return { success: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await MatrixBackgroundSync.requestDistributorSetup();
|
||||||
|
console.log('[BackgroundSync] requestDistributorSetup completed:', result);
|
||||||
|
return result ?? { success: false };
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[BackgroundSync] requestDistributorSetup failed:', err);
|
||||||
|
return { success: false };
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Returns the current native UnifiedPush status, or undefined if unavailable. */
|
||||||
|
export const getBackgroundSyncStatus = async (): Promise<UnifiedPushStatus | undefined> => {
|
||||||
|
if (!isBackgroundSyncSupported()) return undefined;
|
||||||
|
try {
|
||||||
|
return await MatrixBackgroundSync.getStatus();
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('[BackgroundSync] getStatus failed:', err);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
};
|
||||||
72
overlay/src/app/utils/pluginHost.ts
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
import { Capacitor, registerPlugin } from '@capacitor/core';
|
||||||
|
|
||||||
|
export type PluginHostListItem = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
version?: string;
|
||||||
|
description?: string;
|
||||||
|
author?: string;
|
||||||
|
repository?: string;
|
||||||
|
thumbnail?: string;
|
||||||
|
homepage?: string;
|
||||||
|
tags?: string[];
|
||||||
|
installedDate: string;
|
||||||
|
path: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PluginHostResult<T> = {
|
||||||
|
success: boolean;
|
||||||
|
data?: T;
|
||||||
|
error?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface PluginHostAPI {
|
||||||
|
getPath: () => Promise<PluginHostResult<string>>;
|
||||||
|
download: (
|
||||||
|
pluginId: string,
|
||||||
|
downloadUrl: string,
|
||||||
|
name: string
|
||||||
|
) => Promise<PluginHostResult<{ path: string }>>;
|
||||||
|
list: () => Promise<PluginHostResult<PluginHostListItem[]>>;
|
||||||
|
uninstall: (pluginId: string) => Promise<PluginHostResult<void>>;
|
||||||
|
readPluginCode: (pluginId: string) => Promise<PluginHostResult<string>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PluginStoragePlugin {
|
||||||
|
getPath(): Promise<PluginHostResult<string>>;
|
||||||
|
download(options: {
|
||||||
|
pluginId: string;
|
||||||
|
downloadUrl: string;
|
||||||
|
name: string;
|
||||||
|
}): Promise<PluginHostResult<{ path: string }>>;
|
||||||
|
list(): Promise<PluginHostResult<PluginHostListItem[]>>;
|
||||||
|
uninstall(options: { pluginId: string }): Promise<PluginHostResult<void>>;
|
||||||
|
readPluginCode(options: { pluginId: string }): Promise<PluginHostResult<string>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const PluginStorage = registerPlugin<PluginStoragePlugin>('PluginStorage');
|
||||||
|
|
||||||
|
const androidPluginHost: PluginHostAPI = {
|
||||||
|
getPath: () => PluginStorage.getPath(),
|
||||||
|
download: (pluginId, downloadUrl, name) =>
|
||||||
|
PluginStorage.download({ pluginId, downloadUrl, name }),
|
||||||
|
list: () => PluginStorage.list(),
|
||||||
|
uninstall: (pluginId) => PluginStorage.uninstall({ pluginId }),
|
||||||
|
readPluginCode: (pluginId) => PluginStorage.readPluginCode({ pluginId }),
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Returns true when a native plugin filesystem host is available. */
|
||||||
|
export const isPluginHostSupported = (): boolean => getPluginHost() !== null;
|
||||||
|
|
||||||
|
/** Returns the active plugin host for Electron or Android. */
|
||||||
|
export const getPluginHost = (): PluginHostAPI | null => {
|
||||||
|
if (window.electron?.plugins) {
|
||||||
|
return window.electron.plugins;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Capacitor.isNativePlatform() && Capacitor.getPlatform() === 'android') {
|
||||||
|
return androidPluginHost;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
@@ -248,6 +248,41 @@ export const isAndroid = (): boolean => {
|
|||||||
return ua.includes('android');
|
return ua.includes('android');
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** Returns true when running as a Capacitor app on Android. */
|
||||||
|
export const isCapacitorAndroid = (): boolean => {
|
||||||
|
if (typeof window === 'undefined') return false;
|
||||||
|
const cap = (window as { Capacitor?: { isNativePlatform?: () => boolean; getPlatform?: () => string } }).Capacitor;
|
||||||
|
return Boolean(cap?.isNativePlatform?.() && cap?.getPlatform?.() === 'android');
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Android WebView mishandles Inter Variable — swap to static Inter / system fonts.
|
||||||
|
*/
|
||||||
|
export const applyAndroidTypographyFix = (): void => {
|
||||||
|
if (!isCapacitorAndroid()) return;
|
||||||
|
|
||||||
|
const root = document.documentElement;
|
||||||
|
root.classList.add('android-capacitor');
|
||||||
|
|
||||||
|
void (async () => {
|
||||||
|
try {
|
||||||
|
await import('@fontsource/inter/400.css');
|
||||||
|
await import('@fontsource/inter/500.css');
|
||||||
|
await import('@fontsource/inter/600.css');
|
||||||
|
root.style.setProperty(
|
||||||
|
'--font-secondary',
|
||||||
|
`'Inter', system-ui, Roboto, 'Noto Sans', var(--font-emoji), sans-serif`
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('[applyAndroidTypographyFix] Falling back to system font:', err);
|
||||||
|
root.style.setProperty(
|
||||||
|
'--font-secondary',
|
||||||
|
`system-ui, Roboto, 'Noto Sans', var(--font-emoji), sans-serif`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Apply safe area insets for mobile devices
|
* Apply safe area insets for mobile devices
|
||||||
* On Android, CSS env() may not work, so we apply fallback padding
|
* On Android, CSS env() may not work, so we apply fallback padding
|
||||||
|
|||||||
6156
package-lock.json
generated
27
package.json
@@ -1,22 +1,19 @@
|
|||||||
{
|
{
|
||||||
"name": "paarrot",
|
"name": "paarrot",
|
||||||
"version": "4.11.76",
|
"version": "4.11.111",
|
||||||
"description": "Paarrot - A Matrix client based on Cinny",
|
"description": "Paarrot - A Matrix client based on Cinny",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18.0.0"
|
"node": ">=18.0.0"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"android:prepare": "node scripts/apply-overlay.mjs && cd cinny && npm install && npm run build && npx cap sync android",
|
"apply-overlay": "node scripts/apply-overlay.mjs",
|
||||||
"android:patch-settings:linux": "sed -i 's|../node_modules/|../cinny/node_modules/|g' android/capacitor.settings.gradle",
|
"android:prepare": "cd cinny && npm run build && cd .. && npx cap sync android",
|
||||||
"android:restore": "node scripts/restore-cinny.mjs",
|
"android:open": "npx cap open android",
|
||||||
"android:open": "cd cinny && npx cap open android",
|
"android:run": "npx cap run android",
|
||||||
"android:run": "cd cinny && npx cap run android",
|
"android:apk": "npm run apply-overlay && npm run android:prepare && cd android && gradlew.bat assembleDebug",
|
||||||
"android:apk": "npm run android:prepare && cd cinny/android && gradlew.bat assembleDebug && cd ../.. && node scripts/restore-cinny.mjs",
|
"android:apk:release": "npm run apply-overlay && npm run android:prepare && cd android && gradlew.bat assembleRelease",
|
||||||
"android:apk:linux": "npm run android:prepare && npm run android:patch-settings:linux && cd cinny/android && ./gradlew assembleDebug && cd ../.. && node scripts/restore-cinny.mjs",
|
"android:apk:linux": "cd cinny && npm run build && cd .. && npx cap sync android && cd android && ./gradlew assembleDebug",
|
||||||
"android:apk:release": "npm run android:prepare && cd cinny/android && gradlew.bat assembleRelease && cd ../.. && node scripts/restore-cinny.mjs",
|
"android:aab": "npm run apply-overlay && npm run android:prepare && cd android && gradlew.bat bundleRelease"
|
||||||
"android:apk:release:linux": "npm run android:prepare && npm run android:patch-settings:linux && cd cinny/android && ./gradlew assembleRelease && cd ../.. && node scripts/restore-cinny.mjs",
|
|
||||||
"android:aab": "npm run android:prepare && cd cinny/android && gradlew.bat bundleRelease && cd ../.. && node scripts/restore-cinny.mjs",
|
|
||||||
"android:aab:linux": "npm run android:prepare && npm run android:patch-settings:linux && cd cinny/android && ./gradlew bundleRelease && cd ../.. && node scripts/restore-cinny.mjs"
|
|
||||||
},
|
},
|
||||||
"keywords": [],
|
"keywords": [],
|
||||||
"author": {
|
"author": {
|
||||||
@@ -24,6 +21,12 @@
|
|||||||
"email": "mates.media@users.noreply.github.com"
|
"email": "mates.media@users.noreply.github.com"
|
||||||
},
|
},
|
||||||
"license": "AGPL-3.0-only",
|
"license": "AGPL-3.0-only",
|
||||||
|
"dependencies": {
|
||||||
|
"@capacitor/android": "8.3.0",
|
||||||
|
"@capacitor/cli": "8.3.0",
|
||||||
|
"@capacitor/core": "8.3.0",
|
||||||
|
"@capacitor/local-notifications": "8.0.2"
|
||||||
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"node-fetch": "3.3.2",
|
"node-fetch": "3.3.2",
|
||||||
"png2icons": "2.0.1",
|
"png2icons": "2.0.1",
|
||||||
|
|||||||
@@ -30,6 +30,14 @@ const STASH_FLAG = join(ROOT, '.cinny-stash-pending');
|
|||||||
const cinnyStatus = execSync('git status --porcelain', { cwd: CINNY, encoding: 'utf8' });
|
const cinnyStatus = execSync('git status --porcelain', { cwd: CINNY, encoding: 'utf8' });
|
||||||
if (cinnyStatus.trim()) {
|
if (cinnyStatus.trim()) {
|
||||||
console.log('[apply-overlay] Stashing pre-existing cinny changes...');
|
console.log('[apply-overlay] Stashing pre-existing cinny changes...');
|
||||||
|
// Ensure android/ junction is excluded from the cinny git view — otherwise
|
||||||
|
// `git stash -u` would traverse the junction into the root android/ directory
|
||||||
|
// and delete files there when the stash is created.
|
||||||
|
const cinnyExclude = join(CINNY, '.git', 'info', 'exclude');
|
||||||
|
const excludeContent = existsSync(cinnyExclude) ? readFileSync(cinnyExclude, 'utf8') : '';
|
||||||
|
if (!excludeContent.includes('android/')) {
|
||||||
|
writeFileSync(cinnyExclude, excludeContent + (excludeContent.endsWith('\n') || !excludeContent ? '' : '\n') + 'android/\n', 'utf8');
|
||||||
|
}
|
||||||
execSync('git stash push -u -m "pre-overlay stash"', { cwd: CINNY, stdio: 'inherit' });
|
execSync('git stash push -u -m "pre-overlay stash"', { cwd: CINNY, stdio: 'inherit' });
|
||||||
writeFileSync(STASH_FLAG, '1', 'utf8');
|
writeFileSync(STASH_FLAG, '1', 'utf8');
|
||||||
console.log('[apply-overlay] Stash saved.');
|
console.log('[apply-overlay] Stash saved.');
|
||||||
@@ -120,7 +128,21 @@ if (!existsSync(androidJunction)) {
|
|||||||
console.log(` android/ link already exists, skipping`);
|
console.log(` android/ link already exists, skipping`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 7. Write android/local.properties with the current machine's Android SDK path
|
// 8. Sync time-based version into android/app/build.gradle
|
||||||
|
console.log('[apply-overlay] Syncing time-based version into build.gradle...');
|
||||||
|
const buildTimestampMs = Date.now();
|
||||||
|
const versionCode = Math.floor(buildTimestampMs / 1000);
|
||||||
|
const versionName = new Date(buildTimestampMs).toISOString().replace('T', '.').replaceAll(':', '').replace('Z', '');
|
||||||
|
|
||||||
|
const buildGradlePath = join(androidTarget, 'app', 'build.gradle');
|
||||||
|
let buildGradle = readFileSync(buildGradlePath, 'utf8');
|
||||||
|
buildGradle = buildGradle
|
||||||
|
.replace(/versionCode\s+\d+/, `versionCode ${versionCode}`)
|
||||||
|
.replace(/versionName\s+"[^"]+"/, `versionName "${versionName}"`);
|
||||||
|
writeFileSync(buildGradlePath, buildGradle, 'utf8');
|
||||||
|
console.log(` versionName "${versionName}", versionCode ${versionCode}`);
|
||||||
|
|
||||||
|
// 9. Write android/local.properties with the current machine's Android SDK path
|
||||||
const androidHome = process.env.ANDROID_HOME || process.env.ANDROID_SDK_ROOT;
|
const androidHome = process.env.ANDROID_HOME || process.env.ANDROID_SDK_ROOT;
|
||||||
if (androidHome) {
|
if (androidHome) {
|
||||||
console.log('[apply-overlay] Writing android/local.properties...');
|
console.log('[apply-overlay] Writing android/local.properties...');
|
||||||
|
|||||||
@@ -49,14 +49,68 @@ async function generateElectronIcons() {
|
|||||||
console.log('Generated: icon.icns');
|
console.log('Generated: icon.icns');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Android adaptive icon density configs.
|
||||||
|
* Foreground canvas = 108dp equivalent. Safe zone = center 72dp (66.7%).
|
||||||
|
* Legacy launcher icon = 48dp equivalent.
|
||||||
|
* @type {Array<{density: string, foregroundSize: number, launcherSize: number}>}
|
||||||
|
*/
|
||||||
|
const androidDensities = [
|
||||||
|
{ density: 'mipmap-mdpi', foregroundSize: 108, launcherSize: 48 },
|
||||||
|
{ density: 'mipmap-hdpi', foregroundSize: 162, launcherSize: 72 },
|
||||||
|
{ density: 'mipmap-xhdpi', foregroundSize: 216, launcherSize: 96 },
|
||||||
|
{ density: 'mipmap-xxhdpi', foregroundSize: 324, launcherSize: 144 },
|
||||||
|
{ density: 'mipmap-xxxhdpi', foregroundSize: 432, launcherSize: 192 },
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generates Android mipmap launcher icons from the source icon.
|
||||||
|
* The foreground layer centers the content within the adaptive icon safe zone
|
||||||
|
* (72/108dp = 66.7% of canvas), leaving transparent padding for the system mask.
|
||||||
|
*/
|
||||||
|
async function generateAndroidIcons() {
|
||||||
|
const resDir = path.join(rootDir, 'android', 'app', 'src', 'main', 'res');
|
||||||
|
|
||||||
|
for (const { density, foregroundSize, launcherSize } of androidDensities) {
|
||||||
|
const dir = path.join(resDir, density);
|
||||||
|
|
||||||
|
// Foreground layer: content occupies 72/108 of the canvas (safe zone), centered
|
||||||
|
const contentSize = Math.round(foregroundSize * (72 / 108));
|
||||||
|
const padding = Math.round((foregroundSize - contentSize) / 2);
|
||||||
|
|
||||||
|
const foregroundBuffer = await sharp(sourceIcon)
|
||||||
|
.resize(contentSize, contentSize, { fit: 'contain', background: { r: 0, g: 0, b: 0, alpha: 0 } })
|
||||||
|
.extend({ top: padding, bottom: padding, left: padding, right: padding, background: { r: 0, g: 0, b: 0, alpha: 0 } })
|
||||||
|
.png()
|
||||||
|
.toBuffer();
|
||||||
|
|
||||||
|
await fs.writeFile(path.join(dir, 'ic_launcher_foreground.png'), foregroundBuffer);
|
||||||
|
console.log(`${density}/ic_launcher_foreground.png (${foregroundSize}x${foregroundSize}, content ${contentSize}px)`);
|
||||||
|
|
||||||
|
// Legacy launcher icon (ic_launcher, ic_launcher_round) — plain square resize
|
||||||
|
const legacyBuffer = await sharp(sourceIcon)
|
||||||
|
.resize(launcherSize, launcherSize, { fit: 'contain', background: { r: 0, g: 0, b: 0, alpha: 0 } })
|
||||||
|
.png()
|
||||||
|
.toBuffer();
|
||||||
|
|
||||||
|
await fs.writeFile(path.join(dir, 'ic_launcher.png'), legacyBuffer);
|
||||||
|
await fs.writeFile(path.join(dir, 'ic_launcher_round.png'), legacyBuffer);
|
||||||
|
console.log(`${density}/ic_launcher.png + ic_launcher_round.png (${launcherSize}x${launcherSize})`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
console.log('Generating icons from:', sourceIcon);
|
console.log('Generating icons from:', sourceIcon);
|
||||||
console.log('');
|
console.log('');
|
||||||
|
|
||||||
console.log('=== Electron Desktop Icons ===');
|
console.log('=== Electron Desktop Icons ===');
|
||||||
await generateElectronIcons();
|
await generateElectronIcons();
|
||||||
console.log('');
|
console.log('');
|
||||||
|
|
||||||
|
console.log('=== Android Mipmap Icons ===');
|
||||||
|
await generateAndroidIcons();
|
||||||
|
console.log('');
|
||||||
|
|
||||||
console.log('Done!');
|
console.log('Done!');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||