Compare commits
No commits in common. "3af516f" and "v1.3.0" have entirely different histories.
|
|
@ -1,2 +0,0 @@
|
|||
node_modules/
|
||||
.git/
|
||||
|
|
@ -1,3 +1,5 @@
|
|||
webpack.common.js
|
||||
webpack.dev.js
|
||||
webpack.prod.js
|
||||
experiment
|
||||
node_modules
|
||||
*.css
|
||||
node_modules
|
||||
|
|
@ -1,99 +0,0 @@
|
|||
module.exports = {
|
||||
env: {
|
||||
browser: true,
|
||||
es2021: true,
|
||||
},
|
||||
extends: [
|
||||
'eslint:recommended',
|
||||
'plugin:react/recommended',
|
||||
'plugin:react-hooks/recommended',
|
||||
'plugin:@typescript-eslint/eslint-recommended',
|
||||
'plugin:@typescript-eslint/recommended',
|
||||
'airbnb',
|
||||
'prettier',
|
||||
],
|
||||
parser: '@typescript-eslint/parser',
|
||||
parserOptions: {
|
||||
ecmaFeatures: {
|
||||
jsx: true,
|
||||
},
|
||||
ecmaVersion: 'latest',
|
||||
sourceType: 'module',
|
||||
},
|
||||
globals: {
|
||||
JSX: 'readonly',
|
||||
__APP_VERSION__: 'readonly',
|
||||
},
|
||||
plugins: ['react', '@typescript-eslint'],
|
||||
rules: {
|
||||
'linebreak-style': 0,
|
||||
'no-underscore-dangle': 0,
|
||||
'no-shadow': 'off',
|
||||
|
||||
'import/prefer-default-export': 'off',
|
||||
'import/extensions': 'off',
|
||||
'import/no-unresolved': 'off',
|
||||
'import/no-extraneous-dependencies': [
|
||||
'error',
|
||||
{
|
||||
devDependencies: true,
|
||||
},
|
||||
],
|
||||
|
||||
'react/no-unstable-nested-components': ['error', { allowAsProps: true }],
|
||||
'react/jsx-filename-extension': [
|
||||
'error',
|
||||
{
|
||||
extensions: ['.tsx', '.jsx'],
|
||||
},
|
||||
],
|
||||
|
||||
'react/require-default-props': 'off',
|
||||
'react/jsx-props-no-spreading': 'off',
|
||||
'react-hooks/rules-of-hooks': 'error',
|
||||
'react-hooks/exhaustive-deps': 'error',
|
||||
|
||||
// Disable base rules in favour of their @typescript-eslint counterparts —
|
||||
// the base rules can't see TS-specific constructs (interface members, type
|
||||
// imports, etc.) and double-fire alongside the TS versions.
|
||||
'no-unused-vars': 'off',
|
||||
'@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_' }],
|
||||
'@typescript-eslint/no-shadow': 'error',
|
||||
|
||||
// Policy: kept as `warn` at the rule level so editors / `eslint --fix` /
|
||||
// ad-hoc runs surface them as warnings, but `npm run check:eslint` and
|
||||
// `lint-staged` BOTH pass `--max-warnings 0`, so new occurrences block
|
||||
// commit. When unavoidable (matrix-js-sdk boundary, generic helpers,
|
||||
// third-party callback shapes), suppress on the line with
|
||||
// `// eslint-disable-next-line` and a one-line justification.
|
||||
'@typescript-eslint/no-explicit-any': 'warn',
|
||||
'@typescript-eslint/no-non-null-assertion': 'warn',
|
||||
},
|
||||
overrides: [
|
||||
{
|
||||
files: ['*.ts', '*.tsx'],
|
||||
rules: {
|
||||
'no-undef': 'off',
|
||||
},
|
||||
},
|
||||
{
|
||||
// Upstream-vendored binary parsing copied verbatim from matrix-react-sdk
|
||||
// (src/util/cryptE2ERoomKeys.js header link). Bitwise ops, post-increment
|
||||
// and string concatenation are correct for the domain — clean-up risks
|
||||
// breaking E2E room-key import/export. Keep the body byte-identical to
|
||||
// upstream and disable only the rules that fire on those idioms.
|
||||
files: ['src/util/cryptE2ERoomKeys.js'],
|
||||
rules: {
|
||||
'no-bitwise': 'off',
|
||||
'no-plusplus': 'off',
|
||||
'prefer-template': 'off',
|
||||
'no-param-reassign': 'off',
|
||||
// `for (;;)` form upstream uses for the iter-loops trips eslint
|
||||
// even though it's intentional — keep upstream control flow.
|
||||
'no-constant-condition': 'off',
|
||||
// Diagnostic `console.log` left as-is in vendor copy.
|
||||
'no-console': 'off',
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
24
.eslintrc.js
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
module.exports = {
|
||||
env: {
|
||||
browser: true,
|
||||
es2021: true,
|
||||
},
|
||||
extends: [
|
||||
'plugin:react/recommended',
|
||||
'airbnb',
|
||||
],
|
||||
parserOptions: {
|
||||
ecmaFeatures: {
|
||||
jsx: true,
|
||||
},
|
||||
ecmaVersion: 12,
|
||||
sourceType: 'module',
|
||||
},
|
||||
plugins: [
|
||||
'react',
|
||||
],
|
||||
rules: {
|
||||
'linebreak-style': 0,
|
||||
'no-underscore-dangle': 0,
|
||||
},
|
||||
};
|
||||
125
.github/DISCUSSION_TEMPLATE/issue-triage.yml
vendored
|
|
@ -1,125 +0,0 @@
|
|||
labels: ["needs-confirmation"]
|
||||
body:
|
||||
- type: markdown #add faqs in future
|
||||
attributes:
|
||||
value: |
|
||||
> [!IMPORTANT]
|
||||
> Please check for both existing Discussions and Issues prior to opening a new Discussion.
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: "# Issue Details"
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Issue Description
|
||||
description: |
|
||||
Provide a detailed description of the issue. Include relevant information, such as:
|
||||
- The feature or configuration option you encounter the issue with.
|
||||
- Screenshots, screen recordings, or other supporting media (as needed).
|
||||
- If this is a regression of an existing issue that was closed or resolved, please include the previous item reference (Discussion, Issue, PR, commit) in your description.
|
||||
placeholder: |
|
||||
When I try to send a message in a room, the message doesn't appear in the timeline.
|
||||
OR
|
||||
The application crashes when I click on the settings button.
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Expected Behavior
|
||||
description: |
|
||||
Describe how you expect Vojo to behave in this situation.
|
||||
placeholder: |
|
||||
I expected the message to appear in the room timeline immediately after sending.
|
||||
OR
|
||||
The settings panel should open smoothly without any crashes.
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Actual Behavior
|
||||
description: |
|
||||
Describe how Vojo actually behaves in this situation. If it is not immediately obvious how the actual behavior differs from the expected behavior described above, please be sure to mention the deviation specifically.
|
||||
placeholder: |
|
||||
The application freezes for 3 seconds and then shows a white screen.
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Reproduction Steps
|
||||
description: |
|
||||
Provide a detailed set of step-by-step instructions for reproducing this issue.
|
||||
placeholder: |
|
||||
1. Open Vojo and log in to my account
|
||||
2. Navigate to the #general room
|
||||
3. Type a message in the message box
|
||||
4. Press Enter to send
|
||||
5. Notice that the message doesn't appear in the timeline
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Environement
|
||||
description: |
|
||||
Please provide information about your environment. Include the following:
|
||||
- OS:
|
||||
- Browser:
|
||||
- Vojo Web Version: (vojo.chat or self hosted)
|
||||
- Matrix Homeserver:
|
||||
placeholder: |
|
||||
- OS: Windows 11
|
||||
- Browser: Chrome 120.0.6099.109
|
||||
- Vojo Web Version: (vojo.chat or self hosted)
|
||||
- Matrix Homeserver: matrix.org (Synapse 1.97.0)
|
||||
render: text
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: logs
|
||||
attributes:
|
||||
label: Relevant Logs
|
||||
description: |
|
||||
If applicable, add browser console logs to help explain your problem.
|
||||
|
||||
**To get browser console logs:**
|
||||
- Chrome/Edge: Press F12 → Console tab
|
||||
- Firefox: Press F12 → Console tab
|
||||
- Safari: Develop → Show Web Inspector → Console
|
||||
|
||||
Please wrap large log outputs in code blocks with triple backticks (```).
|
||||
placeholder: |
|
||||
```
|
||||
Error: Failed to send message
|
||||
at MessageComposer.sendMessage (composer.js:245)
|
||||
at HTMLButtonElement.onClick (composer.js:189)
|
||||
TypeError: Cannot read property 'content' of undefined
|
||||
at RoomTimeline.render (timeline.js:567)
|
||||
```
|
||||
render: shell
|
||||
validations:
|
||||
required: false
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Additional context
|
||||
description: |
|
||||
Add any other context about the problem here (e.g., when did this start happening, does it happen on different homeservers, etc.)
|
||||
placeholder: |
|
||||
- This started happening after I updated to version 3.2.0
|
||||
- It only happens in encrypted rooms, not in public rooms
|
||||
- I've tried on both Firefox and Chrome with the same result
|
||||
- It works fine on my phone using the same account
|
||||
- This happens on all homeservers I've tested (matrix.org, mozilla.org)
|
||||
validations:
|
||||
required: false
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
# User Acknowledgements
|
||||
> [!TIP]
|
||||
> Use the search function to review existing Discussions and Issues.
|
||||
- type: checkboxes #add faqs in future
|
||||
attributes:
|
||||
label: "I acknowledge that:"
|
||||
options:
|
||||
- label: I have searched the Vojo repository (both open and closed Discussions and Issues) and confirm this is not a duplicate of an existing issue or discussion.
|
||||
required: true
|
||||
- label: I have checked the "Preview" tab on all text fields to ensure that everything looks right, and have wrapped all configuration and code in code blocks with a group of three backticks (` ``` `) on separate lines.
|
||||
required: true
|
||||
3
.github/FUNDING.yml
vendored
|
|
@ -1 +1,2 @@
|
|||
# Vojo project funding
|
||||
open_collective: cinny
|
||||
liberapay: ajbura
|
||||
32
.github/ISSUE_TEMPLATE/bug_report.md
vendored
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
---
|
||||
name: Bug report
|
||||
about: Create a report to help us improve
|
||||
title: ''
|
||||
labels: ''
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
#### Describe the bug
|
||||
A clear and concise description of what the bug is.
|
||||
|
||||
#### To Reproduce
|
||||
Steps to reproduce the behavior:
|
||||
1. Go to '...'
|
||||
2. Click on '....'
|
||||
3. Scroll down to '....'
|
||||
4. See error
|
||||
|
||||
#### Expected behavior
|
||||
A clear and concise description of what you expected to happen.
|
||||
|
||||
#### Screenshots
|
||||
If applicable, add screenshots to help explain your problem.
|
||||
|
||||
#### Desktop (please complete the following information):
|
||||
- OS: [e.g. Windows, MacOS]
|
||||
- Browser: [e.g. chrome, firefox]
|
||||
- Version: [e.g. 3.22]
|
||||
|
||||
#### Additional context
|
||||
Add any other context about the problem here.
|
||||
5
.github/ISSUE_TEMPLATE/config.yml
vendored
|
|
@ -1,5 +0,0 @@
|
|||
blank_issues_enabled: false
|
||||
contact_links:
|
||||
- name: Features, Bug Reports, Questions
|
||||
url: https://github.com/nicejuice-cc/vojo/discussions/new/choose
|
||||
about: Our preferred starting point if you have any questions or suggestions about features or behavior.
|
||||
20
.github/ISSUE_TEMPLATE/feature_request.md
vendored
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
---
|
||||
name: Feature request
|
||||
about: Suggest an idea for this project
|
||||
title: ''
|
||||
labels: ''
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
#### Is your feature request related to a problem? Please describe.
|
||||
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
|
||||
|
||||
#### Describe the solution you'd like
|
||||
A clear and concise description of what you want to happen.
|
||||
|
||||
#### Describe alternatives you've considered
|
||||
A clear and concise description of any alternative solutions or features you've considered.
|
||||
|
||||
#### Additional context
|
||||
Add any other context or screenshots about the feature request here.
|
||||
9
.github/ISSUE_TEMPLATE/preapproved.md
vendored
|
|
@ -1,9 +0,0 @@
|
|||
---
|
||||
name: Pre-Discussed and Approved Topics
|
||||
about: |-
|
||||
Only for topics already discussed and approved in the GitHub Discussions section.
|
||||
---
|
||||
|
||||
**DO NOT OPEN A NEW ISSUE. PLEASE USE THE DISCUSSIONS SECTION.**
|
||||
|
||||
**I DIDN'T READ THE ABOVE LINE. PLEASE CLOSE THIS ISSUE.**
|
||||
24
.github/PULL_REQUEST_TEMPLATE.md
vendored
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
<!-- Please read https://github.com/ajbura/cinny/CONTRIBUTING.md before submitting your pull request -->
|
||||
|
||||
### Description
|
||||
|
||||
Please include a summary of the change and which issue is fixed. Please also include relevant motivation and context. List any dependencies that are required for this change.
|
||||
|
||||
Fixes # (issue)
|
||||
|
||||
#### Type of change
|
||||
|
||||
Please delete options that are not relevant.
|
||||
|
||||
- [ ] 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
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
# 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: docker
|
||||
directory: /
|
||||
schedule:
|
||||
interval: weekly
|
||||
day: "tuesday"
|
||||
time: "01:00"
|
||||
timezone: "Asia/Kolkata"
|
||||
open-pull-requests-limit: 5
|
||||
32
.github/renovate.json
vendored
|
|
@ -1,32 +0,0 @@
|
|||
{
|
||||
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
|
||||
"extends": [
|
||||
"config:recommended",
|
||||
":dependencyDashboardApproval",
|
||||
":semanticCommits",
|
||||
"group:monorepos"
|
||||
],
|
||||
"labels": ["Dependencies"],
|
||||
"rebaseWhen": "conflicted",
|
||||
"packageRules": [
|
||||
{
|
||||
"matchUpdateTypes": ["lockFileMaintenance"]
|
||||
},
|
||||
{
|
||||
"groupName": "Slatejs",
|
||||
"matchPackageNames": ["slate", "slate-dom", "slate-history", "slate-react"]
|
||||
},
|
||||
{
|
||||
"groupName": "Call",
|
||||
"matchPackageNames": ["@element-hq/element-call-embedded", "matrix-widget-api"]
|
||||
},
|
||||
{
|
||||
"groupName": "Linkify",
|
||||
"matchPackageNames": ["linkifyjs", "linkify-react"]
|
||||
}
|
||||
],
|
||||
"lockFileMaintenance": {
|
||||
"enabled": true
|
||||
},
|
||||
"dependencyDashboard": true
|
||||
}
|
||||
40
.github/workflows/build-pull-request.yml
vendored
|
|
@ -1,40 +0,0 @@
|
|||
name: Build pull request
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: ['opened', 'synchronize']
|
||||
|
||||
jobs:
|
||||
build-pull-request:
|
||||
name: Build pull request
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
PR_NUMBER: ${{github.event.number}}
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- name: Setup node
|
||||
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
|
||||
with:
|
||||
node-version-file: ".node-version"
|
||||
package-manager-cache: false
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
- name: Build app
|
||||
env:
|
||||
NODE_OPTIONS: '--max_old_space_size=4096'
|
||||
run: npm run build
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
with:
|
||||
name: preview
|
||||
path: dist
|
||||
retention-days: 1
|
||||
- name: Save pr number
|
||||
run: echo ${PR_NUMBER} > ./pr.txt
|
||||
- name: Upload pr number
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
with:
|
||||
name: pr
|
||||
path: ./pr.txt
|
||||
retention-days: 1
|
||||
57
.github/workflows/deploy-pull-request.yml
vendored
|
|
@ -1,57 +0,0 @@
|
|||
name: Deploy PR to Netlify
|
||||
run-name: "Deploy PR to Netlify (${{ github.event.workflow_run.head_branch }})"
|
||||
|
||||
on:
|
||||
workflow_run:
|
||||
workflows: ["Build pull request"]
|
||||
types: [completed]
|
||||
|
||||
jobs:
|
||||
deploy-pull-request:
|
||||
name: Deploy pull request
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
if: ${{ github.event.workflow_run.conclusion == 'success' }}
|
||||
steps:
|
||||
- name: Download pr number
|
||||
uses: dawidd6/action-download-artifact@2536c51d3d126276eb39f74d6bc9c72ac6ef30d3 # v16
|
||||
with:
|
||||
workflow: ${{ github.event.workflow.id }}
|
||||
run_id: ${{ github.event.workflow_run.id }}
|
||||
name: pr
|
||||
- name: Output pr number
|
||||
id: pr
|
||||
run: echo "id=$(<pr.txt)" >> $GITHUB_OUTPUT
|
||||
- name: Download artifact
|
||||
uses: dawidd6/action-download-artifact@2536c51d3d126276eb39f74d6bc9c72ac6ef30d3 # v16
|
||||
with:
|
||||
workflow: ${{ github.event.workflow.id }}
|
||||
run_id: ${{ github.event.workflow_run.id }}
|
||||
name: preview
|
||||
path: dist
|
||||
- name: Deploy to Netlify
|
||||
id: netlify
|
||||
uses: nwtgck/actions-netlify@4cbaf4c08f1a7bfa537d6113472ef4424e4eb654 # v3.0.0
|
||||
with:
|
||||
publish-dir: dist
|
||||
deploy-message: "Deploy PR ${{ steps.pr.outputs.id }}"
|
||||
alias: ${{ steps.pr.outputs.id }}
|
||||
# These don't work because we're in workflow_run
|
||||
enable-pull-request-comment: false
|
||||
enable-commit-comment: false
|
||||
env:
|
||||
NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }}
|
||||
NETLIFY_SITE_ID: ${{ secrets.NETLIFY_SITE_ID_PR_VOJO }}
|
||||
timeout-minutes: 1
|
||||
- name: Comment preview on PR
|
||||
uses: thollander/actions-comment-pull-request@24bffb9b452ba05a4f3f77933840a6a841d1b32b #v3.0.1
|
||||
env:
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
with:
|
||||
pr-number: ${{ steps.pr.outputs.id }}
|
||||
comment-tag: ${{ steps.pr.outputs.id }}
|
||||
message: |
|
||||
Preview: ${{ steps.netlify.outputs.deploy-url }}
|
||||
⚠️ Exercise caution. Use test accounts. ⚠️
|
||||
62
.github/workflows/docker-pr.yml
vendored
|
|
@ -1,62 +0,0 @@
|
|||
name: 'Docker check'
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- 'Dockerfile'
|
||||
- '.github/workflows/docker-pr.yml'
|
||||
- '.github/workflows/prod-deploy.yml'
|
||||
|
||||
jobs:
|
||||
docker-build:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
|
||||
|
||||
- name: Login to Docker Hub #Do not update this action from a outside PR
|
||||
if: github.event.pull_request.head.repo.fork == false
|
||||
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
continue-on-error: true
|
||||
|
||||
- name: Login to the Github Container registry #Do not update this action from a outside PR
|
||||
if: github.event.pull_request.head.repo.fork == false
|
||||
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
continue-on-error: true
|
||||
|
||||
- name: Extract metadata (tags, labels) for Docker, GHCR
|
||||
id: meta
|
||||
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0
|
||||
with:
|
||||
images: |
|
||||
ghcr.io/${{ github.repository }}
|
||||
|
||||
- name: Build Docker image (no push)
|
||||
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/amd64
|
||||
push: false
|
||||
load: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
|
||||
- name: Show Docker images
|
||||
run: docker images
|
||||
34
.github/workflows/docker.yaml
vendored
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
name: Publish Docker image
|
||||
|
||||
on:
|
||||
release:
|
||||
types: [published]
|
||||
|
||||
jobs:
|
||||
push_to_registry:
|
||||
name: Push Docker image to Docker Hub
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Check out the repo
|
||||
uses: actions/checkout@v2
|
||||
|
||||
- name: Log in to Docker Hub
|
||||
uses: docker/login-action@v1
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
|
||||
- name: Extract metadata (tags, labels) for Docker
|
||||
id: meta
|
||||
uses: docker/metadata-action@v3
|
||||
with:
|
||||
images: ajbura/cinny
|
||||
|
||||
- name: Build and push Docker image
|
||||
uses: docker/build-push-action@v2
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- name: NPM Lockfile Changes
|
||||
uses: codepunkt/npm-lockfile-changes@b40543471c36394409466fdb277a73a0856d7891 # v1.0.0
|
||||
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
|
||||
21
.github/workflows/netlify-dev.yaml
vendored
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
name: 'Deploy to Netlify (dev)'
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- dev
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
name: 'Deploy'
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v1
|
||||
- uses: jsmrcaga/action-netlify-deploy@master
|
||||
with:
|
||||
NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }}
|
||||
NETLIFY_SITE_ID: ${{ secrets.NETLIFY_SITE2_ID }}
|
||||
BUILD_DIRECTORY: "dist"
|
||||
NETLIFY_DEPLOY_MESSAGE: "Dev deploy v${{ github.ref }}"
|
||||
NETLIFY_DEPLOY_TO_PROD: true
|
||||
39
.github/workflows/netlify-dev.yml
vendored
|
|
@ -1,39 +0,0 @@
|
|||
name: Deploy to Netlify (dev)
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- dev
|
||||
|
||||
jobs:
|
||||
deploy-to-netlify:
|
||||
name: Deploy to Netlify
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- name: Setup node
|
||||
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
|
||||
with:
|
||||
node-version-file: ".node-version"
|
||||
package-manager-cache: false
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
- name: Build app
|
||||
env:
|
||||
NODE_OPTIONS: '--max_old_space_size=4096'
|
||||
run: npm run build
|
||||
- name: Deploy to Netlify
|
||||
uses: nwtgck/actions-netlify@4cbaf4c08f1a7bfa537d6113472ef4424e4eb654 # v3.0.0
|
||||
with:
|
||||
publish-dir: dist
|
||||
deploy-message: 'Dev deploy ${{ github.sha }}'
|
||||
enable-commit-comment: false
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
production-deploy: true
|
||||
github-deployment-environment: nightly
|
||||
github-deployment-description: 'Nightly deployment on each commit to dev branch'
|
||||
env:
|
||||
NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }}
|
||||
NETLIFY_SITE_ID: ${{ secrets.NETLIFY_SITE_ID_DEV }}
|
||||
timeout-minutes: 1
|
||||
20
.github/workflows/netlify-prod.yaml
vendored
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
name: 'Deploy to Netlify (prod)'
|
||||
|
||||
on:
|
||||
release:
|
||||
types: [published]
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
name: 'Deploy'
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v1
|
||||
- uses: jsmrcaga/action-netlify-deploy@master
|
||||
with:
|
||||
NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }}
|
||||
NETLIFY_SITE_ID: ${{ secrets.NETLIFY_SITE_ID }}
|
||||
BUILD_DIRECTORY: "dist"
|
||||
NETLIFY_DEPLOY_MESSAGE: "Prod deploy v${{ github.ref }}"
|
||||
NETLIFY_DEPLOY_TO_PROD: true
|
||||
15
.github/workflows/pr-title.yml
vendored
|
|
@ -1,15 +0,0 @@
|
|||
name: Check PR title
|
||||
|
||||
on:
|
||||
pull_request_target:
|
||||
types:
|
||||
- opened
|
||||
- edited
|
||||
|
||||
jobs:
|
||||
lint:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: amannn/action-semantic-pull-request@48f256284bd46cdaab1048c3721360e808335d50 # v6.1.1
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
113
.github/workflows/prod-deploy.yml
vendored
|
|
@ -1,113 +0,0 @@
|
|||
name: Production deploy
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
deploy-and-tarball:
|
||||
name: Netlify deploy and tarball
|
||||
outputs:
|
||||
version: ${{ steps.vars.outputs.tag }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Setup node
|
||||
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
|
||||
with:
|
||||
node-version-file: ".node-version"
|
||||
package-manager-cache: false
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
- name: Get version from tag
|
||||
id: vars
|
||||
run: |
|
||||
TAG=$(git describe --tags --abbrev=0)
|
||||
echo "tag=$TAG" >> $GITHUB_OUTPUT
|
||||
- name: Build app
|
||||
env:
|
||||
NODE_OPTIONS: '--max_old_space_size=4096'
|
||||
run: npm run build
|
||||
- name: Deploy to Netlify
|
||||
uses: nwtgck/actions-netlify@4cbaf4c08f1a7bfa537d6113472ef4424e4eb654 # v3.0.0
|
||||
with:
|
||||
publish-dir: dist
|
||||
deploy-message: 'Prod deploy ${{ steps.vars.outputs.tag }}'
|
||||
enable-commit-comment: false
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
production-deploy: true
|
||||
github-deployment-environment: stable
|
||||
github-deployment-description: 'Stable deployment on each release'
|
||||
env:
|
||||
NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }}
|
||||
NETLIFY_SITE_ID: ${{ secrets.NETLIFY_SITE_ID_APP }}
|
||||
timeout-minutes: 1
|
||||
- name: Create tar.gz
|
||||
run: tar -czvf vojo-${{ steps.vars.outputs.tag }}.tar.gz dist
|
||||
- name: Sign tar.gz
|
||||
run: |
|
||||
echo '${{ secrets.GNUPG_KEY }}' | gpg --batch --import
|
||||
# Sadly a few lines in the private key match a few lines in the public key,
|
||||
# As a result just --export --armor gives us a few lines replaced with ***
|
||||
# making it useless for importing the signing key. Instead, we dump it as
|
||||
# non-armored and hex-encode it so that its printable.
|
||||
echo "PGP Signing key, in raw PGP format in hex. Import with cat ... | xxd -r -p - | gpg --import"
|
||||
gpg --export | xxd -p
|
||||
echo '${{ secrets.GNUPG_PASSPHRASE }}' | gpg --batch --yes --pinentry-mode loopback --passphrase-fd 0 --armor --detach-sign vojo-${{ steps.vars.outputs.tag }}.tar.gz
|
||||
- name: Upload tagged release
|
||||
uses: softprops/action-gh-release@6cbd405e2c4e67a21c47fa9e383d020e4e28b836 # v2.3.3
|
||||
with:
|
||||
tag_name: ${{ steps.vars.outputs.tag }}
|
||||
files: |
|
||||
vojo-${{ steps.vars.outputs.tag }}.tar.gz
|
||||
vojo-${{ steps.vars.outputs.tag }}.tar.gz.asc
|
||||
|
||||
publish-image:
|
||||
name: Push Docker image to Docker Hub, GHCR
|
||||
needs: deploy-and-tarball
|
||||
env:
|
||||
VERSION: ${{ needs.deploy-and-tarball.outputs.version }}
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
|
||||
- name: Login to Docker Hub #Do not update this action from a outside PR
|
||||
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
- name: Login to the Github Container registry #Do not update this action from a outside PR
|
||||
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: Extract metadata (tags, labels) for Docker, GHCR
|
||||
id: meta
|
||||
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0
|
||||
with:
|
||||
images: |
|
||||
${{ secrets.DOCKER_USERNAME }}/vojo
|
||||
ghcr.io/${{ github.repository }}
|
||||
tags: |
|
||||
type=raw,value=${{ env.VERSION }}
|
||||
type=raw,value=latest
|
||||
- name: Build and push Docker image
|
||||
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
25
.gitignore
vendored
|
|
@ -1,27 +1,4 @@
|
|||
experiment
|
||||
dist
|
||||
node_modules
|
||||
devAssets
|
||||
config.local.json
|
||||
|
||||
electron/dist-electron
|
||||
release
|
||||
|
||||
.DS_Store
|
||||
.idea
|
||||
.vscode/*
|
||||
!.vscode/tasks.json
|
||||
.codex
|
||||
.claude
|
||||
docs/plans
|
||||
docs/design
|
||||
docs/ai/*
|
||||
!docs/ai/README.md
|
||||
!docs/ai/android.md
|
||||
!docs/ai/architecture.md
|
||||
!docs/ai/electron.md
|
||||
!docs/ai/i18n.md
|
||||
!docs/ai/overview.md
|
||||
!docs/ai/server-side.md
|
||||
|
||||
vite.config.*.timestamp-*.mjs
|
||||
devAssets
|
||||
|
|
@ -1,2 +0,0 @@
|
|||
npx tsc -p tsconfig.json --noEmit
|
||||
npx lint-staged
|
||||
|
|
@ -1 +0,0 @@
|
|||
24.13.1
|
||||
2
.npmrc
|
|
@ -1,2 +0,0 @@
|
|||
legacy-peer-deps=true
|
||||
save-exact=true
|
||||
|
|
@ -1,42 +0,0 @@
|
|||
dist
|
||||
node_modules
|
||||
package.json
|
||||
package-lock.json
|
||||
LICENSE
|
||||
README.md
|
||||
|
||||
# Generated by Capacitor / Gradle / AGP — never format these.
|
||||
android/app/build/
|
||||
android/build/
|
||||
android/capacitor-cordova-android-plugins/build/
|
||||
android/app/src/main/assets/public/
|
||||
android/app/src/main/assets/capacitor.config.json
|
||||
android/app/src/main/assets/capacitor.plugins.json
|
||||
android/app/google-services.json
|
||||
|
||||
# Internal docs — hand-formatted markdown. Prettier reflows tables and
|
||||
# fenced code blocks (e.g. YAML inside fences in server-side.md, tables in
|
||||
# architecture.md) in ways that change document structure, not whitespace.
|
||||
# Most paths under docs/ are gitignored anyway via top-level .gitignore.
|
||||
docs/
|
||||
|
||||
# Upstream Cinny GitHub Actions / templates — leave as-is, format drift here
|
||||
# is unrelated to our work.
|
||||
.github/
|
||||
|
||||
# Minified third-party assets.
|
||||
*.min.js
|
||||
|
||||
# Top-level docs / HTML inherited from upstream Cinny — not part of this
|
||||
# infra cleanup's scope. They have minor pre-existing format drift; touching
|
||||
# them would just add review noise.
|
||||
CLAUDE.md
|
||||
CODE_OF_CONDUCT.md
|
||||
CONTRIBUTING.md
|
||||
index.html
|
||||
|
||||
# Upstream-vendored files copied verbatim from external projects (links in
|
||||
# their headers). Keep byte-identical to upstream to make future re-syncs
|
||||
# trivially diffable. Same intent as the per-file ESLint override.
|
||||
src/util/cryptE2ERoomKeys.js
|
||||
src/util/colorMXID.js
|
||||
|
|
@ -1,4 +0,0 @@
|
|||
{
|
||||
"printWidth": 100,
|
||||
"singleQuote": true
|
||||
}
|
||||
3
.vscode/extensions.json
vendored
|
|
@ -1,3 +0,0 @@
|
|||
{
|
||||
"recommendations": ["dbaeumer.vscode-eslint", "esbenp.prettier-vscode"]
|
||||
}
|
||||
5
.vscode/settings.json
vendored
|
|
@ -1,5 +0,0 @@
|
|||
{
|
||||
"editor.formatOnSave": true,
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode",
|
||||
"typescript.tsdk": "node_modules/typescript/lib"
|
||||
}
|
||||
104
.vscode/tasks.json
vendored
|
|
@ -1,104 +0,0 @@
|
|||
{
|
||||
"version": "2.0.0",
|
||||
"tasks": [
|
||||
{
|
||||
"label": "Deploy to vojo.chat",
|
||||
"type": "shell",
|
||||
"command": "npm run build && rsync -avz --delete dist/ vojo-superuser@187.127.77.124:~/vojo/cinny/",
|
||||
"group": "none",
|
||||
"presentation": {
|
||||
"reveal": "always",
|
||||
"panel": "shared",
|
||||
"showReuseMessage": false
|
||||
},
|
||||
"problemMatcher": []
|
||||
},
|
||||
{
|
||||
"label": "Deploy widgets",
|
||||
"type": "shell",
|
||||
"command": "(cd apps/widget-telegram && npm run build && rsync -avz --delete dist/ vojo-superuser@187.127.77.124:~/vojo/widgets/telegram/) & PID1=$!; (cd apps/widget-discord && npm run build && rsync -avz --delete dist/ vojo-superuser@187.127.77.124:~/vojo/widgets/discord/) & PID2=$!; (cd apps/widget-whatsapp && npm run build && rsync -avz --delete dist/ vojo-superuser@187.127.77.124:~/vojo/widgets/whatsapp/) & PID3=$!; FAIL=0; wait $PID1 || FAIL=1; wait $PID2 || FAIL=1; wait $PID3 || FAIL=1; exit $FAIL",
|
||||
"group": "none",
|
||||
"presentation": {
|
||||
"reveal": "always",
|
||||
"panel": "shared",
|
||||
"showReuseMessage": false
|
||||
},
|
||||
"problemMatcher": []
|
||||
},
|
||||
{
|
||||
"label": "Build Android APK",
|
||||
"type": "shell",
|
||||
"command": "npm run build:android:debug",
|
||||
"group": "none",
|
||||
"presentation": {
|
||||
"reveal": "always",
|
||||
"panel": "shared",
|
||||
"showReuseMessage": false
|
||||
},
|
||||
"problemMatcher": []
|
||||
},
|
||||
{
|
||||
"label": "Deploy to Android (ADB)",
|
||||
"type": "shell",
|
||||
"command": "npm run build:android:debug && adb install -r android/app/build/outputs/apk/debug/app-debug.apk",
|
||||
"group": "none",
|
||||
"presentation": {
|
||||
"reveal": "always",
|
||||
"panel": "shared",
|
||||
"showReuseMessage": false
|
||||
},
|
||||
"problemMatcher": []
|
||||
},
|
||||
{
|
||||
"label": "Connect to Android device (ADB)",
|
||||
"type": "shell",
|
||||
"command": "adb connect 192.168.1.204:5555",
|
||||
"group": "none",
|
||||
"presentation": {
|
||||
"reveal": "always",
|
||||
"panel": "shared",
|
||||
"showReuseMessage": false
|
||||
},
|
||||
"problemMatcher": []
|
||||
},
|
||||
{
|
||||
"label": "Start Electron (dev)",
|
||||
"type": "shell",
|
||||
"command": "npm run electron:dev",
|
||||
"group": "none",
|
||||
"presentation": {
|
||||
"reveal": "always",
|
||||
"panel": "shared",
|
||||
"showReuseMessage": false
|
||||
},
|
||||
"problemMatcher": []
|
||||
},
|
||||
{
|
||||
"label": "Build Electron Windows",
|
||||
"type": "shell",
|
||||
"command": "npm run build:electron:win",
|
||||
"group": "none",
|
||||
"presentation": {
|
||||
"reveal": "always",
|
||||
"panel": "shared",
|
||||
"showReuseMessage": false
|
||||
},
|
||||
"problemMatcher": []
|
||||
},
|
||||
{
|
||||
"label": "Deploy Discord bridge",
|
||||
"type": "shell",
|
||||
"command": "docker build -t vojo-mautrix-discord:custom . && docker save vojo-mautrix-discord:custom | gzip | ssh vojo-superuser@187.127.77.124 'gunzip | docker load'",
|
||||
"options": {
|
||||
"cwd": "${workspaceFolder}/../vojo-mautrix-discord"
|
||||
},
|
||||
"group": "none",
|
||||
"presentation": {
|
||||
"reveal": "always",
|
||||
"panel": "shared",
|
||||
"showReuseMessage": false
|
||||
},
|
||||
"problemMatcher": []
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -1,9 +0,0 @@
|
|||
# Directive for AI agents
|
||||
|
||||
**All project context for Vojo lives in [`docs/ai/`](docs/ai/README.md). Read it before making any non-trivial change.**
|
||||
|
||||
**All plans that you create should be always created in docs/plans
|
||||
|
||||
This file exists only as a pointer. Do not add project knowledge here — put it in `docs/ai/`. Same rule for `.cursorrules`, `.windsurfrules`, `AGENTS.md`, `.codex`, home-directory memory, or any other agent-specific context file: if you're tempted to write project knowledge there, write it in `docs/ai/` instead and keep those files as thin pointers.
|
||||
|
||||
Start here: [docs/ai/README.md](docs/ai/README.md).
|
||||
|
|
@ -1,128 +0,0 @@
|
|||
# Contributor Covenant Code of Conduct
|
||||
|
||||
## Our Pledge
|
||||
|
||||
We as members, contributors, and leaders pledge to make participation in our
|
||||
community a harassment-free experience for everyone, regardless of age, body
|
||||
size, visible or invisible disability, ethnicity, sex characteristics, gender
|
||||
identity and expression, level of experience, education, socio-economic status,
|
||||
nationality, personal appearance, race, religion, or sexual identity
|
||||
and orientation.
|
||||
|
||||
We pledge to act and interact in ways that contribute to an open, welcoming,
|
||||
diverse, inclusive, and healthy community.
|
||||
|
||||
## Our Standards
|
||||
|
||||
Examples of behavior that contributes to a positive environment for our
|
||||
community include:
|
||||
|
||||
* Demonstrating empathy and kindness toward other people
|
||||
* Being respectful of differing opinions, viewpoints, and experiences
|
||||
* Giving and gracefully accepting constructive feedback
|
||||
* Accepting responsibility and apologizing to those affected by our mistakes,
|
||||
and learning from the experience
|
||||
* Focusing on what is best not just for us as individuals, but for the
|
||||
overall community
|
||||
|
||||
Examples of unacceptable behavior include:
|
||||
|
||||
* The use of sexualized language or imagery, and sexual attention or
|
||||
advances of any kind
|
||||
* Trolling, insulting or derogatory comments, and personal or political attacks
|
||||
* Public or private harassment
|
||||
* Publishing others' private information, such as a physical or email
|
||||
address, without their explicit permission
|
||||
* Other conduct which could reasonably be considered inappropriate in a
|
||||
professional setting
|
||||
|
||||
## Enforcement Responsibilities
|
||||
|
||||
Community leaders are responsible for clarifying and enforcing our standards of
|
||||
acceptable behavior and will take appropriate and fair corrective action in
|
||||
response to any behavior that they deem inappropriate, threatening, offensive,
|
||||
or harmful.
|
||||
|
||||
Community leaders have the right and responsibility to remove, edit, or reject
|
||||
comments, commits, code, wiki edits, issues, and other contributions that are
|
||||
not aligned to this Code of Conduct, and will communicate reasons for moderation
|
||||
decisions when appropriate.
|
||||
|
||||
## Scope
|
||||
|
||||
This Code of Conduct applies within all community spaces, and also applies when
|
||||
an individual is officially representing the community in public spaces.
|
||||
Examples of representing our community include using an official e-mail address,
|
||||
posting via an official social media account, or acting as an appointed
|
||||
representative at an online or offline event.
|
||||
|
||||
## Enforcement
|
||||
|
||||
Instances of abusive, harassing, or otherwise unacceptable behavior may be
|
||||
reported to the community leaders responsible for enforcement at
|
||||
vojo@vojo.chat.
|
||||
All complaints will be reviewed and investigated promptly and fairly.
|
||||
|
||||
All community leaders are obligated to respect the privacy and security of the
|
||||
reporter of any incident.
|
||||
|
||||
## Enforcement Guidelines
|
||||
|
||||
Community leaders will follow these Community Impact Guidelines in determining
|
||||
the consequences for any action they deem in violation of this Code of Conduct:
|
||||
|
||||
### 1. Correction
|
||||
|
||||
**Community Impact**: Use of inappropriate language or other behavior deemed
|
||||
unprofessional or unwelcome in the community.
|
||||
|
||||
**Consequence**: A private, written warning from community leaders, providing
|
||||
clarity around the nature of the violation and an explanation of why the
|
||||
behavior was inappropriate. A public apology may be requested.
|
||||
|
||||
### 2. Warning
|
||||
|
||||
**Community Impact**: A violation through a single incident or series
|
||||
of actions.
|
||||
|
||||
**Consequence**: A warning with consequences for continued behavior. No
|
||||
interaction with the people involved, including unsolicited interaction with
|
||||
those enforcing the Code of Conduct, for a specified period of time. This
|
||||
includes avoiding interactions in community spaces as well as external channels
|
||||
like social media. Violating these terms may lead to a temporary or
|
||||
permanent ban.
|
||||
|
||||
### 3. Temporary Ban
|
||||
|
||||
**Community Impact**: A serious violation of community standards, including
|
||||
sustained inappropriate behavior.
|
||||
|
||||
**Consequence**: A temporary ban from any sort of interaction or public
|
||||
communication with the community for a specified period of time. No public or
|
||||
private interaction with the people involved, including unsolicited interaction
|
||||
with those enforcing the Code of Conduct, is allowed during this period.
|
||||
Violating these terms may lead to a permanent ban.
|
||||
|
||||
### 4. Permanent Ban
|
||||
|
||||
**Community Impact**: Demonstrating a pattern of violation of community
|
||||
standards, including sustained inappropriate behavior, harassment of an
|
||||
individual, or aggression toward or disparagement of classes of individuals.
|
||||
|
||||
**Consequence**: A permanent ban from any sort of public interaction within
|
||||
the community.
|
||||
|
||||
## Attribution
|
||||
|
||||
This Code of Conduct is adapted from the [Contributor Covenant][homepage],
|
||||
version 2.0, available at
|
||||
https://www.contributor-covenant.org/version/2/0/code_of_conduct.html.
|
||||
|
||||
Community Impact Guidelines were inspired by [Mozilla's code of conduct
|
||||
enforcement ladder](https://github.com/mozilla/diversity).
|
||||
|
||||
[homepage]: https://www.contributor-covenant.org
|
||||
|
||||
For answers to common questions about this code of conduct, see the FAQ at
|
||||
https://www.contributor-covenant.org/faq. Translations are available at
|
||||
https://www.contributor-covenant.org/translations.
|
||||
140
CONTRIBUTING.md
|
|
@ -1,35 +1,139 @@
|
|||
# Contributing to Vojo
|
||||
<!-- omit in toc -->
|
||||
# Contributing to Cinny
|
||||
|
||||
First off, thanks for taking the time to contribute!
|
||||
First off, thanks for taking the time to contribute! ❤️
|
||||
|
||||
All types of contributions are encouraged and valued. Please make sure to read the relevant section before making your contribution.
|
||||
All types of contributions are encouraged and valued. See the [Table of Contents](#table-of-contents) for different ways to help and details about how this project handles them. Please make sure to read the relevant section before making your contribution. It will make it a lot easier for us maintainers and smooth out the experience for all involved. The community looks forward to your contributions. 🎉
|
||||
|
||||
> And if you like the project, but just don't have time to contribute, that's fine. There are other easy ways to support the project and show your appreciation:
|
||||
> And if you like the project, but just don't have time to contribute, that's fine. There are other easy ways to support the project and show your appreciation, which we would also be very happy about:
|
||||
> - Star the project
|
||||
> - Tweet about it (tag @cinnyapp)
|
||||
> - Refer this project in your project's readme
|
||||
> - Mention the project at local meetups and tell your friends/colleagues
|
||||
> - [Donate to us](https://liberapay.com/ajbura/donate)
|
||||
|
||||
## Bug reports
|
||||
<!-- omit in toc -->
|
||||
## Table of Contents
|
||||
|
||||
Bug reports and feature suggestions must use descriptive and concise titles and be submitted to GitHub Issues. Please use the search function to make sure that you are not submitting duplicates, and that a similar report or request has not already been resolved or rejected.
|
||||
- [I Have a Question](#i-have-a-question)
|
||||
- [I Want To Contribute](#i-want-to-contribute)
|
||||
- [Reporting Bugs](#reporting-bugs)
|
||||
- [Suggesting Enhancements](#suggesting-enhancements)
|
||||
- [Your First Code Contribution](#your-first-code-contribution)
|
||||
- [Styleguides](#styleguides)
|
||||
- [Commit Messages](#commit-messages)
|
||||
- [Coding conventions](#coding-conventions)
|
||||
|
||||
## Pull requests
|
||||
## I Have a Question
|
||||
|
||||
**NOTE: If you want to add new features, please discuss with maintainers before coding or opening a pull request.** This is to ensure that we are on the same track.
|
||||
Before you ask a question, it is best to search for existing [Issues](https://github.com/ajbura/cinny/issues) that might help you. In case you have found a suitable issue and still need clarification, you can write your question in this issue.
|
||||
|
||||
**Please use clean, concise titles for your pull requests.** We use commit squashing, so the final commit in the dev branch will carry the title of the pull request. For easier sorting in changelog, start your pull request titles using one of the verbs "Add", "Change", "Remove", or "Fix" (present tense).
|
||||
If you then still feel the need to ask a question and need clarification, we recommend the following:
|
||||
|
||||
Example:
|
||||
- Ask in our [Matrix room](https://matrix.to/#/#cinny:matrix.org) or [IRC channel](https://web.libera.chat/?channel=#cinny).
|
||||
- If no one respond in our channel, please open an [Issue](https://github.com/ajbura/cinny/issues/new).
|
||||
- Provide as much context as you can about what you're running into.
|
||||
- Provide project and platform versions (nodejs, npm, etc), depending on what seems relevant.
|
||||
|
||||
|Not ideal|Better|
|
||||
|---|----|
|
||||
|Fixed markAllAsRead in RoomTimeline|Fix read marker when paginating room timeline|
|
||||
We will then take care of the issue as soon as possible.
|
||||
|
||||
It is not always possible to phrase every change in such a manner, but it is desired.
|
||||
|
||||
**The smaller the set of changes in the pull request is, the quicker it can be reviewed and merged.** Splitting tasks into multiple smaller pull requests is often preferable.
|
||||
## I Want To Contribute
|
||||
|
||||
Also, we use [ESLint](https://eslint.org/) for clean and stylistically consistent code syntax, so make sure your pull request follows it.
|
||||
> ### Legal Notice <!-- omit in toc -->
|
||||
> When contributing to this project, you must agree that you have authored 100% of the content, that you have the necessary rights to the content and that the content you contribute may be provided under the project license.
|
||||
|
||||
## Helpful links
|
||||
- [Matrix JavaScript SDK documentation](https://matrix-org.github.io/matrix-js-sdk/index.html)
|
||||
### Reporting Bugs
|
||||
|
||||
<!-- omit in toc -->
|
||||
#### Before Submitting a Bug Report
|
||||
|
||||
A good bug report shouldn't leave others needing to chase you up for more information. Therefore, we ask you to investigate carefully, collect information and describe the issue in detail in your report. Please complete the following steps in advance to help us fix any potential bug as fast as possible.
|
||||
|
||||
- Make sure that you are using the latest version.
|
||||
- Determine if your bug is really a bug and not an error on your side. If you are looking for support, you might want to check [this section](#i-have-a-question)).
|
||||
- To see if other users have experienced (and potentially already solved) the same issue you are having, check if there is not already a bug report existing for your bug or error in the [bug tracker](https://github.com/ajbura/cinny/issues?q=label%3Abug).
|
||||
- Collect information about the bug:
|
||||
- OS, Platform and Version (Windows, Linux, macOS, x86, ARM)
|
||||
- Possibly your input and the output
|
||||
- Can you reliably reproduce the issue?
|
||||
|
||||
<!-- omit in toc -->
|
||||
#### How Do I Submit a Good Bug Report?
|
||||
|
||||
> You must never report security related issues, vulnerabilities or bugs to the issue tracker, or elsewhere in public. Instead sensitive bugs must be sent by email to <cinnyapp@gmail.com>.
|
||||
|
||||
We use GitHub issues to track bugs and errors. If you run into an issue with the project:
|
||||
|
||||
- Open an [Issue](https://github.com/ajbura/cinny/issues/new). (Since we can't be sure at this point whether it is a bug or not, we ask you not to talk about a bug yet and not to label the issue.)
|
||||
- Explain the behavior you would expect and the actual behavior.
|
||||
- Please provide as much context as possible and describe the *reproduction steps* that someone else can follow to recreate the issue on their own. For good bug reports you should isolate the problem and create a reduced test case.
|
||||
- Provide the information you collected in the previous section.
|
||||
|
||||
Once it's filed:
|
||||
|
||||
- The project team will label the issue accordingly.
|
||||
- A team member will try to reproduce the issue with your provided steps. If there are no reproduction steps or no obvious way to reproduce the issue, the team will ask you for those steps and mark the issue as `needs-repro`. Bugs with the `needs-repro` tag will not be addressed until they are reproduced.
|
||||
- If the team is able to reproduce the issue, it will be marked `needs-fix`, as well as possibly other tags (such as `critical`), and the issue will be left to be [implemented by someone](#your-first-code-contribution).
|
||||
|
||||
|
||||
### Suggesting Enhancements
|
||||
|
||||
This section guides you through submitting an enhancement suggestion for Cinny, **including completely new features and minor improvements to existing functionality**. Following these guidelines will help maintainers and the community to understand your suggestion and find related suggestions.
|
||||
|
||||
<!-- omit in toc -->
|
||||
#### Before Submitting an Enhancement
|
||||
|
||||
- Make sure that you are using the latest version.
|
||||
- Perform a [search](https://github.com/ajbura/cinny/issues) to see if the enhancement has already been suggested. If it has, add a comment to the existing issue instead of opening a new one.
|
||||
- Find out whether your idea fits with the scope and aims of the project. It's up to you to make a strong case to convince the project's developers of the merits of this feature. Keep in mind that we want features that will be useful to the majority of our users and not just a small subset.
|
||||
|
||||
<!-- omit in toc -->
|
||||
#### How Do I Submit a Good Enhancement Suggestion?
|
||||
|
||||
Enhancement suggestions are tracked as [GitHub issues](https://github.com/ajbura/cinny/issues).
|
||||
|
||||
- Use a **clear and descriptive title** for the issue to identify the suggestion.
|
||||
- Provide a **step-by-step description of the suggested enhancement** in as many details as possible.
|
||||
- **Describe the current behavior** and **explain which behavior you expected to see instead** and why. At this point you can also tell which alternatives do not work for you.
|
||||
- You may want to **include screenshots and animated GIFs** which help you demonstrate the steps or point out the part which the suggestion is related to. You can use [this tool](https://www.cockos.com/licecap/) to record GIFs on macOS and Windows, and [this tool](https://github.com/colinkeenan/silentcast) on Linux.
|
||||
- **Explain why this enhancement would be useful** to most Cinny users. You may also want to point out the other projects that solved it better and which could serve as inspiration.
|
||||
|
||||
### Your First Code Contribution
|
||||
Please send a [GitHub Pull Request to cinny](https://github.com/ajbura/cinny/pull/new/master) with a clear list of what you've done (read more about [pull requests](http://help.github.com/pull-requests/)).
|
||||
|
||||
When proposing a PR:
|
||||
|
||||
- Describe what problem it solves, what side effects come with it.
|
||||
- Adding some screenshots will help.
|
||||
- Add some documentation if relevant.
|
||||
- Add some comments around blocks/functions if relevant.
|
||||
|
||||
Some reasons why a PR could be refused:
|
||||
|
||||
- PR is not meeting one of the previous points.
|
||||
- PR is not meeting project goals.
|
||||
- PR is conflicting with another PR, and the latter is being preferred.
|
||||
- PR slows down Cinny, or it obviously does too many
|
||||
computations for the task being accomplished. It needs to be optimized.
|
||||
- PR is using copy-n-paste-programming. It needs to be factorized.
|
||||
- PR contains commented code: remove it.
|
||||
- PR adds new features or changes the behavior of Cinny without
|
||||
having be approved by the current project owners first.
|
||||
- PR is too big and needs to be splitted in many smaller ones.
|
||||
- PR contains unnecessary "space/indentations fixes".
|
||||
|
||||
If a PR stays in a stale/WIP/POC state for too long, it may be closed
|
||||
at any time.
|
||||
|
||||
|
||||
## Styleguides
|
||||
### Commit Messages
|
||||
Always write a clear log message for your commits. One-line messages are fine for small changes, but bigger changes should look like this:
|
||||
|
||||
$ git commit -m "A brief summary of the commit
|
||||
>
|
||||
> A paragraph describing what changed and its impact."
|
||||
|
||||
### Coding conventions
|
||||
We use [ESLint](https://eslint.org/) for clean and stylistically consistent code syntax.
|
||||
16
Dockerfile
|
|
@ -1,20 +1,20 @@
|
|||
## Builder
|
||||
FROM node:24.13.1-alpine AS builder
|
||||
FROM node:14-alpine as builder
|
||||
|
||||
WORKDIR /src
|
||||
|
||||
COPY .npmrc package.json package-lock.json /src/
|
||||
RUN npm ci
|
||||
COPY . /src/
|
||||
ENV NODE_OPTIONS=--max_old_space_size=4096
|
||||
RUN npm run build
|
||||
COPY . /src
|
||||
RUN npm install \
|
||||
&& npm run build
|
||||
|
||||
|
||||
## App
|
||||
FROM nginx:1.29.5-alpine
|
||||
FROM nginx:alpine
|
||||
|
||||
COPY --from=builder /src/dist /app
|
||||
COPY --from=builder /src/docker-nginx.conf /etc/nginx/conf.d/default.conf
|
||||
|
||||
# Insert wasm type into Nginx mime.types file so they load correctly.
|
||||
RUN sed -i '3i\ \ \ \ application/wasm wasm\;' /etc/nginx/mime.types
|
||||
|
||||
RUN rm -rf /usr/share/nginx/html \
|
||||
&& ln -s /app /usr/share/nginx/html
|
||||
|
|
|
|||
682
LICENSE
|
|
@ -1,661 +1,21 @@
|
|||
GNU AFFERO GENERAL PUBLIC LICENSE
|
||||
Version 3, 19 November 2007
|
||||
|
||||
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
||||
Everyone is permitted to copy and distribute verbatim copies
|
||||
of this license document, but changing it is not allowed.
|
||||
|
||||
Preamble
|
||||
|
||||
The GNU Affero General Public License is a free, copyleft license for
|
||||
software and other kinds of works, specifically designed to ensure
|
||||
cooperation with the community in the case of network server software.
|
||||
|
||||
The licenses for most software and other practical works are designed
|
||||
to take away your freedom to share and change the works. By contrast,
|
||||
our General Public Licenses are intended to guarantee your freedom to
|
||||
share and change all versions of a program--to make sure it remains free
|
||||
software for all its users.
|
||||
|
||||
When we speak of free software, we are referring to freedom, not
|
||||
price. Our General Public Licenses are designed to make sure that you
|
||||
have the freedom to distribute copies of free software (and charge for
|
||||
them if you wish), that you receive source code or can get it if you
|
||||
want it, that you can change the software or use pieces of it in new
|
||||
free programs, and that you know you can do these things.
|
||||
|
||||
Developers that use our General Public Licenses protect your rights
|
||||
with two steps: (1) assert copyright on the software, and (2) offer
|
||||
you this License which gives you legal permission to copy, distribute
|
||||
and/or modify the software.
|
||||
|
||||
A secondary benefit of defending all users' freedom is that
|
||||
improvements made in alternate versions of the program, if they
|
||||
receive widespread use, become available for other developers to
|
||||
incorporate. Many developers of free software are heartened and
|
||||
encouraged by the resulting cooperation. However, in the case of
|
||||
software used on network servers, this result may fail to come about.
|
||||
The GNU General Public License permits making a modified version and
|
||||
letting the public access it on a server without ever releasing its
|
||||
source code to the public.
|
||||
|
||||
The GNU Affero General Public License is designed specifically to
|
||||
ensure that, in such cases, the modified source code becomes available
|
||||
to the community. It requires the operator of a network server to
|
||||
provide the source code of the modified version running there to the
|
||||
users of that server. Therefore, public use of a modified version, on
|
||||
a publicly accessible server, gives the public access to the source
|
||||
code of the modified version.
|
||||
|
||||
An older license, called the Affero General Public License and
|
||||
published by Affero, was designed to accomplish similar goals. This is
|
||||
a different license, not a version of the Affero GPL, but Affero has
|
||||
released a new version of the Affero GPL which permits relicensing under
|
||||
this license.
|
||||
|
||||
The precise terms and conditions for copying, distribution and
|
||||
modification follow.
|
||||
|
||||
TERMS AND CONDITIONS
|
||||
|
||||
0. Definitions.
|
||||
|
||||
"This License" refers to version 3 of the GNU Affero General Public License.
|
||||
|
||||
"Copyright" also means copyright-like laws that apply to other kinds of
|
||||
works, such as semiconductor masks.
|
||||
|
||||
"The Program" refers to any copyrightable work licensed under this
|
||||
License. Each licensee is addressed as "you". "Licensees" and
|
||||
"recipients" may be individuals or organizations.
|
||||
|
||||
To "modify" a work means to copy from or adapt all or part of the work
|
||||
in a fashion requiring copyright permission, other than the making of an
|
||||
exact copy. The resulting work is called a "modified version" of the
|
||||
earlier work or a work "based on" the earlier work.
|
||||
|
||||
A "covered work" means either the unmodified Program or a work based
|
||||
on the Program.
|
||||
|
||||
To "propagate" a work means to do anything with it that, without
|
||||
permission, would make you directly or secondarily liable for
|
||||
infringement under applicable copyright law, except executing it on a
|
||||
computer or modifying a private copy. Propagation includes copying,
|
||||
distribution (with or without modification), making available to the
|
||||
public, and in some countries other activities as well.
|
||||
|
||||
To "convey" a work means any kind of propagation that enables other
|
||||
parties to make or receive copies. Mere interaction with a user through
|
||||
a computer network, with no transfer of a copy, is not conveying.
|
||||
|
||||
An interactive user interface displays "Appropriate Legal Notices"
|
||||
to the extent that it includes a convenient and prominently visible
|
||||
feature that (1) displays an appropriate copyright notice, and (2)
|
||||
tells the user that there is no warranty for the work (except to the
|
||||
extent that warranties are provided), that licensees may convey the
|
||||
work under this License, and how to view a copy of this License. If
|
||||
the interface presents a list of user commands or options, such as a
|
||||
menu, a prominent item in the list meets this criterion.
|
||||
|
||||
1. Source Code.
|
||||
|
||||
The "source code" for a work means the preferred form of the work
|
||||
for making modifications to it. "Object code" means any non-source
|
||||
form of a work.
|
||||
|
||||
A "Standard Interface" means an interface that either is an official
|
||||
standard defined by a recognized standards body, or, in the case of
|
||||
interfaces specified for a particular programming language, one that
|
||||
is widely used among developers working in that language.
|
||||
|
||||
The "System Libraries" of an executable work include anything, other
|
||||
than the work as a whole, that (a) is included in the normal form of
|
||||
packaging a Major Component, but which is not part of that Major
|
||||
Component, and (b) serves only to enable use of the work with that
|
||||
Major Component, or to implement a Standard Interface for which an
|
||||
implementation is available to the public in source code form. A
|
||||
"Major Component", in this context, means a major essential component
|
||||
(kernel, window system, and so on) of the specific operating system
|
||||
(if any) on which the executable work runs, or a compiler used to
|
||||
produce the work, or an object code interpreter used to run it.
|
||||
|
||||
The "Corresponding Source" for a work in object code form means all
|
||||
the source code needed to generate, install, and (for an executable
|
||||
work) run the object code and to modify the work, including scripts to
|
||||
control those activities. However, it does not include the work's
|
||||
System Libraries, or general-purpose tools or generally available free
|
||||
programs which are used unmodified in performing those activities but
|
||||
which are not part of the work. For example, Corresponding Source
|
||||
includes interface definition files associated with source files for
|
||||
the work, and the source code for shared libraries and dynamically
|
||||
linked subprograms that the work is specifically designed to require,
|
||||
such as by intimate data communication or control flow between those
|
||||
subprograms and other parts of the work.
|
||||
|
||||
The Corresponding Source need not include anything that users
|
||||
can regenerate automatically from other parts of the Corresponding
|
||||
Source.
|
||||
|
||||
The Corresponding Source for a work in source code form is that
|
||||
same work.
|
||||
|
||||
2. Basic Permissions.
|
||||
|
||||
All rights granted under this License are granted for the term of
|
||||
copyright on the Program, and are irrevocable provided the stated
|
||||
conditions are met. This License explicitly affirms your unlimited
|
||||
permission to run the unmodified Program. The output from running a
|
||||
covered work is covered by this License only if the output, given its
|
||||
content, constitutes a covered work. This License acknowledges your
|
||||
rights of fair use or other equivalent, as provided by copyright law.
|
||||
|
||||
You may make, run and propagate covered works that you do not
|
||||
convey, without conditions so long as your license otherwise remains
|
||||
in force. You may convey covered works to others for the sole purpose
|
||||
of having them make modifications exclusively for you, or provide you
|
||||
with facilities for running those works, provided that you comply with
|
||||
the terms of this License in conveying all material for which you do
|
||||
not control copyright. Those thus making or running the covered works
|
||||
for you must do so exclusively on your behalf, under your direction
|
||||
and control, on terms that prohibit them from making any copies of
|
||||
your copyrighted material outside their relationship with you.
|
||||
|
||||
Conveying under any other circumstances is permitted solely under
|
||||
the conditions stated below. Sublicensing is not allowed; section 10
|
||||
makes it unnecessary.
|
||||
|
||||
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
|
||||
|
||||
No covered work shall be deemed part of an effective technological
|
||||
measure under any applicable law fulfilling obligations under article
|
||||
11 of the WIPO copyright treaty adopted on 20 December 1996, or
|
||||
similar laws prohibiting or restricting circumvention of such
|
||||
measures.
|
||||
|
||||
When you convey a covered work, you waive any legal power to forbid
|
||||
circumvention of technological measures to the extent such circumvention
|
||||
is effected by exercising rights under this License with respect to
|
||||
the covered work, and you disclaim any intention to limit operation or
|
||||
modification of the work as a means of enforcing, against the work's
|
||||
users, your or third parties' legal rights to forbid circumvention of
|
||||
technological measures.
|
||||
|
||||
4. Conveying Verbatim Copies.
|
||||
|
||||
You may convey verbatim copies of the Program's source code as you
|
||||
receive it, in any medium, provided that you conspicuously and
|
||||
appropriately publish on each copy an appropriate copyright notice;
|
||||
keep intact all notices stating that this License and any
|
||||
non-permissive terms added in accord with section 7 apply to the code;
|
||||
keep intact all notices of the absence of any warranty; and give all
|
||||
recipients a copy of this License along with the Program.
|
||||
|
||||
You may charge any price or no price for each copy that you convey,
|
||||
and you may offer support or warranty protection for a fee.
|
||||
|
||||
5. Conveying Modified Source Versions.
|
||||
|
||||
You may convey a work based on the Program, or the modifications to
|
||||
produce it from the Program, in the form of source code under the
|
||||
terms of section 4, provided that you also meet all of these conditions:
|
||||
|
||||
a) The work must carry prominent notices stating that you modified
|
||||
it, and giving a relevant date.
|
||||
|
||||
b) The work must carry prominent notices stating that it is
|
||||
released under this License and any conditions added under section
|
||||
7. This requirement modifies the requirement in section 4 to
|
||||
"keep intact all notices".
|
||||
|
||||
c) You must license the entire work, as a whole, under this
|
||||
License to anyone who comes into possession of a copy. This
|
||||
License will therefore apply, along with any applicable section 7
|
||||
additional terms, to the whole of the work, and all its parts,
|
||||
regardless of how they are packaged. This License gives no
|
||||
permission to license the work in any other way, but it does not
|
||||
invalidate such permission if you have separately received it.
|
||||
|
||||
d) If the work has interactive user interfaces, each must display
|
||||
Appropriate Legal Notices; however, if the Program has interactive
|
||||
interfaces that do not display Appropriate Legal Notices, your
|
||||
work need not make them do so.
|
||||
|
||||
A compilation of a covered work with other separate and independent
|
||||
works, which are not by their nature extensions of the covered work,
|
||||
and which are not combined with it such as to form a larger program,
|
||||
in or on a volume of a storage or distribution medium, is called an
|
||||
"aggregate" if the compilation and its resulting copyright are not
|
||||
used to limit the access or legal rights of the compilation's users
|
||||
beyond what the individual works permit. Inclusion of a covered work
|
||||
in an aggregate does not cause this License to apply to the other
|
||||
parts of the aggregate.
|
||||
|
||||
6. Conveying Non-Source Forms.
|
||||
|
||||
You may convey a covered work in object code form under the terms
|
||||
of sections 4 and 5, provided that you also convey the
|
||||
machine-readable Corresponding Source under the terms of this License,
|
||||
in one of these ways:
|
||||
|
||||
a) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by the
|
||||
Corresponding Source fixed on a durable physical medium
|
||||
customarily used for software interchange.
|
||||
|
||||
b) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by a
|
||||
written offer, valid for at least three years and valid for as
|
||||
long as you offer spare parts or customer support for that product
|
||||
model, to give anyone who possesses the object code either (1) a
|
||||
copy of the Corresponding Source for all the software in the
|
||||
product that is covered by this License, on a durable physical
|
||||
medium customarily used for software interchange, for a price no
|
||||
more than your reasonable cost of physically performing this
|
||||
conveying of source, or (2) access to copy the
|
||||
Corresponding Source from a network server at no charge.
|
||||
|
||||
c) Convey individual copies of the object code with a copy of the
|
||||
written offer to provide the Corresponding Source. This
|
||||
alternative is allowed only occasionally and noncommercially, and
|
||||
only if you received the object code with such an offer, in accord
|
||||
with subsection 6b.
|
||||
|
||||
d) Convey the object code by offering access from a designated
|
||||
place (gratis or for a charge), and offer equivalent access to the
|
||||
Corresponding Source in the same way through the same place at no
|
||||
further charge. You need not require recipients to copy the
|
||||
Corresponding Source along with the object code. If the place to
|
||||
copy the object code is a network server, the Corresponding Source
|
||||
may be on a different server (operated by you or a third party)
|
||||
that supports equivalent copying facilities, provided you maintain
|
||||
clear directions next to the object code saying where to find the
|
||||
Corresponding Source. Regardless of what server hosts the
|
||||
Corresponding Source, you remain obligated to ensure that it is
|
||||
available for as long as needed to satisfy these requirements.
|
||||
|
||||
e) Convey the object code using peer-to-peer transmission, provided
|
||||
you inform other peers where the object code and Corresponding
|
||||
Source of the work are being offered to the general public at no
|
||||
charge under subsection 6d.
|
||||
|
||||
A separable portion of the object code, whose source code is excluded
|
||||
from the Corresponding Source as a System Library, need not be
|
||||
included in conveying the object code work.
|
||||
|
||||
A "User Product" is either (1) a "consumer product", which means any
|
||||
tangible personal property which is normally used for personal, family,
|
||||
or household purposes, or (2) anything designed or sold for incorporation
|
||||
into a dwelling. In determining whether a product is a consumer product,
|
||||
doubtful cases shall be resolved in favor of coverage. For a particular
|
||||
product received by a particular user, "normally used" refers to a
|
||||
typical or common use of that class of product, regardless of the status
|
||||
of the particular user or of the way in which the particular user
|
||||
actually uses, or expects or is expected to use, the product. A product
|
||||
is a consumer product regardless of whether the product has substantial
|
||||
commercial, industrial or non-consumer uses, unless such uses represent
|
||||
the only significant mode of use of the product.
|
||||
|
||||
"Installation Information" for a User Product means any methods,
|
||||
procedures, authorization keys, or other information required to install
|
||||
and execute modified versions of a covered work in that User Product from
|
||||
a modified version of its Corresponding Source. The information must
|
||||
suffice to ensure that the continued functioning of the modified object
|
||||
code is in no case prevented or interfered with solely because
|
||||
modification has been made.
|
||||
|
||||
If you convey an object code work under this section in, or with, or
|
||||
specifically for use in, a User Product, and the conveying occurs as
|
||||
part of a transaction in which the right of possession and use of the
|
||||
User Product is transferred to the recipient in perpetuity or for a
|
||||
fixed term (regardless of how the transaction is characterized), the
|
||||
Corresponding Source conveyed under this section must be accompanied
|
||||
by the Installation Information. But this requirement does not apply
|
||||
if neither you nor any third party retains the ability to install
|
||||
modified object code on the User Product (for example, the work has
|
||||
been installed in ROM).
|
||||
|
||||
The requirement to provide Installation Information does not include a
|
||||
requirement to continue to provide support service, warranty, or updates
|
||||
for a work that has been modified or installed by the recipient, or for
|
||||
the User Product in which it has been modified or installed. Access to a
|
||||
network may be denied when the modification itself materially and
|
||||
adversely affects the operation of the network or violates the rules and
|
||||
protocols for communication across the network.
|
||||
|
||||
Corresponding Source conveyed, and Installation Information provided,
|
||||
in accord with this section must be in a format that is publicly
|
||||
documented (and with an implementation available to the public in
|
||||
source code form), and must require no special password or key for
|
||||
unpacking, reading or copying.
|
||||
|
||||
7. Additional Terms.
|
||||
|
||||
"Additional permissions" are terms that supplement the terms of this
|
||||
License by making exceptions from one or more of its conditions.
|
||||
Additional permissions that are applicable to the entire Program shall
|
||||
be treated as though they were included in this License, to the extent
|
||||
that they are valid under applicable law. If additional permissions
|
||||
apply only to part of the Program, that part may be used separately
|
||||
under those permissions, but the entire Program remains governed by
|
||||
this License without regard to the additional permissions.
|
||||
|
||||
When you convey a copy of a covered work, you may at your option
|
||||
remove any additional permissions from that copy, or from any part of
|
||||
it. (Additional permissions may be written to require their own
|
||||
removal in certain cases when you modify the work.) You may place
|
||||
additional permissions on material, added by you to a covered work,
|
||||
for which you have or can give appropriate copyright permission.
|
||||
|
||||
Notwithstanding any other provision of this License, for material you
|
||||
add to a covered work, you may (if authorized by the copyright holders of
|
||||
that material) supplement the terms of this License with terms:
|
||||
|
||||
a) Disclaiming warranty or limiting liability differently from the
|
||||
terms of sections 15 and 16 of this License; or
|
||||
|
||||
b) Requiring preservation of specified reasonable legal notices or
|
||||
author attributions in that material or in the Appropriate Legal
|
||||
Notices displayed by works containing it; or
|
||||
|
||||
c) Prohibiting misrepresentation of the origin of that material, or
|
||||
requiring that modified versions of such material be marked in
|
||||
reasonable ways as different from the original version; or
|
||||
|
||||
d) Limiting the use for publicity purposes of names of licensors or
|
||||
authors of the material; or
|
||||
|
||||
e) Declining to grant rights under trademark law for use of some
|
||||
trade names, trademarks, or service marks; or
|
||||
|
||||
f) Requiring indemnification of licensors and authors of that
|
||||
material by anyone who conveys the material (or modified versions of
|
||||
it) with contractual assumptions of liability to the recipient, for
|
||||
any liability that these contractual assumptions directly impose on
|
||||
those licensors and authors.
|
||||
|
||||
All other non-permissive additional terms are considered "further
|
||||
restrictions" within the meaning of section 10. If the Program as you
|
||||
received it, or any part of it, contains a notice stating that it is
|
||||
governed by this License along with a term that is a further
|
||||
restriction, you may remove that term. If a license document contains
|
||||
a further restriction but permits relicensing or conveying under this
|
||||
License, you may add to a covered work material governed by the terms
|
||||
of that license document, provided that the further restriction does
|
||||
not survive such relicensing or conveying.
|
||||
|
||||
If you add terms to a covered work in accord with this section, you
|
||||
must place, in the relevant source files, a statement of the
|
||||
additional terms that apply to those files, or a notice indicating
|
||||
where to find the applicable terms.
|
||||
|
||||
Additional terms, permissive or non-permissive, may be stated in the
|
||||
form of a separately written license, or stated as exceptions;
|
||||
the above requirements apply either way.
|
||||
|
||||
8. Termination.
|
||||
|
||||
You may not propagate or modify a covered work except as expressly
|
||||
provided under this License. Any attempt otherwise to propagate or
|
||||
modify it is void, and will automatically terminate your rights under
|
||||
this License (including any patent licenses granted under the third
|
||||
paragraph of section 11).
|
||||
|
||||
However, if you cease all violation of this License, then your
|
||||
license from a particular copyright holder is reinstated (a)
|
||||
provisionally, unless and until the copyright holder explicitly and
|
||||
finally terminates your license, and (b) permanently, if the copyright
|
||||
holder fails to notify you of the violation by some reasonable means
|
||||
prior to 60 days after the cessation.
|
||||
|
||||
Moreover, your license from a particular copyright holder is
|
||||
reinstated permanently if the copyright holder notifies you of the
|
||||
violation by some reasonable means, this is the first time you have
|
||||
received notice of violation of this License (for any work) from that
|
||||
copyright holder, and you cure the violation prior to 30 days after
|
||||
your receipt of the notice.
|
||||
|
||||
Termination of your rights under this section does not terminate the
|
||||
licenses of parties who have received copies or rights from you under
|
||||
this License. If your rights have been terminated and not permanently
|
||||
reinstated, you do not qualify to receive new licenses for the same
|
||||
material under section 10.
|
||||
|
||||
9. Acceptance Not Required for Having Copies.
|
||||
|
||||
You are not required to accept this License in order to receive or
|
||||
run a copy of the Program. Ancillary propagation of a covered work
|
||||
occurring solely as a consequence of using peer-to-peer transmission
|
||||
to receive a copy likewise does not require acceptance. However,
|
||||
nothing other than this License grants you permission to propagate or
|
||||
modify any covered work. These actions infringe copyright if you do
|
||||
not accept this License. Therefore, by modifying or propagating a
|
||||
covered work, you indicate your acceptance of this License to do so.
|
||||
|
||||
10. Automatic Licensing of Downstream Recipients.
|
||||
|
||||
Each time you convey a covered work, the recipient automatically
|
||||
receives a license from the original licensors, to run, modify and
|
||||
propagate that work, subject to this License. You are not responsible
|
||||
for enforcing compliance by third parties with this License.
|
||||
|
||||
An "entity transaction" is a transaction transferring control of an
|
||||
organization, or substantially all assets of one, or subdividing an
|
||||
organization, or merging organizations. If propagation of a covered
|
||||
work results from an entity transaction, each party to that
|
||||
transaction who receives a copy of the work also receives whatever
|
||||
licenses to the work the party's predecessor in interest had or could
|
||||
give under the previous paragraph, plus a right to possession of the
|
||||
Corresponding Source of the work from the predecessor in interest, if
|
||||
the predecessor has it or can get it with reasonable efforts.
|
||||
|
||||
You may not impose any further restrictions on the exercise of the
|
||||
rights granted or affirmed under this License. For example, you may
|
||||
not impose a license fee, royalty, or other charge for exercise of
|
||||
rights granted under this License, and you may not initiate litigation
|
||||
(including a cross-claim or counterclaim in a lawsuit) alleging that
|
||||
any patent claim is infringed by making, using, selling, offering for
|
||||
sale, or importing the Program or any portion of it.
|
||||
|
||||
11. Patents.
|
||||
|
||||
A "contributor" is a copyright holder who authorizes use under this
|
||||
License of the Program or a work on which the Program is based. The
|
||||
work thus licensed is called the contributor's "contributor version".
|
||||
|
||||
A contributor's "essential patent claims" are all patent claims
|
||||
owned or controlled by the contributor, whether already acquired or
|
||||
hereafter acquired, that would be infringed by some manner, permitted
|
||||
by this License, of making, using, or selling its contributor version,
|
||||
but do not include claims that would be infringed only as a
|
||||
consequence of further modification of the contributor version. For
|
||||
purposes of this definition, "control" includes the right to grant
|
||||
patent sublicenses in a manner consistent with the requirements of
|
||||
this License.
|
||||
|
||||
Each contributor grants you a non-exclusive, worldwide, royalty-free
|
||||
patent license under the contributor's essential patent claims, to
|
||||
make, use, sell, offer for sale, import and otherwise run, modify and
|
||||
propagate the contents of its contributor version.
|
||||
|
||||
In the following three paragraphs, a "patent license" is any express
|
||||
agreement or commitment, however denominated, not to enforce a patent
|
||||
(such as an express permission to practice a patent or covenant not to
|
||||
sue for patent infringement). To "grant" such a patent license to a
|
||||
party means to make such an agreement or commitment not to enforce a
|
||||
patent against the party.
|
||||
|
||||
If you convey a covered work, knowingly relying on a patent license,
|
||||
and the Corresponding Source of the work is not available for anyone
|
||||
to copy, free of charge and under the terms of this License, through a
|
||||
publicly available network server or other readily accessible means,
|
||||
then you must either (1) cause the Corresponding Source to be so
|
||||
available, or (2) arrange to deprive yourself of the benefit of the
|
||||
patent license for this particular work, or (3) arrange, in a manner
|
||||
consistent with the requirements of this License, to extend the patent
|
||||
license to downstream recipients. "Knowingly relying" means you have
|
||||
actual knowledge that, but for the patent license, your conveying the
|
||||
covered work in a country, or your recipient's use of the covered work
|
||||
in a country, would infringe one or more identifiable patents in that
|
||||
country that you have reason to believe are valid.
|
||||
|
||||
If, pursuant to or in connection with a single transaction or
|
||||
arrangement, you convey, or propagate by procuring conveyance of, a
|
||||
covered work, and grant a patent license to some of the parties
|
||||
receiving the covered work authorizing them to use, propagate, modify
|
||||
or convey a specific copy of the covered work, then the patent license
|
||||
you grant is automatically extended to all recipients of the covered
|
||||
work and works based on it.
|
||||
|
||||
A patent license is "discriminatory" if it does not include within
|
||||
the scope of its coverage, prohibits the exercise of, or is
|
||||
conditioned on the non-exercise of one or more of the rights that are
|
||||
specifically granted under this License. You may not convey a covered
|
||||
work if you are a party to an arrangement with a third party that is
|
||||
in the business of distributing software, under which you make payment
|
||||
to the third party based on the extent of your activity of conveying
|
||||
the work, and under which the third party grants, to any of the
|
||||
parties who would receive the covered work from you, a discriminatory
|
||||
patent license (a) in connection with copies of the covered work
|
||||
conveyed by you (or copies made from those copies), or (b) primarily
|
||||
for and in connection with specific products or compilations that
|
||||
contain the covered work, unless you entered into that arrangement,
|
||||
or that patent license was granted, prior to 28 March 2007.
|
||||
|
||||
Nothing in this License shall be construed as excluding or limiting
|
||||
any implied license or other defenses to infringement that may
|
||||
otherwise be available to you under applicable patent law.
|
||||
|
||||
12. No Surrender of Others' Freedom.
|
||||
|
||||
If conditions are imposed on you (whether by court order, agreement or
|
||||
otherwise) that contradict the conditions of this License, they do not
|
||||
excuse you from the conditions of this License. If you cannot convey a
|
||||
covered work so as to satisfy simultaneously your obligations under this
|
||||
License and any other pertinent obligations, then as a consequence you may
|
||||
not convey it at all. For example, if you agree to terms that obligate you
|
||||
to collect a royalty for further conveying from those to whom you convey
|
||||
the Program, the only way you could satisfy both those terms and this
|
||||
License would be to refrain entirely from conveying the Program.
|
||||
|
||||
13. Remote Network Interaction; Use with the GNU General Public License.
|
||||
|
||||
Notwithstanding any other provision of this License, if you modify the
|
||||
Program, your modified version must prominently offer all users
|
||||
interacting with it remotely through a computer network (if your version
|
||||
supports such interaction) an opportunity to receive the Corresponding
|
||||
Source of your version by providing access to the Corresponding Source
|
||||
from a network server at no charge, through some standard or customary
|
||||
means of facilitating copying of software. This Corresponding Source
|
||||
shall include the Corresponding Source for any work covered by version 3
|
||||
of the GNU General Public License that is incorporated pursuant to the
|
||||
following paragraph.
|
||||
|
||||
Notwithstanding any other provision of this License, you have
|
||||
permission to link or combine any covered work with a work licensed
|
||||
under version 3 of the GNU General Public License into a single
|
||||
combined work, and to convey the resulting work. The terms of this
|
||||
License will continue to apply to the part which is the covered work,
|
||||
but the work with which it is combined will remain governed by version
|
||||
3 of the GNU General Public License.
|
||||
|
||||
14. Revised Versions of this License.
|
||||
|
||||
The Free Software Foundation may publish revised and/or new versions of
|
||||
the GNU Affero General Public License from time to time. Such new versions
|
||||
will be similar in spirit to the present version, but may differ in detail to
|
||||
address new problems or concerns.
|
||||
|
||||
Each version is given a distinguishing version number. If the
|
||||
Program specifies that a certain numbered version of the GNU Affero General
|
||||
Public License "or any later version" applies to it, you have the
|
||||
option of following the terms and conditions either of that numbered
|
||||
version or of any later version published by the Free Software
|
||||
Foundation. If the Program does not specify a version number of the
|
||||
GNU Affero General Public License, you may choose any version ever published
|
||||
by the Free Software Foundation.
|
||||
|
||||
If the Program specifies that a proxy can decide which future
|
||||
versions of the GNU Affero General Public License can be used, that proxy's
|
||||
public statement of acceptance of a version permanently authorizes you
|
||||
to choose that version for the Program.
|
||||
|
||||
Later license versions may give you additional or different
|
||||
permissions. However, no additional obligations are imposed on any
|
||||
author or copyright holder as a result of your choosing to follow a
|
||||
later version.
|
||||
|
||||
15. Disclaimer of Warranty.
|
||||
|
||||
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
|
||||
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
|
||||
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
|
||||
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
|
||||
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
|
||||
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
|
||||
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
|
||||
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
|
||||
|
||||
16. Limitation of Liability.
|
||||
|
||||
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
|
||||
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
|
||||
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
|
||||
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
|
||||
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
|
||||
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
|
||||
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
|
||||
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
|
||||
SUCH DAMAGES.
|
||||
|
||||
17. Interpretation of Sections 15 and 16.
|
||||
|
||||
If the disclaimer of warranty and limitation of liability provided
|
||||
above cannot be given local legal effect according to their terms,
|
||||
reviewing courts shall apply local law that most closely approximates
|
||||
an absolute waiver of all civil liability in connection with the
|
||||
Program, unless a warranty or assumption of liability accompanies a
|
||||
copy of the Program in return for a fee.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
How to Apply These Terms to Your New Programs
|
||||
|
||||
If you develop a new program, and you want it to be of the greatest
|
||||
possible use to the public, the best way to achieve this is to make it
|
||||
free software which everyone can redistribute and change under these terms.
|
||||
|
||||
To do so, attach the following notices to the program. It is safest
|
||||
to attach them to the start of each source file to most effectively
|
||||
state the exclusion of warranty; and each file should have at least
|
||||
the "copyright" line and a pointer to where the full notice is found.
|
||||
|
||||
<one line to give the program's name and a brief idea of what it does.>
|
||||
Copyright (C) <year> <name of author>
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
Also add information on how to contact you by electronic and paper mail.
|
||||
|
||||
If your software can interact with users remotely through a computer
|
||||
network, you should also make sure that it provides a way for users to
|
||||
get its source. For example, if your program is a web application, its
|
||||
interface could display a "Source" link that leads users to an archive
|
||||
of the code. There are many ways you could offer source, and different
|
||||
solutions will be better for different programs; see section 13 for the
|
||||
specific requirements.
|
||||
|
||||
You should also get your employer (if you work as a programmer) or school,
|
||||
if any, to sign a "copyright disclaimer" for the program, if necessary.
|
||||
For more information on this, and how to apply and follow the GNU AGPL, see
|
||||
<https://www.gnu.org/licenses/>.
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2021 Ajay Bura (ajbura) and other contributors
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
58
README.md
|
|
@ -1,51 +1,53 @@
|
|||
# Vojo
|
||||
# Cinny
|
||||
|
||||
A Matrix client focusing primarily on simple, elegant and secure interface. The main goal is to have an instant messaging application that is easy on people and has a modern touch.
|
||||
|
||||
Based on [Cinny](https://github.com/cinnyapp/cinny) (MIT license).
|
||||
## Table of Contents
|
||||
|
||||
- [About](#about)
|
||||
- [Getting Started](https://cinny.in)
|
||||
- [Contributing](./CONTRIBUTING.md)
|
||||
|
||||
## Getting started
|
||||
## About <a name = "about"></a>
|
||||
|
||||
The web app is available at [vojo.chat](https://vojo.chat).
|
||||
Cinny is a [Matrix](https://matrix.org) client focusing primarily on simple, elegant and secure interface.
|
||||
|
||||
## Self-hosting
|
||||

|
||||
|
||||
To host Vojo on your own, build from source and serve the files from `dist/` using your preferred webserver.
|
||||
## Building and Running
|
||||
|
||||
* The default homeserver is defined in [`config.json`](config.json).
|
||||
### Building from source
|
||||
|
||||
* You need to set up redirects to serve the assets. Example configurations: [nginx](contrib/nginx/vojo.domain.tld.conf), [caddy](contrib/caddy/caddyfile).
|
||||
Execute the following commands to compile the app from its source code:
|
||||
|
||||
* To deploy on a subdirectory, rebuild the app after updating the `base` path in [`build.config.ts`](build.config.ts).
|
||||
|
||||
## Local development
|
||||
|
||||
> [!TIP]
|
||||
> We recommend using a version manager as versions change very quickly. [NVM on Windows](https://github.com/coreybutler/nvm-windows#installation--upgrades) or [nvm](https://github.com/nvm-sh/nvm) on Linux/macOS are good choices.
|
||||
|
||||
Execute the following commands to start a development server:
|
||||
```sh
|
||||
npm ci # Installs all dependencies
|
||||
npm start # Serve a development version
|
||||
```
|
||||
|
||||
To build the app:
|
||||
```sh
|
||||
npm install # Installs all dependencies
|
||||
npm run build # Compiles the app into the dist/ directory
|
||||
```
|
||||
|
||||
You can then copy the files to a webserver's webroot of your choice.
|
||||
To serve a development version of the app locally for testing, you may also use the command `npm start`.
|
||||
|
||||
### Running with Docker
|
||||
|
||||
This repository includes a Dockerfile, which builds the application from source and serves it with Nginx on port 80. To use this locally, you can build the container like so:
|
||||
This repository includes a Dockerfile, which builds the application from source and serves it with Nginx on port 80. To
|
||||
use this locally, you can build the container like so:
|
||||
|
||||
```
|
||||
docker build -t vojo:latest .
|
||||
docker build -t cinny:latest .
|
||||
```
|
||||
|
||||
You can then run the container you've built with a command similar to this:
|
||||
|
||||
```
|
||||
docker run -p 8080:80 vojo:latest
|
||||
docker run -p 8080:80 cinny:latest
|
||||
```
|
||||
|
||||
This will forward your `localhost` port 8080 to the container's port 80. You can visit the app in your browser by navigating to `http://localhost:8080`.
|
||||
This will forward your `localhost` port 8080 to the container's port 80. You can visit the app in your browser by
|
||||
navigating to `http://localhost:8080`.
|
||||
|
||||
## License
|
||||
|
||||
Copyright (c) 2021 Ajay Bura (ajbura) and other contributors
|
||||
|
||||
Code licensed under the MIT License: <http://opensource.org/licenses/MIT>
|
||||
|
||||
Graphics licensed under CC-BY 4.0: <https://creativecommons.org/licenses/by/4.0/>
|
||||
3
_redirects
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
# Redirects from what the browser requests to what we serve
|
||||
/login /
|
||||
/register /
|
||||
101
android/.gitignore
vendored
|
|
@ -1,101 +0,0 @@
|
|||
# Using Android gitignore template: https://github.com/github/gitignore/blob/HEAD/Android.gitignore
|
||||
|
||||
# Built application files
|
||||
*.apk
|
||||
*.aar
|
||||
*.ap_
|
||||
*.aab
|
||||
|
||||
# Files for the ART/Dalvik VM
|
||||
*.dex
|
||||
|
||||
# Java class files
|
||||
*.class
|
||||
|
||||
# Generated files
|
||||
bin/
|
||||
gen/
|
||||
out/
|
||||
# Uncomment the following line in case you need and you don't have the release build type files in your app
|
||||
# release/
|
||||
|
||||
# Gradle files
|
||||
.gradle/
|
||||
build/
|
||||
|
||||
# Local configuration file (sdk path, etc)
|
||||
local.properties
|
||||
|
||||
# Proguard folder generated by Eclipse
|
||||
proguard/
|
||||
|
||||
# Log Files
|
||||
*.log
|
||||
|
||||
# Android Studio Navigation editor temp files
|
||||
.navigation/
|
||||
|
||||
# Android Studio captures folder
|
||||
captures/
|
||||
|
||||
# IntelliJ
|
||||
*.iml
|
||||
.idea/workspace.xml
|
||||
.idea/tasks.xml
|
||||
.idea/gradle.xml
|
||||
.idea/assetWizardSettings.xml
|
||||
.idea/dictionaries
|
||||
.idea/libraries
|
||||
# Android Studio 3 in .gitignore file.
|
||||
.idea/caches
|
||||
.idea/modules.xml
|
||||
# Comment next line if keeping position of elements in Navigation Editor is relevant for you
|
||||
.idea/navEditor.xml
|
||||
|
||||
# Keystore files
|
||||
# Uncomment the following lines if you do not want to check your keystore files in.
|
||||
#*.jks
|
||||
#*.keystore
|
||||
|
||||
# External native build folder generated in Android Studio 2.2 and later
|
||||
.externalNativeBuild
|
||||
.cxx/
|
||||
|
||||
# Google Services (e.g. APIs or Firebase)
|
||||
# google-services.json
|
||||
|
||||
# Freeline
|
||||
freeline.py
|
||||
freeline/
|
||||
freeline_project_description.json
|
||||
|
||||
# fastlane
|
||||
fastlane/report.xml
|
||||
fastlane/Preview.html
|
||||
fastlane/screenshots
|
||||
fastlane/test_output
|
||||
fastlane/readme.md
|
||||
|
||||
# Version control
|
||||
vcs.xml
|
||||
|
||||
# lint
|
||||
lint/intermediates/
|
||||
lint/generated/
|
||||
lint/outputs/
|
||||
lint/tmp/
|
||||
# lint/reports/
|
||||
|
||||
# Android Profiling
|
||||
*.hprof
|
||||
|
||||
# Cordova plugins for Capacitor
|
||||
capacitor-cordova-android-plugins
|
||||
|
||||
# Copied web assets
|
||||
app/src/main/assets/public
|
||||
|
||||
# Generated Config files
|
||||
app/src/main/assets/capacitor.config.json
|
||||
app/src/main/assets/capacitor.plugins.json
|
||||
app/src/main/res/xml/config.xml
|
||||
2
android/app/.gitignore
vendored
|
|
@ -1,2 +0,0 @@
|
|||
/build/*
|
||||
!/build/.npmkeep
|
||||
|
|
@ -1,148 +0,0 @@
|
|||
apply plugin: 'com.android.application'
|
||||
|
||||
// Mirror of resolveAppVersion() in ../../vite.config.js so the APK's
|
||||
// versionName matches __APP_VERSION__ rendered in the About screen.
|
||||
// `git describe --tags --match 'v*'` against tag v0.2.0 yields
|
||||
// `v0.2.0-<commits>-g<hash>`; patch = commit count since the tag.
|
||||
// Falls back to package.json only when git is unavailable.
|
||||
def gitDescribe = providers.exec {
|
||||
it.commandLine 'git', 'describe', '--tags', '--match', 'v*', '--always'
|
||||
it.workingDir rootDir.parentFile
|
||||
it.ignoreExitValue = true
|
||||
}
|
||||
def appVersion = {
|
||||
def fromGit = gitDescribe.result.get().exitValue == 0 ? gitDescribe.standardOutput.asText.get().trim() : null
|
||||
def m = fromGit =~ /^v?(\d+)\.(\d+)\.(\d+)(?:-(\d+)-g[0-9a-f]+)?$/
|
||||
if (fromGit && m.matches()) {
|
||||
def major = m[0][1].toInteger()
|
||||
def minor = m[0][2].toInteger()
|
||||
def patch = (m[0][4] ?: m[0][3]).toInteger()
|
||||
return [name: "${major}.${minor}.${patch}", major: major, minor: minor, patch: patch]
|
||||
}
|
||||
def pkg = new groovy.json.JsonSlurper().parseText(file('../../package.json').text)
|
||||
def parts = pkg.version.split('\\.')
|
||||
return [name: pkg.version, major: parts[0].toInteger(), minor: parts[1].toInteger(), patch: parts[2].toInteger()]
|
||||
}()
|
||||
def computedVersionCode = appVersion.major * 1000000 + appVersion.minor * 1000 + appVersion.patch
|
||||
|
||||
android {
|
||||
namespace = "chat.vojo.app"
|
||||
compileSdk = rootProject.ext.compileSdkVersion
|
||||
defaultConfig {
|
||||
applicationId "chat.vojo.app"
|
||||
minSdkVersion rootProject.ext.minSdkVersion
|
||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||
versionCode computedVersionCode
|
||||
versionName appVersion.name
|
||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||
aaptOptions {
|
||||
// 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
|
||||
ignoreAssetsPattern = '!.svn:!.git:!.ds_store:!*.scc:.*:!CVS:!thumbs.db:!picasa.ini:!*~'
|
||||
}
|
||||
}
|
||||
// AGP 8+ requires explicit opt-in for BuildConfig generation. We rely on
|
||||
// BuildConfig.DEBUG to gate Log.d calls that dump privacy-sensitive
|
||||
// identifiers (roomId, eventId) so release builds don't leak them through
|
||||
// logcat / crash-reporter buffers. See dlog() in VojoFirebaseMessagingService.
|
||||
buildFeatures {
|
||||
buildConfig = true
|
||||
}
|
||||
|
||||
signingConfigs {
|
||||
release {
|
||||
if (project.hasProperty('VOJO_RELEASE_STORE_FILE')) {
|
||||
storeFile file(VOJO_RELEASE_STORE_FILE)
|
||||
storePassword VOJO_RELEASE_STORE_PASSWORD
|
||||
keyAlias VOJO_RELEASE_KEY_ALIAS
|
||||
keyPassword VOJO_RELEASE_KEY_PASSWORD
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
minifyEnabled true
|
||||
shrinkResources true
|
||||
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
|
||||
signingConfig signingConfigs.release
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
repositories {
|
||||
flatDir{
|
||||
dirs '../capacitor-cordova-android-plugins/src/main/libs', 'libs'
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation fileTree(include: ['*.jar'], dir: 'libs')
|
||||
implementation "androidx.activity:activity:$androidxActivityVersion"
|
||||
implementation "androidx.appcompat:appcompat:$androidxAppCompatVersion"
|
||||
implementation "androidx.coordinatorlayout:coordinatorlayout:$androidxCoordinatorLayoutVersion"
|
||||
implementation "androidx.core:core-splashscreen:$coreSplashScreenVersion"
|
||||
implementation project(':capacitor-android')
|
||||
// Needed for VojoFirebaseMessagingService. @capacitor/push-notifications
|
||||
// already depends on firebase-messaging but declares it `implementation`
|
||||
// so classes aren't exposed at app-module compile time.
|
||||
implementation "com.google.firebase:firebase-messaging:25.0.1"
|
||||
// WorkManager hosts VojoPollWorker — periodic /notifications poll that
|
||||
// delivers messages and missed-call surfaces on networks where FCM
|
||||
// (mtalk.google.com:5228) is blocked. Library self-registers its scheduler
|
||||
// in the merged manifest; we declare no permission for it.
|
||||
implementation "androidx.work:work-runtime:2.10.0"
|
||||
testImplementation "junit:junit:$junitVersion"
|
||||
androidTestImplementation "androidx.test.ext:junit:$androidxJunitVersion"
|
||||
androidTestImplementation "androidx.test.espresso:espresso-core:$androidxEspressoCoreVersion"
|
||||
implementation project(':capacitor-cordova-android-plugins')
|
||||
}
|
||||
|
||||
apply from: 'capacitor.build.gradle'
|
||||
|
||||
abstract class GeneratePushStringsTask extends DefaultTask {
|
||||
@InputFiles
|
||||
abstract ConfigurableFileCollection getInputFiles()
|
||||
|
||||
@OutputDirectory
|
||||
abstract DirectoryProperty getOutputDir()
|
||||
|
||||
@TaskAction
|
||||
void generate() {
|
||||
def nodeBin = project.findProperty('NODE_BIN') ?: 'node'
|
||||
project.exec {
|
||||
commandLine nodeBin,
|
||||
new File(project.rootProject.projectDir.parentFile, 'scripts/gen-push-strings.mjs').absolutePath,
|
||||
'--out', outputDir.get().asFile.absolutePath
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
androidComponents {
|
||||
onVariants(selector().all()) { variant ->
|
||||
def repoRoot = rootProject.projectDir.parentFile
|
||||
def taskProvider = tasks.register(
|
||||
"generatePushStrings${variant.name.capitalize()}",
|
||||
GeneratePushStringsTask
|
||||
) {
|
||||
inputFiles.from(
|
||||
new File(repoRoot, 'public/locales/en.json'),
|
||||
new File(repoRoot, 'public/locales/ru.json'),
|
||||
new File(repoRoot, 'scripts/gen-push-strings.mjs')
|
||||
)
|
||||
outputDir.set(layout.buildDirectory.dir("generated/res/push/${variant.name}"))
|
||||
}
|
||||
variant.sources.res.addGeneratedSourceDirectory(
|
||||
taskProvider, GeneratePushStringsTask::getOutputDir
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
def servicesJSON = file('google-services.json')
|
||||
if (servicesJSON.text) {
|
||||
apply plugin: 'com.google.gms.google-services'
|
||||
}
|
||||
} catch(Exception e) {
|
||||
logger.info("google-services.json not found, google-services plugin not applied. Push Notifications won't work")
|
||||
}
|
||||
|
|
@ -1,23 +0,0 @@
|
|||
// DO NOT EDIT THIS FILE! IT IS GENERATED EACH TIME "capacitor update" IS RUN
|
||||
|
||||
android {
|
||||
compileOptions {
|
||||
sourceCompatibility JavaVersion.VERSION_21
|
||||
targetCompatibility JavaVersion.VERSION_21
|
||||
}
|
||||
}
|
||||
|
||||
apply from: "../capacitor-cordova-android-plugins/cordova.variables.gradle"
|
||||
dependencies {
|
||||
implementation project(':capacitor-app')
|
||||
implementation project(':capacitor-browser')
|
||||
implementation project(':capacitor-preferences')
|
||||
implementation project(':capacitor-push-notifications')
|
||||
implementation project(':capacitor-toast')
|
||||
|
||||
}
|
||||
|
||||
|
||||
if (hasProperty('postBuildExtras')) {
|
||||
postBuildExtras()
|
||||
}
|
||||
|
|
@ -1,29 +0,0 @@
|
|||
{
|
||||
"project_info": {
|
||||
"project_number": "51806967595",
|
||||
"project_id": "chat-vojo-app",
|
||||
"storage_bucket": "chat-vojo-app.firebasestorage.app"
|
||||
},
|
||||
"client": [
|
||||
{
|
||||
"client_info": {
|
||||
"mobilesdk_app_id": "1:51806967595:android:93921bf62aa9713a79576e",
|
||||
"android_client_info": {
|
||||
"package_name": "chat.vojo.app"
|
||||
}
|
||||
},
|
||||
"oauth_client": [],
|
||||
"api_key": [
|
||||
{
|
||||
"current_key": "AIzaSyBroeOOHxg-tEyU-O-zjSWF7mEejedRWsM"
|
||||
}
|
||||
],
|
||||
"services": {
|
||||
"appinvite_service": {
|
||||
"other_platform_oauth_client": []
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"configuration_version": "1"
|
||||
}
|
||||
45
android/app/proguard-rules.pro
vendored
|
|
@ -1,45 +0,0 @@
|
|||
# Add project specific ProGuard rules here.
|
||||
# You can control the set of applied configuration files using the
|
||||
# proguardFiles setting in build.gradle.
|
||||
#
|
||||
# For more details, see
|
||||
# http://developer.android.com/guide/developing/tools/proguard.html
|
||||
|
||||
# If your project uses WebView with JS, uncomment the following
|
||||
# and specify the fully qualified class name to the JavaScript interface
|
||||
# class:
|
||||
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
|
||||
# public *;
|
||||
#}
|
||||
|
||||
# Uncomment this to preserve the line number information for
|
||||
# debugging stack traces.
|
||||
#-keepattributes SourceFile,LineNumberTable
|
||||
|
||||
# If you keep the line number information, uncomment this to
|
||||
# hide the original source file name.
|
||||
#-renamesourcefileattribute SourceFile
|
||||
|
||||
# Keep custom app classes — entry points invoked by Android system (Intents,
|
||||
# FCM, AndroidManifest references) or by JS bridge via reflection.
|
||||
-keep class chat.vojo.app.MainActivity { *; }
|
||||
-keep class chat.vojo.app.VojoFirebaseMessagingService { *; }
|
||||
-keep class chat.vojo.app.CallForegroundPlugin { *; }
|
||||
-keep class chat.vojo.app.CallForegroundService { *; }
|
||||
-keep class chat.vojo.app.CallDeclineReceiver { *; }
|
||||
-keep class chat.vojo.app.CallCancelReceiver { *; }
|
||||
-keep class chat.vojo.app.FullScreenIntentPlugin { *; }
|
||||
-keep class chat.vojo.app.LaunchSplashPlugin { *; }
|
||||
|
||||
# Firebase Messaging — receivers/services resolved by Android via manifest.
|
||||
-keep public class * extends com.google.firebase.messaging.FirebaseMessagingService
|
||||
-keep class com.google.firebase.iid.** { *; }
|
||||
-keep class com.google.firebase.messaging.** { *; }
|
||||
|
||||
# Capacitor — plugins discovered by annotation/reflection.
|
||||
-keep @com.getcapacitor.annotation.CapacitorPlugin class * { *; }
|
||||
-keep class com.getcapacitor.** { *; }
|
||||
-keep class com.getcapacitor.plugin.** { *; }
|
||||
|
||||
# AndroidX splashscreen — reflection paths.
|
||||
-keep class androidx.core.splashscreen.** { *; }
|
||||
|
|
@ -1,26 +0,0 @@
|
|||
package com.getcapacitor.myapp;
|
||||
|
||||
import static org.junit.Assert.*;
|
||||
|
||||
import android.content.Context;
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4;
|
||||
import androidx.test.platform.app.InstrumentationRegistry;
|
||||
import org.junit.Test;
|
||||
import org.junit.runner.RunWith;
|
||||
|
||||
/**
|
||||
* Instrumented test, which will execute on an Android device.
|
||||
*
|
||||
* @see <a href="http://d.android.com/tools/testing">Testing documentation</a>
|
||||
*/
|
||||
@RunWith(AndroidJUnit4.class)
|
||||
public class ExampleInstrumentedTest {
|
||||
|
||||
@Test
|
||||
public void useAppContext() throws Exception {
|
||||
// Context of the app under test.
|
||||
Context appContext = InstrumentationRegistry.getInstrumentation().getTargetContext();
|
||||
|
||||
assertEquals("com.getcapacitor.app", appContext.getPackageName());
|
||||
}
|
||||
}
|
||||
|
|
@ -1,145 +0,0 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools">
|
||||
|
||||
<!-- allowBackup=false: CallDeclineReceiver reads the Matrix access_token
|
||||
from shared_prefs/CapacitorStorage.xml (written by sessionBridge).
|
||||
With allowBackup=true a rooted device or adb-backup-enabled user
|
||||
could exfiltrate the cleartext token. Session data is cheap to
|
||||
recreate via re-login, so excluding ourselves from Auto Backup
|
||||
is the simpler control vs. fine-grained backup_rules.xml exclusions. -->
|
||||
<application
|
||||
android:allowBackup="false"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:label="@string/app_name"
|
||||
android:roundIcon="@mipmap/ic_launcher_round"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/AppTheme">
|
||||
|
||||
<activity
|
||||
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|locale|smallestScreenSize|screenLayout|uiMode|navigation|density"
|
||||
android:name=".MainActivity"
|
||||
android:label="@string/title_activity_main"
|
||||
android:theme="@style/AppTheme.NoActionBarLaunch"
|
||||
android:launchMode="singleTask"
|
||||
android:exported="true">
|
||||
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
|
||||
<!-- App Links for https://vojo.chat/u/<user>. autoVerify=true lets
|
||||
Chrome/Gmail/SMS hand the tap straight to this activity; the
|
||||
server must publish a matching /.well-known/assetlinks.json
|
||||
over HTTPS with the installed APK's signing SHA-256. Telegram
|
||||
and other in-app browsers ignore verification and load the
|
||||
URL in their own webview — the intent-URL redirect injected
|
||||
in index.html covers that case. -->
|
||||
<intent-filter android:autoVerify="true">
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
<data
|
||||
android:scheme="https"
|
||||
android:host="vojo.chat"
|
||||
android:pathPrefix="/u/" />
|
||||
</intent-filter>
|
||||
|
||||
<!-- System share-sheet target. Three filters because Android's
|
||||
sheet UI dedupes by activity but resolves by MIME match:
|
||||
text/* gets its own filter so the Vojo icon shows up
|
||||
alongside WhatsApp/Telegram for «share link/selection»; */*
|
||||
covers single-file (image/video/audio/pdf/…) and
|
||||
SEND_MULTIPLE picks up gallery multi-select.
|
||||
Payload extraction lives in ShareTargetPlugin — MainActivity
|
||||
only routes the Intent to the plugin via onNewIntent. -->
|
||||
<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>
|
||||
|
||||
<provider
|
||||
android:name="androidx.core.content.FileProvider"
|
||||
android:authorities="${applicationId}.fileprovider"
|
||||
android:exported="false"
|
||||
android:grantUriPermissions="true">
|
||||
<meta-data
|
||||
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||
android:resource="@xml/file_paths"></meta-data>
|
||||
</provider>
|
||||
|
||||
<!-- Replace Capacitor's default FCM service. VojoFirebaseMessagingService
|
||||
extends MessagingService so super.onMessageReceived() still forwards
|
||||
to the JS bridge; we add cold-start notification display on top. -->
|
||||
<service
|
||||
android:name="com.capacitorjs.plugins.pushnotifications.MessagingService"
|
||||
tools:node="remove" />
|
||||
|
||||
<service
|
||||
android:name=".VojoFirebaseMessagingService"
|
||||
android:exported="false">
|
||||
<intent-filter>
|
||||
<action android:name="com.google.firebase.MESSAGING_EVENT" />
|
||||
</intent-filter>
|
||||
</service>
|
||||
|
||||
<service
|
||||
android:name=".CallForegroundService"
|
||||
android:exported="false"
|
||||
android:foregroundServiceType="microphone" />
|
||||
|
||||
<receiver
|
||||
android:name=".CallCancelReceiver"
|
||||
android:exported="false" />
|
||||
|
||||
<receiver
|
||||
android:name=".CallDeclineReceiver"
|
||||
android:exported="false" />
|
||||
|
||||
<receiver
|
||||
android:name=".MarkAsReadReceiver"
|
||||
android:exported="false" />
|
||||
|
||||
<receiver
|
||||
android:name=".NotificationDismissReceiver"
|
||||
android:exported="false" />
|
||||
|
||||
<receiver
|
||||
android:name=".ReplyReceiver"
|
||||
android:exported="false" />
|
||||
</application>
|
||||
|
||||
<!-- Permissions -->
|
||||
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
<!-- DM voice calls: mic + audio routing. Capacitor auto-requests at getUserMedia time. -->
|
||||
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
||||
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
|
||||
<!-- Required to unblock NotificationCompat.CallStyle on API 31+: NMS's
|
||||
checkDisqualifyingFeatures rejects CallStyle notifications without
|
||||
FSI/FGS/UIJ, throwing IllegalArgumentException on its own handler
|
||||
thread (silent to the app). Declaring the permission flips
|
||||
FLAG_FSI_REQUESTED_BUT_DENIED so the gate passes, even though we
|
||||
never call setFullScreenIntent(). See ADR 2.5-heads-up. -->
|
||||
<uses-permission android:name="android.permission.USE_FULL_SCREEN_INTENT" />
|
||||
<!-- DM call lock-screen retention: CallForegroundService keeps the call
|
||||
process foregrounded under lock so AppOps doesn't revoke RECORD_AUDIO
|
||||
and netd doesn't block background network. -->
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MICROPHONE" />
|
||||
</manifest>
|
||||
|
|
@ -1,65 +0,0 @@
|
|||
package chat.vojo.app;
|
||||
|
||||
import android.graphics.Bitmap;
|
||||
import android.util.LruCache;
|
||||
|
||||
/**
|
||||
* In-memory LRU cache of decoded avatar bitmaps keyed by MXC URL string.
|
||||
*
|
||||
* Sized as a process-singleton (~4 MB) so the FCM service, polling Worker
|
||||
* and ReplyReceiver all share one pool. 96×96 ARGB_8888 bitmap is about
|
||||
* 36 KB, so a 4 MB cache holds ~110 avatars — enough for the active
|
||||
* conversation set on a typical user. LruCache evicts the least-recently-
|
||||
* read entry when full; this is the right shape for "rooms the user is
|
||||
* actively talking in stay warm, dormant rooms reload on demand".
|
||||
*
|
||||
* Thread-safety: LruCache itself is synchronized internally on every
|
||||
* get/put/remove. We don't need an outer lock for normal operation. The
|
||||
* AvatarLoader funnels all puts through this class.
|
||||
*
|
||||
* Process death: cache is in-memory only. After a kill, the first push
|
||||
* to any room cold-renders without avatars and re-renders once the
|
||||
* loader populates the cache (see AvatarLoader.loadAllWithTimeout).
|
||||
*/
|
||||
final class AvatarBitmapCache {
|
||||
|
||||
// Heap budget: bytes. 4 MB is generous against ARGB_8888 96×96 bitmaps
|
||||
// (~36 KB each) and stays comfortably under the 1/8-of-heap Android
|
||||
// recommendation on every device we ship to (minSdk 24 → at least
|
||||
// 96 MB heap on a low-end phone).
|
||||
private static final int MAX_SIZE_BYTES = 4 * 1024 * 1024;
|
||||
|
||||
private static final LruCache<String, Bitmap> CACHE =
|
||||
new LruCache<String, Bitmap>(MAX_SIZE_BYTES) {
|
||||
@Override
|
||||
protected int sizeOf(String key, Bitmap value) {
|
||||
return value.getByteCount();
|
||||
}
|
||||
};
|
||||
|
||||
private AvatarBitmapCache() {}
|
||||
|
||||
/**
|
||||
* Returns the cached bitmap for an MXC URL, or null on miss.
|
||||
*
|
||||
* Bitmap references are NOT defensively copied — the cache hands out
|
||||
* the same reference to every caller. This is safe because no code
|
||||
* path in the app calls Bitmap.recycle() on a cached bitmap (the
|
||||
* intermediate square / source bitmaps inside AvatarLoader.
|
||||
* toCircularBitmap ARE recycled, but the circular output that lands
|
||||
* here is held until LRU evicts it). LRU eviction simply drops the
|
||||
* cache's reference, and the GC reclaims memory only after every
|
||||
* Notification that referenced the bitmap is also released by the
|
||||
* system. Adding a defensive copy here would halve the effective
|
||||
* cache size for no real-world benefit.
|
||||
*/
|
||||
static Bitmap get(String mxc) {
|
||||
if (mxc == null || mxc.isEmpty()) return null;
|
||||
return CACHE.get(mxc);
|
||||
}
|
||||
|
||||
static void put(String mxc, Bitmap bitmap) {
|
||||
if (mxc == null || mxc.isEmpty() || bitmap == null) return;
|
||||
CACHE.put(mxc, bitmap);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,368 +0,0 @@
|
|||
package chat.vojo.app;
|
||||
|
||||
import android.content.Context;
|
||||
import android.content.SharedPreferences;
|
||||
import android.graphics.Bitmap;
|
||||
import android.graphics.BitmapFactory;
|
||||
import android.graphics.BitmapShader;
|
||||
import android.graphics.Canvas;
|
||||
import android.graphics.Paint;
|
||||
import android.graphics.Shader;
|
||||
import android.util.Log;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.net.HttpURLConnection;
|
||||
import java.net.URL;
|
||||
import java.util.Collection;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.CountDownLatch;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
/**
|
||||
* Fetches and decodes avatar bitmaps from MXC URLs, populating
|
||||
* {@link AvatarBitmapCache}.
|
||||
*
|
||||
* URL resolution mirrors matrix-js-sdk's auth-media v1.11+ pattern:
|
||||
* mxc://server/mediaId
|
||||
* → <homeserver>/_matrix/client/v1/media/thumbnail/<server>/<mediaId>
|
||||
* ?width=96&height=96&method=crop
|
||||
* + Authorization: Bearer <accessToken>
|
||||
*
|
||||
* The legacy unauthenticated `/_matrix/media/v3/thumbnail/...` endpoint is
|
||||
* NOT used — every Synapse the Vojo audience runs against (vanilla, v1.11+
|
||||
* by deployment policy, see docs/ai/server-side.md) speaks auth media.
|
||||
* Removing the legacy fallback keeps the loader off the deprecated path
|
||||
* and avoids leaking the access token to a server route that doesn't
|
||||
* require it.
|
||||
*
|
||||
* Concurrency: each MXC URL is fetched at most once concurrently — the
|
||||
* `inFlight` set short-circuits duplicate requests from rapid
|
||||
* append-rebuild cycles on the same conversation. Loads happen on a
|
||||
* shared 4-thread pool; bigger than 1 so 5 senders in a group chat can
|
||||
* load in parallel, capped to keep socket pressure under the typical
|
||||
* mobile network budget.
|
||||
*
|
||||
* Two entry points:
|
||||
* - {@link #loadAllWithTimeout}: synchronous wait, used by the render
|
||||
* path to populate the cache before building the MessagingStyle so the
|
||||
* first post already has avatars. Timeout-bounded to keep FCM thread
|
||||
* responsive (Android budgets ~10s; we use 800 ms).
|
||||
* - {@link #prefetch}: fire-and-forget, used for warm-up scenarios.
|
||||
* Not currently called but kept for the room-metadata bridge to
|
||||
* eventually warm the cache on visibility resume.
|
||||
*/
|
||||
final class AvatarLoader {
|
||||
|
||||
private static final String TAG = "AvatarLoader";
|
||||
|
||||
private static final int AVATAR_SIZE_PX = 96;
|
||||
private static final int CONNECT_TIMEOUT_MS = 5_000;
|
||||
private static final int READ_TIMEOUT_MS = 5_000;
|
||||
private static final int RENDER_BLOCK_TIMEOUT_MS = 800;
|
||||
// Cap decoded bitmap byte count — a malicious / huge avatar shouldn't
|
||||
// OOM the FCM service. 96×96 ARGB_8888 is ~36 KB; we accept up to
|
||||
// 4× that (~140 KB) to allow some downscaling slack on servers that
|
||||
// return slightly oversized thumbnails.
|
||||
private static final int MAX_DECODED_BYTES = 144 * 1024;
|
||||
|
||||
private static final ExecutorService EXECUTOR = Executors.newFixedThreadPool(4);
|
||||
|
||||
// MXC URL → CountDownLatch that fires when the in-flight download
|
||||
// completes (success or failure). A second caller observing an
|
||||
// already-pending mxc waits on the SAME latch instead of either
|
||||
// returning empty-handed or kicking off a duplicate fetch. Latches
|
||||
// are removed by the worker task in its finally block; the same task
|
||||
// that put the entry is the only one allowed to remove it, so a slow
|
||||
// remove() race is harmless.
|
||||
private static final ConcurrentHashMap<String, CountDownLatch> inFlight =
|
||||
new ConcurrentHashMap<>();
|
||||
|
||||
private AvatarLoader() {}
|
||||
|
||||
/**
|
||||
* Block the caller for up to {@link #RENDER_BLOCK_TIMEOUT_MS} while
|
||||
* fetching any of the given MXC URLs that are not yet in
|
||||
* {@link AvatarBitmapCache}. Cache hits are no-ops. Already-in-flight
|
||||
* URLs are awaited via the shared latch — duplicate concurrent
|
||||
* fetches do not happen.
|
||||
*
|
||||
* Designed to be called inline from the render path: after this
|
||||
* returns, {@link AvatarBitmapCache#get} will be non-null for every
|
||||
* MXC that loaded successfully within the budget. Failures are
|
||||
* silent — the render then falls back to a Person without icon
|
||||
* (Android renders initials/blank).
|
||||
*
|
||||
* Returns the count of avatars that landed in the cache during this
|
||||
* call (purely informational — useful for logs).
|
||||
*/
|
||||
static int loadAllWithTimeout(Context ctx, Collection<String> mxcs) {
|
||||
if (mxcs == null || mxcs.isEmpty()) {
|
||||
Log.i(TAG, "loadAll: empty input, skip");
|
||||
return 0;
|
||||
}
|
||||
SharedPreferences prefs = ctx.getSharedPreferences(
|
||||
VojoPollWorker.PREFS, Context.MODE_PRIVATE);
|
||||
String token = prefs.getString(VojoPollWorker.KEY_ACCESS_TOKEN, null);
|
||||
String homeserver = prefs.getString(VojoPollWorker.KEY_HOMESERVER_URL, null);
|
||||
if (token == null || token.isEmpty() || homeserver == null || homeserver.isEmpty()) {
|
||||
// No credentials yet (fresh install + first push). We can't
|
||||
// resolve MXC URLs without an access token. Falling back to
|
||||
// no-icon Person renderer is the correct behaviour here.
|
||||
Log.i(TAG, "loadAll: no credentials in prefs, skip"
|
||||
+ " hasToken=" + (token != null && !token.isEmpty())
|
||||
+ " hasHs=" + (homeserver != null && !homeserver.isEmpty()));
|
||||
return 0;
|
||||
}
|
||||
// De-duplicate and filter to misses only; if the cache already has
|
||||
// an entry, no work is needed.
|
||||
Set<String> toLoad = new LinkedHashSet<>();
|
||||
for (String mxc : mxcs) {
|
||||
if (mxc == null || mxc.isEmpty()) continue;
|
||||
if (!mxc.startsWith("mxc://")) continue;
|
||||
if (AvatarBitmapCache.get(mxc) != null) continue;
|
||||
toLoad.add(mxc);
|
||||
}
|
||||
if (toLoad.isEmpty()) return 0;
|
||||
|
||||
// Per-mxc latches shared across concurrent callers — a second
|
||||
// caller arriving while we're already mid-fetch waits on the
|
||||
// SAME latch instead of forcing a duplicate HTTP or returning
|
||||
// immediately empty-handed (which was the previous bug — see
|
||||
// git blame for the race description).
|
||||
java.util.List<CountDownLatch> waits = new java.util.ArrayList<>(toLoad.size());
|
||||
for (String mxc : toLoad) {
|
||||
CountDownLatch myLatch = new CountDownLatch(1);
|
||||
CountDownLatch existing = inFlight.putIfAbsent(mxc, myLatch);
|
||||
if (existing != null) {
|
||||
// Already in flight — share the original latch.
|
||||
waits.add(existing);
|
||||
continue;
|
||||
}
|
||||
// We own this fetch; kick off the worker that will fire
|
||||
// myLatch when done.
|
||||
waits.add(myLatch);
|
||||
final String capturedMxc = mxc;
|
||||
final String capturedHomeserver = homeserver;
|
||||
final String capturedToken = token;
|
||||
EXECUTOR.execute(() -> {
|
||||
try {
|
||||
Bitmap bmp = fetchAndDecode(capturedMxc, capturedHomeserver, capturedToken);
|
||||
if (bmp != null) AvatarBitmapCache.put(capturedMxc, bmp);
|
||||
} catch (Throwable t) {
|
||||
Log.w(TAG, "fetch threw mxc=" + capturedMxc, t);
|
||||
} finally {
|
||||
// Remove BEFORE countDown so a freshly-arriving caller
|
||||
// doesn't observe a stale latch for an already-loaded
|
||||
// mxc (would block until the next call with no fetch
|
||||
// actually pending). Cache.get() on the post-await
|
||||
// side covers the race where remove+put-cache happens
|
||||
// between two latch waits.
|
||||
inFlight.remove(capturedMxc);
|
||||
myLatch.countDown();
|
||||
}
|
||||
});
|
||||
}
|
||||
// Single budget for the whole batch — wait for all latches OR
|
||||
// hit the timeout. Latches that fire early just return await()
|
||||
// immediately; the slowest one consumes the remainder of the
|
||||
// budget.
|
||||
long deadline = System.nanoTime() + TimeUnit.MILLISECONDS.toNanos(RENDER_BLOCK_TIMEOUT_MS);
|
||||
try {
|
||||
for (CountDownLatch latch : waits) {
|
||||
long remaining = deadline - System.nanoTime();
|
||||
if (remaining <= 0) break;
|
||||
latch.await(remaining, TimeUnit.NANOSECONDS);
|
||||
}
|
||||
} catch (InterruptedException ie) {
|
||||
Thread.currentThread().interrupt();
|
||||
}
|
||||
// Count how many actually landed in the cache during this call —
|
||||
// includes both items we fetched and items that finished after our
|
||||
// timeout (which won't be reflected in this count but are still
|
||||
// usable on the next render).
|
||||
int hits = 0;
|
||||
for (String mxc : toLoad) {
|
||||
if (AvatarBitmapCache.get(mxc) != null) hits += 1;
|
||||
}
|
||||
Log.i(TAG, "loadAll: requested=" + mxcs.size()
|
||||
+ " toLoad=" + toLoad.size() + " hits=" + hits);
|
||||
return hits;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve an `mxc://server/mediaId` URL to a 96×96 thumbnail via the
|
||||
* authenticated v1.11+ media endpoint and decode the response into a
|
||||
* Bitmap. Returns null on any non-2xx, decode failure, or oversized
|
||||
* payload (see {@link #MAX_DECODED_BYTES}).
|
||||
*/
|
||||
private static Bitmap fetchAndDecode(String mxc, String homeserver, String token)
|
||||
throws IOException {
|
||||
Parsed parsed = parseMxc(mxc);
|
||||
if (parsed == null) {
|
||||
Log.w(TAG, "fetch: malformed mxc=" + mxc);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Server + mediaId are NOT URL-encoded — matches matrix-js-sdk's
|
||||
// content-repo.ts (it concatenates verbatim via `new URL()`).
|
||||
// URLEncoder would turn `example.com:8448` into `example.com%3A8448`,
|
||||
// which Synapse's media router rejects as an unknown server.
|
||||
// mediaId is base64-ish per spec (URL-safe alphabet) so no
|
||||
// encoding is needed there either.
|
||||
StringBuilder url = new StringBuilder(homeserver);
|
||||
if (!homeserver.endsWith("/")) url.append('/');
|
||||
url.append("_matrix/client/v1/media/thumbnail/")
|
||||
.append(parsed.server)
|
||||
.append('/')
|
||||
.append(parsed.mediaId)
|
||||
.append("?width=").append(AVATAR_SIZE_PX)
|
||||
.append("&height=").append(AVATAR_SIZE_PX)
|
||||
.append("&method=crop");
|
||||
|
||||
HttpURLConnection conn = (HttpURLConnection) new URL(url.toString()).openConnection();
|
||||
try {
|
||||
conn.setRequestMethod("GET");
|
||||
conn.setRequestProperty("Authorization", "Bearer " + token);
|
||||
conn.setRequestProperty("Accept", "image/*");
|
||||
conn.setConnectTimeout(CONNECT_TIMEOUT_MS);
|
||||
conn.setReadTimeout(READ_TIMEOUT_MS);
|
||||
int code = conn.getResponseCode();
|
||||
Log.i(TAG, "fetch: mxc=" + mxc + " status=" + code);
|
||||
if (code < 200 || code >= 300) return null;
|
||||
int contentLength = conn.getContentLength();
|
||||
if (contentLength > MAX_DECODED_BYTES) {
|
||||
Log.w(TAG, "fetch: oversized contentLength=" + contentLength + " mxc=" + mxc);
|
||||
return null;
|
||||
}
|
||||
try (InputStream in = conn.getInputStream()) {
|
||||
BitmapFactory.Options opts = new BitmapFactory.Options();
|
||||
// Stick with ARGB_8888 even on low-mem devices — RGB_565
|
||||
// would lose alpha (group avatars often have a
|
||||
// transparent corner) and the cache cap (4 MB) already
|
||||
// bounds total memory. inJustDecodeBounds + sample-size
|
||||
// dance is overkill at 96×96.
|
||||
opts.inPreferredConfig = Bitmap.Config.ARGB_8888;
|
||||
Bitmap bmp = BitmapFactory.decodeStream(in, null, opts);
|
||||
if (bmp == null) {
|
||||
Log.w(TAG, "fetch: decodeStream returned null mxc=" + mxc);
|
||||
return null;
|
||||
}
|
||||
if (bmp.getByteCount() > MAX_DECODED_BYTES) {
|
||||
Log.w(TAG, "fetch: decoded oversized "
|
||||
+ bmp.getByteCount() + " bytes mxc=" + mxc);
|
||||
bmp.recycle();
|
||||
return null;
|
||||
}
|
||||
// Crop into a circle BEFORE caching — IconCompat.createWithBitmap
|
||||
// renders the bitmap verbatim, with no shape mask, so a
|
||||
// square thumbnail from the homeserver lands as a square
|
||||
// tile in the shade (visible on Android 12+ where
|
||||
// conversation Person icons used to be auto-rounded by the
|
||||
// OS — this changed). Pre-cropping guarantees a round
|
||||
// visual on every API level instead of relying on the
|
||||
// SystemUI of the day. The original square bitmap is
|
||||
// recycled once the circular copy is in hand.
|
||||
return toCircularBitmap(bmp);
|
||||
}
|
||||
} finally {
|
||||
conn.disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-encode a circular avatar as an adaptive-icon-shaped bitmap:
|
||||
* embeds the avatar inside a transparent canvas whose total size is
|
||||
* 1.5× the avatar so Android's adaptive-icon safe zone (66% of total)
|
||||
* covers the entire avatar without clipping.
|
||||
*
|
||||
* Required for conversation-shortcut icons per docs at
|
||||
* developer.android.com/develop/ui/views/notifications/conversations:
|
||||
* *"To avoid unintentional clipping of your shortcut avatar, provide
|
||||
* an AdaptiveIconDrawable for the shortcut's icon."*
|
||||
*
|
||||
* Without this padding, IconCompat.createWithAdaptiveBitmap would
|
||||
* crop ~17% off every edge of the avatar to fit the safe zone — a
|
||||
* visible mutilation. With it, the shortcut icon renders pixel-
|
||||
* identical to the circular avatar inside the system shade's
|
||||
* conversation slot.
|
||||
*/
|
||||
static Bitmap toAdaptivePaddedBitmap(Bitmap circularAvatar) {
|
||||
int avatarSize = Math.min(circularAvatar.getWidth(), circularAvatar.getHeight());
|
||||
// Pad to 150% so the adaptive safe-zone (66% of canvas = avatarSize)
|
||||
// covers the full avatar. Rounded up to keep the canvas even.
|
||||
int canvasSize = (int) Math.ceil(avatarSize / 0.66f);
|
||||
if (canvasSize % 2 != 0) canvasSize += 1;
|
||||
Bitmap output = Bitmap.createBitmap(canvasSize, canvasSize, Bitmap.Config.ARGB_8888);
|
||||
Canvas canvas = new Canvas(output);
|
||||
int offset = (canvasSize - avatarSize) / 2;
|
||||
canvas.drawBitmap(circularAvatar, offset, offset, null);
|
||||
return output;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return a circular ARGB_8888 bitmap of the source — centre-cropped to
|
||||
* a square if non-square, then masked with a circular path so the
|
||||
* corners are transparent. The source bitmap is recycled.
|
||||
*
|
||||
* Anti-aliased edges via Paint.setAntiAlias on the circle draw — the
|
||||
* BitmapShader copies the source's pixels into the circular region in
|
||||
* a single drawCircle call, which keeps allocation to one output
|
||||
* bitmap (vs the naive "decode → square crop → mask compose" path
|
||||
* that touches three intermediate bitmaps).
|
||||
*/
|
||||
private static Bitmap toCircularBitmap(Bitmap source) {
|
||||
int size = Math.min(source.getWidth(), source.getHeight());
|
||||
Bitmap squareSource;
|
||||
if (source.getWidth() == size && source.getHeight() == size) {
|
||||
squareSource = source;
|
||||
} else {
|
||||
int x = (source.getWidth() - size) / 2;
|
||||
int y = (source.getHeight() - size) / 2;
|
||||
squareSource = Bitmap.createBitmap(source, x, y, size, size);
|
||||
source.recycle();
|
||||
}
|
||||
Bitmap output = Bitmap.createBitmap(size, size, Bitmap.Config.ARGB_8888);
|
||||
Canvas canvas = new Canvas(output);
|
||||
Paint paint = new Paint();
|
||||
paint.setAntiAlias(true);
|
||||
paint.setShader(new BitmapShader(
|
||||
squareSource, Shader.TileMode.CLAMP, Shader.TileMode.CLAMP));
|
||||
float radius = size / 2f;
|
||||
canvas.drawCircle(radius, radius, radius, paint);
|
||||
if (squareSource != source) {
|
||||
squareSource.recycle();
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
private static final class Parsed {
|
||||
final String server;
|
||||
final String mediaId;
|
||||
|
||||
Parsed(String server, String mediaId) {
|
||||
this.server = server;
|
||||
this.mediaId = mediaId;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Split an `mxc://server/mediaId` URL into its two components. Returns
|
||||
* null on any malformed input — caller drops the avatar silently.
|
||||
*/
|
||||
private static Parsed parseMxc(String mxc) {
|
||||
if (mxc == null) return null;
|
||||
final String prefix = "mxc://";
|
||||
if (!mxc.startsWith(prefix)) return null;
|
||||
int slash = mxc.indexOf('/', prefix.length());
|
||||
if (slash < 0 || slash == prefix.length()) return null;
|
||||
String server = mxc.substring(prefix.length(), slash);
|
||||
String mediaId = mxc.substring(slash + 1);
|
||||
if (server.isEmpty() || mediaId.isEmpty()) return null;
|
||||
return new Parsed(server, mediaId);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,44 +0,0 @@
|
|||
package chat.vojo.app;
|
||||
|
||||
import android.app.NotificationManager;
|
||||
import android.content.BroadcastReceiver;
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
|
||||
/**
|
||||
* Dismisses the incoming-call notification when its RTC lifetime expires.
|
||||
*
|
||||
* Scheduled by {@link VojoFirebaseMessagingService} via AlarmManager at
|
||||
* sender_ts + lifetime. The extras carry tag/id so we can target the exact
|
||||
* notification even if multiple ring pushes overlap (rare but possible across
|
||||
* rapid re-dials).
|
||||
*
|
||||
* Also invoked directly (same intent shape) when the app receives a decline /
|
||||
* other-device-answer via the live Matrix sync and wants the system notification
|
||||
* cleared — see Capacitor removeDeliveredNotifications in the JS layer.
|
||||
*/
|
||||
public class CallCancelReceiver extends BroadcastReceiver {
|
||||
|
||||
public static final String ACTION_CANCEL_CALL = "chat.vojo.app.CANCEL_CALL";
|
||||
public static final String EXTRA_NOTIF_TAG = "notif_tag";
|
||||
public static final String EXTRA_NOTIF_ID = "notif_id";
|
||||
// Carried so the receiver can tombstone and drop the matching registry
|
||||
// entry — otherwise a same-eventId re-delivery after expiry would seed the
|
||||
// registry again and render a stale ring on next backgrounding.
|
||||
public static final String EXTRA_NOTIF_EVENT_ID = "notif_event_id";
|
||||
|
||||
@Override
|
||||
public void onReceive(Context context, Intent intent) {
|
||||
if (intent == null || !ACTION_CANCEL_CALL.equals(intent.getAction())) return;
|
||||
String tag = intent.getStringExtra(EXTRA_NOTIF_TAG);
|
||||
int id = intent.getIntExtra(EXTRA_NOTIF_ID, -1);
|
||||
String notifEventId = intent.getStringExtra(EXTRA_NOTIF_EVENT_ID);
|
||||
if (tag == null || id == -1) return;
|
||||
NotificationManager nm =
|
||||
(NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE);
|
||||
if (nm != null) nm.cancel(tag, id);
|
||||
if (notifEventId != null) {
|
||||
VojoFirebaseMessagingService.removeIncomingRing(context, notifEventId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,219 +0,0 @@
|
|||
package chat.vojo.app;
|
||||
|
||||
import android.content.BroadcastReceiver;
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.content.SharedPreferences;
|
||||
import android.util.Log;
|
||||
|
||||
import androidx.core.app.NotificationManagerCompat;
|
||||
|
||||
import org.json.JSONObject;
|
||||
|
||||
import java.io.OutputStream;
|
||||
import java.net.HttpURLConnection;
|
||||
import java.net.URL;
|
||||
import java.net.URLEncoder;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
|
||||
/**
|
||||
* Sends {@code m.call.decline} directly from the notification action tap,
|
||||
* bypassing the WebView boot entirely. Fires via {@code PendingIntent.getBroadcast}
|
||||
* from the CallStyle Decline button — MainActivity never starts, so the
|
||||
* lockscreen-unlock-and-app-flash UX from Phase 2.5.3 is gone.
|
||||
*
|
||||
* Session data ({@code accessToken}, {@code baseUrl}, {@code userId}) is
|
||||
* mirrored from JS into {@code shared_prefs/CapacitorStorage.xml} by
|
||||
* {@code writeSessionBridge()} on client mount.
|
||||
*
|
||||
* Recovery contract:
|
||||
* - Before HTTP: write {@code vojo.pendingDeclines.{notifEventId}} tombstone
|
||||
* so {@code usePendingDeclinesFlusher} can retry on app resume if our
|
||||
* HTTP fails (network drop, token invalidated, process killed mid-PUT).
|
||||
* - On 2xx: remove the tombstone — receiver path succeeded, no flusher work.
|
||||
* - On non-2xx or exception: leave tombstone; flusher drains on next resume.
|
||||
*
|
||||
* Null-session edge case (fresh reinstall + first push before first login):
|
||||
* we cannot send the decline at all — there's no access token, and the
|
||||
* JS-path can't cover us either (a logged-out client has no Matrix session
|
||||
* to call sendRtcDecline against). Cancelling the notification is the only
|
||||
* feedback we can give; leaving the ring would trap the user on a call they
|
||||
* can't accept or decline until the A-side times out.
|
||||
*
|
||||
* Note on idempotency: the flusher's retry generates a new txnId, so on a
|
||||
* split-success-fail sequence (receiver HTTP timed out, flusher succeeds)
|
||||
* we may land two {@code m.call.decline} events in the timeline with the
|
||||
* same {@code rel.event_id}. This is cosmetic — the caller's auto-hangup
|
||||
* hook is idempotent and fires on the first decline.
|
||||
*/
|
||||
public class CallDeclineReceiver extends BroadcastReceiver {
|
||||
|
||||
public static final String ACTION_DECLINE_CALL = "chat.vojo.app.DECLINE_CALL";
|
||||
public static final String EXTRA_ROOM_ID = "room_id";
|
||||
public static final String EXTRA_NOTIF_EVENT_ID = "notif_event_id";
|
||||
public static final String EXTRA_NOTIF_TAG = "notif_tag";
|
||||
public static final String EXTRA_NOTIF_ID = "notif_id";
|
||||
|
||||
private static final String PREFS_FILE = "CapacitorStorage";
|
||||
private static final String SESSION_KEY = "vojo.matrixSession";
|
||||
private static final String PENDING_DECLINES_PREFIX = "vojo.pendingDeclines.";
|
||||
|
||||
private static final int CONNECT_TIMEOUT_MS = 8_000;
|
||||
private static final int READ_TIMEOUT_MS = 8_000;
|
||||
|
||||
private static final String TAG = "CallDeclineReceiver";
|
||||
|
||||
// Single reusable executor keeps us off the main thread without spawning
|
||||
// a fresh one per broadcast — declines come rarely enough that a pool of 1
|
||||
// is fine; a short-lived Thread would also work but is noisier on tracing.
|
||||
private static final ExecutorService EXECUTOR = Executors.newSingleThreadExecutor();
|
||||
|
||||
@Override
|
||||
public void onReceive(Context context, Intent intent) {
|
||||
if (intent == null) return;
|
||||
final String roomId = intent.getStringExtra(EXTRA_ROOM_ID);
|
||||
final String notifEventId = intent.getStringExtra(EXTRA_NOTIF_EVENT_ID);
|
||||
final String notifTag = intent.getStringExtra(EXTRA_NOTIF_TAG);
|
||||
final int notifId = intent.getIntExtra(EXTRA_NOTIF_ID, -1);
|
||||
if (roomId == null || notifEventId == null) {
|
||||
Log.w(TAG, "onReceive: missing extras, abort");
|
||||
return;
|
||||
}
|
||||
|
||||
final Context appContext = context.getApplicationContext();
|
||||
final SharedPreferences prefs =
|
||||
appContext.getSharedPreferences(PREFS_FILE, Context.MODE_PRIVATE);
|
||||
|
||||
final String sessionJson = prefs.getString(SESSION_KEY, null);
|
||||
if (sessionJson == null) {
|
||||
// Fresh reinstall / logged-out: no access token, no Matrix client.
|
||||
// We can't send m.call.decline and neither can the JS-path (no
|
||||
// session to decline from). Cancel the notif so the user isn't
|
||||
// stuck on a ring they can't action. Skip the tombstone — a
|
||||
// retry without a session would be equally impotent.
|
||||
Log.w(TAG, "onReceive: no session in prefs, cancelling notif without HTTP");
|
||||
if (notifTag != null && notifId != -1) {
|
||||
NotificationManagerCompat.from(appContext).cancel(notifTag, notifId);
|
||||
}
|
||||
VojoFirebaseMessagingService.removeIncomingRing(appContext, notifEventId);
|
||||
return;
|
||||
}
|
||||
|
||||
final String accessToken;
|
||||
final String baseUrl;
|
||||
try {
|
||||
JSONObject session = new JSONObject(sessionJson);
|
||||
accessToken = session.optString("accessToken", null);
|
||||
baseUrl = session.optString("baseUrl", null);
|
||||
} catch (Throwable t) {
|
||||
// Do NOT pass the Throwable — JSONException.getMessage() embeds
|
||||
// the malformed input, which here contains the access token.
|
||||
Log.e(TAG, "onReceive: prefs JSON parse failed: " + t.getClass().getSimpleName());
|
||||
// Still drop the native and the registry entry — user tapped Decline,
|
||||
// the ring should not re-surface on next backgrounding just because
|
||||
// we can't send the decline over HTTP.
|
||||
if (notifTag != null && notifId != -1) {
|
||||
NotificationManagerCompat.from(appContext).cancel(notifTag, notifId);
|
||||
}
|
||||
VojoFirebaseMessagingService.removeIncomingRing(appContext, notifEventId);
|
||||
return;
|
||||
}
|
||||
if (accessToken == null || accessToken.isEmpty() || baseUrl == null || baseUrl.isEmpty()) {
|
||||
Log.w(TAG, "onReceive: empty accessToken/baseUrl in session, cancelling ring locally");
|
||||
if (notifTag != null && notifId != -1) {
|
||||
NotificationManagerCompat.from(appContext).cancel(notifTag, notifId);
|
||||
}
|
||||
VojoFirebaseMessagingService.removeIncomingRing(appContext, notifEventId);
|
||||
return;
|
||||
}
|
||||
|
||||
// 1. Cancel first for instant user feedback. Ringtone stops within
|
||||
// NotificationManagerCompat's Binder latency (~tens of ms) — HTTP
|
||||
// completion is irrelevant to the perceived UX.
|
||||
if (notifTag != null && notifId != -1) {
|
||||
NotificationManagerCompat.from(appContext).cancel(notifTag, notifId);
|
||||
}
|
||||
// Drop the registry entry and tombstone the eventId so the next
|
||||
// onPause renderRegistry can't resurrect the declined ring.
|
||||
VojoFirebaseMessagingService.removeIncomingRing(appContext, notifEventId);
|
||||
|
||||
// 2. Write tombstone BEFORE HTTP so the flusher sees work to do
|
||||
// if we fail/die. Remove only on confirmed 2xx.
|
||||
try {
|
||||
JSONObject tombstone = new JSONObject();
|
||||
tombstone.put("roomId", roomId);
|
||||
tombstone.put("ts", System.currentTimeMillis());
|
||||
prefs.edit()
|
||||
.putString(PENDING_DECLINES_PREFIX + notifEventId, tombstone.toString())
|
||||
.apply();
|
||||
} catch (Throwable t) {
|
||||
Log.w(TAG, "onReceive: tombstone write failed (non-fatal)", t);
|
||||
}
|
||||
|
||||
// 3. HTTP PUT off-main on goAsync. Receiver stays alive ~10s for us
|
||||
// to finish; after that Android reclaims the process and the
|
||||
// pending request dies — flusher covers recovery on next resume.
|
||||
final PendingResult pendingResult = goAsync();
|
||||
final String txnId = UUID.randomUUID().toString();
|
||||
EXECUTOR.execute(() -> {
|
||||
try {
|
||||
int status = sendDecline(baseUrl, accessToken, roomId, notifEventId, txnId);
|
||||
if (status >= 200 && status < 300) {
|
||||
prefs.edit().remove(PENDING_DECLINES_PREFIX + notifEventId).apply();
|
||||
Log.d(TAG, "decline PUT ok status=" + status + " room=" + roomId);
|
||||
} else {
|
||||
Log.w(TAG, "decline PUT non-2xx status=" + status + " room=" + roomId);
|
||||
}
|
||||
} catch (Throwable t) {
|
||||
Log.e(TAG, "decline PUT threw", t);
|
||||
} finally {
|
||||
pendingResult.finish();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private int sendDecline(
|
||||
String baseUrl,
|
||||
String accessToken,
|
||||
String roomId,
|
||||
String notifEventId,
|
||||
String txnId
|
||||
) throws Exception {
|
||||
String url = trimTrailingSlash(baseUrl)
|
||||
+ "/_matrix/client/v3/rooms/"
|
||||
+ URLEncoder.encode(roomId, "UTF-8")
|
||||
+ "/send/org.matrix.msc4310.rtc.decline/"
|
||||
+ URLEncoder.encode(txnId, "UTF-8");
|
||||
|
||||
JSONObject relates = new JSONObject();
|
||||
relates.put("rel_type", "m.reference");
|
||||
relates.put("event_id", notifEventId);
|
||||
JSONObject body = new JSONObject();
|
||||
body.put("m.relates_to", relates);
|
||||
byte[] payload = body.toString().getBytes("UTF-8");
|
||||
|
||||
HttpURLConnection conn = null;
|
||||
try {
|
||||
conn = (HttpURLConnection) new URL(url).openConnection();
|
||||
conn.setRequestMethod("PUT");
|
||||
conn.setConnectTimeout(CONNECT_TIMEOUT_MS);
|
||||
conn.setReadTimeout(READ_TIMEOUT_MS);
|
||||
conn.setDoOutput(true);
|
||||
conn.setRequestProperty("Authorization", "Bearer " + accessToken);
|
||||
conn.setRequestProperty("Content-Type", "application/json");
|
||||
conn.setFixedLengthStreamingMode(payload.length);
|
||||
try (OutputStream os = conn.getOutputStream()) {
|
||||
os.write(payload);
|
||||
}
|
||||
return conn.getResponseCode();
|
||||
} finally {
|
||||
if (conn != null) conn.disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
private static String trimTrailingSlash(String s) {
|
||||
return (s != null && s.endsWith("/")) ? s.substring(0, s.length() - 1) : s;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,148 +0,0 @@
|
|||
package chat.vojo.app;
|
||||
|
||||
import android.Manifest;
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.content.pm.PackageManager;
|
||||
import android.os.Build;
|
||||
import android.util.Log;
|
||||
|
||||
import androidx.core.content.ContextCompat;
|
||||
|
||||
import com.getcapacitor.Plugin;
|
||||
import com.getcapacitor.PluginCall;
|
||||
import com.getcapacitor.PluginMethod;
|
||||
import com.getcapacitor.annotation.CapacitorPlugin;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* JS → Android bridge for CallForegroundService lifecycle.
|
||||
*
|
||||
* start / stop map onto startForegroundService / stopService.
|
||||
*
|
||||
* RECORD_AUDIO permission is re-verified here before dispatch: the JS caller
|
||||
* (useAndroidCallForegroundSync) gates on the widget's JoinCall signal, which
|
||||
* implies getUserMedia has run and the grant is in place — but the plugin
|
||||
* checks defensively so the service never attempts startForeground with
|
||||
* TYPE_MICROPHONE without the permission. The manifest declares the service
|
||||
* as foregroundServiceType="microphone" only (no fallback type), so on API
|
||||
* 34+ starting without RECORD_AUDIO would throw ForegroundServiceTypeException.
|
||||
*/
|
||||
@CapacitorPlugin(name = "CallForegroundService")
|
||||
public class CallForegroundPlugin extends Plugin {
|
||||
|
||||
private static final String TAG = "CallFgsPlugin";
|
||||
|
||||
@PluginMethod
|
||||
public void start(PluginCall call) {
|
||||
String title = call.getString("title");
|
||||
String body = call.getString("body");
|
||||
Context ctx = getContext();
|
||||
|
||||
// Defense-in-depth: starting the microphone-typed FGS without
|
||||
// RECORD_AUDIO granted is invalid on API 34+. JS side already gates
|
||||
// on JoinCall (see useAndroidCallForegroundSync) so this should never
|
||||
// fire in practice. If it does, resolve cleanly without starting —
|
||||
// the call will run without retention, which is the same fate as
|
||||
// the first-ever-call window before getUserMedia prompt answered.
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||
int micPerm = ContextCompat.checkSelfPermission(ctx, Manifest.permission.RECORD_AUDIO);
|
||||
if (micPerm != PackageManager.PERMISSION_GRANTED) {
|
||||
Log.w(TAG, "start: RECORD_AUDIO not granted, skipping FGS (would fail on TYPE_MICROPHONE)");
|
||||
call.resolve();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
Intent intent = new Intent(ctx, CallForegroundService.class);
|
||||
if (title != null) intent.putExtra(CallForegroundService.EXTRA_TITLE, title);
|
||||
if (body != null) intent.putExtra(CallForegroundService.EXTRA_BODY, body);
|
||||
try {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
ctx.startForegroundService(intent);
|
||||
} else {
|
||||
ctx.startService(intent);
|
||||
}
|
||||
Log.d(TAG, "start: service started");
|
||||
call.resolve();
|
||||
} catch (Throwable t) {
|
||||
Log.e(TAG, "start: failed to start service", t);
|
||||
call.reject("start_failed: " + t.getClass().getSimpleName() + ": " + t.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
public void stop(PluginCall call) {
|
||||
Context ctx = getContext();
|
||||
try {
|
||||
ctx.stopService(new Intent(ctx, CallForegroundService.class));
|
||||
Log.d(TAG, "stop: stopService dispatched");
|
||||
call.resolve();
|
||||
} catch (Throwable t) {
|
||||
Log.w(TAG, "stop: stopService threw", t);
|
||||
call.reject("stop_failed: " + t.getClass().getSimpleName() + ": " + t.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
// JS upserts a live incoming ring into the native registry (atom ADD
|
||||
// happy-path). Idempotent with any prior FCM seed for the same eventId —
|
||||
// Java merges metadata fields append-only. See VojoFirebaseMessagingService
|
||||
// for the registry operations contract.
|
||||
@PluginMethod
|
||||
public void upsertIncomingRing(PluginCall call) {
|
||||
String eventId = call.getString("eventId");
|
||||
String roomId = call.getString("roomId");
|
||||
if (eventId == null || eventId.isEmpty() || roomId == null || roomId.isEmpty()) {
|
||||
call.reject("missing_eventId_or_roomId");
|
||||
return;
|
||||
}
|
||||
Map<String, String> data = new HashMap<>();
|
||||
data.put("event_id", eventId);
|
||||
data.put("room_id", roomId);
|
||||
String callerName = call.getString("callerName");
|
||||
if (callerName != null && !callerName.isEmpty()) {
|
||||
data.put("sender_display_name", callerName);
|
||||
}
|
||||
// Pass through senderTs/lifetime as strings — registry stores the same
|
||||
// Map<String,String> shape that FCM delivers, and downstream consumers
|
||||
// (scheduleCallNotificationExpiry, isExpired) parseLong them.
|
||||
Long senderTs = call.getLong("senderTs");
|
||||
if (senderTs != null && senderTs > 0) {
|
||||
data.put("content_sender_ts", Long.toString(senderTs));
|
||||
}
|
||||
Long lifetime = call.getLong("lifetime");
|
||||
if (lifetime != null && lifetime > 0) {
|
||||
data.put("content_lifetime", Long.toString(lifetime));
|
||||
}
|
||||
String messageId = call.getString("messageId");
|
||||
// messageId is used as google.message_id in the Answer/Launch PendingIntent
|
||||
// extras — Capacitor PushNotificationsPlugin gates pushNotificationActionPerformed
|
||||
// on containsKey. Empty string also satisfies the gate; we pass the
|
||||
// caller's value through verbatim.
|
||||
boolean seeded = VojoFirebaseMessagingService.upsertIncomingRing(data, messageId);
|
||||
// Mark in NotificationDedup so a polling fire 15 minutes later
|
||||
// doesn't post a "Missed call" notification for a ring the user
|
||||
// already saw live via the in-app strip. Mirrors the FCM-arrival
|
||||
// path in VojoFirebaseMessagingService.onMessageReceived.
|
||||
if (seeded) {
|
||||
NotificationDedup.markNotified(getContext(), eventId);
|
||||
}
|
||||
call.resolve();
|
||||
}
|
||||
|
||||
// JS removes a ring from the native registry (atom REMOVE / suppress path /
|
||||
// native action receiver path). Tombstones the eventId to reject late
|
||||
// FCM or /sync re-seeds within the ring lifetime.
|
||||
@PluginMethod
|
||||
public void removeIncomingRing(PluginCall call) {
|
||||
String eventId = call.getString("eventId");
|
||||
if (eventId == null || eventId.isEmpty()) {
|
||||
call.reject("missing_event_id");
|
||||
return;
|
||||
}
|
||||
VojoFirebaseMessagingService.removeIncomingRing(getContext(), eventId);
|
||||
call.resolve();
|
||||
}
|
||||
}
|
||||
|
|
@ -1,143 +0,0 @@
|
|||
package chat.vojo.app;
|
||||
|
||||
import android.app.NotificationChannel;
|
||||
import android.app.NotificationManager;
|
||||
import android.app.PendingIntent;
|
||||
import android.app.Service;
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.content.pm.ServiceInfo;
|
||||
import android.os.Build;
|
||||
import android.os.IBinder;
|
||||
import android.util.Log;
|
||||
|
||||
import androidx.core.app.NotificationCompat;
|
||||
|
||||
/**
|
||||
* Foreground service kept alive for the duration of an active DM call. Its
|
||||
* sole job is to promote the process to PROCESS_STATE_FOREGROUND_SERVICE so
|
||||
* Android doesn't:
|
||||
* - revoke RECORD_AUDIO via AppOps (API 31+ while-in-use gating);
|
||||
* - apply background network firewall via netd.
|
||||
*
|
||||
* Both revocations were observed in Phase 0 capture on Samsung OneUI API 36:
|
||||
* mic went Active=false ~5s after screen-off, netd isBlocked=true ~13s after,
|
||||
* causing Element Call inside the hidden WebView to tear down the LiveKit
|
||||
* session and the call to drop.
|
||||
*
|
||||
* Preconditions enforced by callers:
|
||||
* - RECORD_AUDIO runtime permission granted (plugin-side check in
|
||||
* CallForegroundPlugin.start). The manifest declares
|
||||
* foregroundServiceType="microphone" only, so TYPE_NONE is not a valid
|
||||
* fallback on API 34+ — we never attempt one.
|
||||
* - JS side gates on useCallJoined so the widget's getUserMedia has already
|
||||
* prompted for and received the grant by the time we start.
|
||||
*/
|
||||
public class CallForegroundService extends Service {
|
||||
|
||||
public static final String EXTRA_TITLE = "title";
|
||||
public static final String EXTRA_BODY = "body";
|
||||
|
||||
private static final String CHANNEL_ID = "vojo_calls_ongoing";
|
||||
// Stable id, distinct from VojoFirebaseMessagingService.SUMMARY_NOTIFICATION_ID
|
||||
// (Integer.MIN_VALUE) and from per-room call ids (String.hashCode of "call_<roomId>").
|
||||
private static final int NOTIFICATION_ID = 0x766F6A6F; // "vojo" as ASCII bytes
|
||||
|
||||
private static final String TAG = "CallFgs";
|
||||
|
||||
@Override
|
||||
public int onStartCommand(Intent intent, int flags, int startId) {
|
||||
String title = intent != null ? intent.getStringExtra(EXTRA_TITLE) : null;
|
||||
String body = intent != null ? intent.getStringExtra(EXTRA_BODY) : null;
|
||||
if (title == null || title.isEmpty()) title = "Активный звонок";
|
||||
if (body == null) body = "";
|
||||
|
||||
NotificationManager nm = (NotificationManager) getSystemService(Context.NOTIFICATION_SERVICE);
|
||||
if (nm == null) {
|
||||
Log.w(TAG, "onStartCommand: NotificationManager null, cannot start FGS");
|
||||
stopSelf(startId);
|
||||
return START_NOT_STICKY;
|
||||
}
|
||||
|
||||
ensureOngoingChannel(nm);
|
||||
|
||||
Intent launchIntent = new Intent(this, MainActivity.class)
|
||||
.setAction(Intent.ACTION_MAIN)
|
||||
.addCategory(Intent.CATEGORY_LAUNCHER)
|
||||
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_SINGLE_TOP);
|
||||
int piFlags = PendingIntent.FLAG_UPDATE_CURRENT
|
||||
| (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M ? PendingIntent.FLAG_IMMUTABLE : 0);
|
||||
PendingIntent launchPI = PendingIntent.getActivity(this, 0, launchIntent, piFlags);
|
||||
|
||||
NotificationCompat.Builder builder = new NotificationCompat.Builder(this, CHANNEL_ID)
|
||||
.setSmallIcon(R.mipmap.ic_launcher)
|
||||
.setContentTitle(title)
|
||||
.setContentText(body)
|
||||
.setCategory(NotificationCompat.CATEGORY_CALL)
|
||||
.setOngoing(true)
|
||||
.setAutoCancel(false)
|
||||
.setOnlyAlertOnce(true)
|
||||
.setPriority(NotificationCompat.PRIORITY_LOW)
|
||||
.setContentIntent(launchPI);
|
||||
|
||||
try {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||
// API 30+: FOREGROUND_SERVICE_TYPE_MICROPHONE constant exists
|
||||
// and 3-arg startForeground is available. API 34+ REQUIRES
|
||||
// the type to match the manifest — we declared `microphone`
|
||||
// and always pass it. RECORD_AUDIO grant is ensured by
|
||||
// CallForegroundPlugin before this code runs.
|
||||
startForeground(NOTIFICATION_ID, builder.build(),
|
||||
ServiceInfo.FOREGROUND_SERVICE_TYPE_MICROPHONE);
|
||||
Log.d(TAG, "startForeground ok type=microphone");
|
||||
} else {
|
||||
// API 24-29: 2-arg form; manifest foregroundServiceType attribute
|
||||
// is enough for the OS to classify the service correctly.
|
||||
startForeground(NOTIFICATION_ID, builder.build());
|
||||
Log.d(TAG, "startForeground ok (pre-R, manifest-driven type)");
|
||||
}
|
||||
} catch (Throwable t) {
|
||||
// If startForeground with TYPE_MICROPHONE throws despite our
|
||||
// precondition checks (unexpected OEM behavior, race, manifest
|
||||
// drift), we intentionally do NOT retry with TYPE_NONE — that is
|
||||
// invalid on API 34+ when the manifest declares `microphone`.
|
||||
// Better to surface the failure and let the call proceed without
|
||||
// retention than to silently crash with ForegroundServiceTypeException.
|
||||
Log.e(TAG, "startForeground threw, stopping service without retry", t);
|
||||
stopSelf(startId);
|
||||
}
|
||||
|
||||
return START_NOT_STICKY;
|
||||
}
|
||||
|
||||
@Override
|
||||
public IBinder onBind(Intent intent) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onDestroy() {
|
||||
Log.d(TAG, "onDestroy");
|
||||
// Belt-and-suspenders: if the service is being stopped via stopService
|
||||
// and the FGS flag is still up, make sure the notification goes away.
|
||||
// Idempotent if stopForeground was already called elsewhere.
|
||||
stopForeground(STOP_FOREGROUND_REMOVE);
|
||||
super.onDestroy();
|
||||
}
|
||||
|
||||
private void ensureOngoingChannel(NotificationManager nm) {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return;
|
||||
if (nm.getNotificationChannel(CHANNEL_ID) != null) return;
|
||||
NotificationChannel channel = new NotificationChannel(
|
||||
CHANNEL_ID,
|
||||
"Активные звонки",
|
||||
NotificationManager.IMPORTANCE_LOW
|
||||
);
|
||||
channel.setDescription("Уведомление во время активного звонка Vojo");
|
||||
channel.setShowBadge(false);
|
||||
channel.enableLights(false);
|
||||
channel.enableVibration(false);
|
||||
channel.setSound(null, null);
|
||||
nm.createNotificationChannel(channel);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,163 +0,0 @@
|
|||
package chat.vojo.app;
|
||||
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.graphics.Bitmap;
|
||||
import android.os.Build;
|
||||
import android.util.Log;
|
||||
|
||||
import androidx.core.content.LocusIdCompat;
|
||||
import androidx.core.content.pm.ShortcutInfoCompat;
|
||||
import androidx.core.content.pm.ShortcutManagerCompat;
|
||||
import androidx.core.graphics.drawable.IconCompat;
|
||||
|
||||
import java.util.Collections;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* Publish a long-lived sharing shortcut for a Matrix room so the system
|
||||
* treats per-room MessagingStyle notifications as conversations on
|
||||
* Android 11+ (API 30+).
|
||||
*
|
||||
* Without a published shortcut whose id matches the notification's
|
||||
* setShortcutId(), Android falls back to the app icon for the collapsed-
|
||||
* preview avatar regardless of Person.setIcon / Builder.setLargeIcon —
|
||||
* Person icons are only consulted by the Conversation styling layer,
|
||||
* which activates exclusively for notifications backed by a real
|
||||
* ShortcutInfoCompat marked Long Lived + the SHORTCUT_CATEGORY_CONVERSATION
|
||||
* sharing category.
|
||||
*
|
||||
* Idempotent: republishing the same shortcut id is the documented "update"
|
||||
* path; ShortcutManagerCompat handles dedup internally. Cheap to call
|
||||
* from the render hot path (~ms on warm system, indistinguishable from a
|
||||
* SharedPreferences write at our scale).
|
||||
*/
|
||||
final class ConversationShortcuts {
|
||||
|
||||
private static final String TAG = "ConvShortcuts";
|
||||
|
||||
private ConversationShortcuts() {}
|
||||
|
||||
/**
|
||||
* Publish or refresh the shortcut backing a room's conversation
|
||||
* notification. No-op on API < 30 — Conversation styling is an
|
||||
* Android 11+ feature; older OS versions render the notification
|
||||
* fine without the shortcut, and the largeIcon/Person.setIcon
|
||||
* pipeline is the primary avatar source on them.
|
||||
*
|
||||
* @param ctx Context for the shortcut manager binding.
|
||||
* @param roomId Matrix room id, used as the shortcut id so it
|
||||
* matches NotificationCompat.Builder.setShortcutId.
|
||||
* @param isDirect Whether the room is a DM; flips the shortcut
|
||||
* category so launchers can group DMs separately.
|
||||
* @param label Short visible label, typically the room name (or
|
||||
* the peer's display name for a DM).
|
||||
* @param avatar Optional cached avatar bitmap. Null falls through
|
||||
* to the app launcher icon — still publishes the
|
||||
* shortcut so the conversation styling activates.
|
||||
*/
|
||||
/**
|
||||
* Returns the published ShortcutInfoCompat so the caller can attach
|
||||
* it directly to the notification via setShortcutInfo() — this is
|
||||
* the documented "atomic publish + bind" path that avoids the race
|
||||
* where the notification posts before the shortcut publish has
|
||||
* settled and Android sees an orphan shortcut id. Null on API < 30,
|
||||
* null on failure (notification still posts cleanly).
|
||||
*/
|
||||
static ShortcutInfoCompat publishForRoom(
|
||||
Context ctx,
|
||||
String roomId,
|
||||
boolean isDirect,
|
||||
String label,
|
||||
Bitmap avatar
|
||||
) {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) {
|
||||
return null;
|
||||
}
|
||||
if (roomId == null || roomId.isEmpty()) return null;
|
||||
try {
|
||||
// Conversation shortcut icon MUST be adaptive — official docs:
|
||||
// "To avoid unintentional clipping of your shortcut avatar,
|
||||
// provide an AdaptiveIconDrawable for the shortcut's icon."
|
||||
// Without this, Android silently falls back to the app's
|
||||
// launcher icon for the collapsed-shade conversation avatar
|
||||
// slot, even though shortcut publish + bind succeed.
|
||||
// Resource icons (mipmap.ic_launcher) already ship with
|
||||
// adaptive layers in the manifest; bitmap avatars need padding
|
||||
// so the safe zone doesn't crop them.
|
||||
IconCompat icon;
|
||||
if (avatar != null) {
|
||||
Bitmap padded = AvatarLoader.toAdaptivePaddedBitmap(avatar);
|
||||
icon = IconCompat.createWithAdaptiveBitmap(padded);
|
||||
} else {
|
||||
icon = IconCompat.createWithResource(ctx, R.mipmap.ic_launcher);
|
||||
}
|
||||
|
||||
// Intent the shortcut launches when tapped from the launcher
|
||||
// long-press menu or share sheet — opens MainActivity and
|
||||
// delivers the same `room_id` extra the notification tap
|
||||
// path uses, so the existing pushNotificationActionPerformed
|
||||
// listener navigates correctly.
|
||||
Intent launchIntent = new Intent(ctx, MainActivity.class)
|
||||
.setAction(Intent.ACTION_VIEW)
|
||||
.addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP | Intent.FLAG_ACTIVITY_CLEAR_TOP)
|
||||
.putExtra("room_id", roomId)
|
||||
// Capacitor PushNotificationsPlugin gates its action
|
||||
// delivery on bundle.containsKey("google.message_id"); we
|
||||
// attach an empty value so a launcher-initiated open
|
||||
// takes the same path as a push-tap.
|
||||
.putExtra("google.message_id", "");
|
||||
|
||||
// Constant value of androidx.core's
|
||||
// ShortcutInfoCompat.SHORTCUT_CATEGORY_CONVERSATION. Hardcoded
|
||||
// verbatim because older androidx.core in our dependency
|
||||
// graph doesn't export the constant; the string itself is
|
||||
// platform-stable per the Android shortcut category contract.
|
||||
Set<String> categories =
|
||||
Collections.singleton("android.shortcut.conversation");
|
||||
|
||||
ShortcutInfoCompat.Builder b = new ShortcutInfoCompat.Builder(ctx, roomId)
|
||||
.setShortLabel(label != null && !label.isEmpty() ? label : "Vojo")
|
||||
.setLongLabel(label != null && !label.isEmpty() ? label : "Vojo")
|
||||
.setIntent(launchIntent)
|
||||
.setIcon(icon)
|
||||
.setLongLived(true)
|
||||
.setCategories(categories)
|
||||
// LocusId mirrors the shortcut id; the OS uses it to
|
||||
// attribute the notification to a specific conversation
|
||||
// for digital-wellbeing dashboards and bubble grouping.
|
||||
.setLocusId(new LocusIdCompat(roomId))
|
||||
// Marks isDirect so launchers / share sheet can present
|
||||
// person-style affordances on DMs.
|
||||
.setIsConversation();
|
||||
// setPerson is only needed for one-on-one conversations to
|
||||
// unlock direct-share suggestions, but for a DM we also want
|
||||
// it to anchor the shortcut on the peer's identity. Skipped
|
||||
// for groups (single Person doesn't represent the room).
|
||||
if (isDirect) {
|
||||
b.setPerson(new androidx.core.app.Person.Builder()
|
||||
// setKey must match the Person.key used in the
|
||||
// MessagingStyle so Android's conversation
|
||||
// attribution matches the shortcut to the
|
||||
// notification on the same identity.
|
||||
.setKey(roomId)
|
||||
.setName(label != null ? label : "")
|
||||
.setIcon(icon)
|
||||
.build());
|
||||
}
|
||||
|
||||
ShortcutInfoCompat shortcut = b.build();
|
||||
boolean ok = ShortcutManagerCompat.pushDynamicShortcut(ctx, shortcut);
|
||||
Log.i(TAG, "publish room=" + roomId + " label=" + label
|
||||
+ " hasAvatar=" + (avatar != null) + " ok=" + ok);
|
||||
return shortcut;
|
||||
} catch (Throwable t) {
|
||||
// Shortcut publish is best-effort UX — a failure must not
|
||||
// sink the notification. Worst case: collapsed preview
|
||||
// falls back to app icon (same as before the shortcut path
|
||||
// existed at all).
|
||||
Log.w(TAG, "publish failed room=" + roomId, t);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,75 +0,0 @@
|
|||
package chat.vojo.app;
|
||||
|
||||
import android.app.NotificationManager;
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.net.Uri;
|
||||
import android.os.Build;
|
||||
import android.provider.Settings;
|
||||
|
||||
import com.getcapacitor.JSObject;
|
||||
import com.getcapacitor.Plugin;
|
||||
import com.getcapacitor.PluginCall;
|
||||
import com.getcapacitor.PluginMethod;
|
||||
import com.getcapacitor.annotation.CapacitorPlugin;
|
||||
|
||||
/**
|
||||
* Bridges Android 14+ (API 34) full-screen-intent opt-in into JS.
|
||||
*
|
||||
* On API 34 `USE_FULL_SCREEN_INTENT` was reclassified from "normal" to
|
||||
* "special appop" — declaring it in the manifest is no longer enough to
|
||||
* actually display a full-screen notification over the lockscreen. The user
|
||||
* must opt in via Settings → Apps → Vojo → Full-screen notifications. There's
|
||||
* no runtime grant API, only a deep-link.
|
||||
*
|
||||
* Without the opt-in, `setFullScreenIntent(launchPI, true)` still satisfies
|
||||
* the AOSP NotificationManagerService gate (so CallStyle doesn't get silently
|
||||
* dropped), but the notification renders as a regular heads-up and the screen
|
||||
* doesn't wake over the lockscreen — which was the "why is this just a banner
|
||||
* on the lockscreen?" symptom we saw on the Samsung OneUI test device.
|
||||
*
|
||||
* See docs/plans/dm_calls.md ADR 2.5-fsi for the full history.
|
||||
*/
|
||||
@CapacitorPlugin(name = "FullScreenIntent")
|
||||
public class FullScreenIntentPlugin extends Plugin {
|
||||
|
||||
@PluginMethod
|
||||
public void canUseFullScreenIntent(PluginCall call) {
|
||||
JSObject ret = new JSObject();
|
||||
ret.put("value", canUseFullScreenIntentInternal());
|
||||
call.resolve(ret);
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
public void openSettings(PluginCall call) {
|
||||
Context ctx = getContext();
|
||||
Intent intent;
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
|
||||
// API 34+ has a dedicated Settings screen for the full-screen notification opt-in.
|
||||
intent = new Intent(Settings.ACTION_MANAGE_APP_USE_FULL_SCREEN_INTENT);
|
||||
intent.setData(Uri.parse("package:" + ctx.getPackageName()));
|
||||
} else {
|
||||
// Fallback for API ≤33: the per-app notification Settings page is the closest
|
||||
// equivalent and also covers channel-level toggles (mute, DND bypass, etc).
|
||||
intent = new Intent(Settings.ACTION_APP_NOTIFICATION_SETTINGS);
|
||||
intent.putExtra(Settings.EXTRA_APP_PACKAGE, ctx.getPackageName());
|
||||
}
|
||||
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
|
||||
try {
|
||||
ctx.startActivity(intent);
|
||||
call.resolve();
|
||||
} catch (Throwable t) {
|
||||
call.reject("Failed to open FSI settings: " + t.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private boolean canUseFullScreenIntentInternal() {
|
||||
// On API ≤33 `USE_FULL_SCREEN_INTENT` is a normal permission — if it's
|
||||
// declared in the manifest, the app already has it. Skip the runtime check.
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.UPSIDE_DOWN_CAKE) return true;
|
||||
NotificationManager nm = (NotificationManager)
|
||||
getContext().getSystemService(Context.NOTIFICATION_SERVICE);
|
||||
if (nm == null) return false;
|
||||
return nm.canUseFullScreenIntent();
|
||||
}
|
||||
}
|
||||
|
|
@ -1,16 +0,0 @@
|
|||
package chat.vojo.app;
|
||||
|
||||
import com.getcapacitor.Plugin;
|
||||
import com.getcapacitor.PluginCall;
|
||||
import com.getcapacitor.PluginMethod;
|
||||
import com.getcapacitor.annotation.CapacitorPlugin;
|
||||
|
||||
@CapacitorPlugin(name = "LaunchSplash")
|
||||
public class LaunchSplashPlugin extends Plugin {
|
||||
|
||||
@PluginMethod
|
||||
public void ready(PluginCall call) {
|
||||
MainActivity.releaseLaunchSplash();
|
||||
call.resolve();
|
||||
}
|
||||
}
|
||||
|
|
@ -1,128 +0,0 @@
|
|||
package chat.vojo.app;
|
||||
|
||||
import android.os.Bundle;
|
||||
import android.os.Handler;
|
||||
import android.os.Looper;
|
||||
import androidx.activity.EdgeToEdge;
|
||||
import androidx.core.splashscreen.SplashScreen;
|
||||
import androidx.core.view.WindowCompat;
|
||||
import androidx.core.view.WindowInsetsControllerCompat;
|
||||
import com.getcapacitor.BridgeActivity;
|
||||
|
||||
public class MainActivity extends BridgeActivity {
|
||||
public static volatile boolean isInForeground = false;
|
||||
private static volatile boolean launchSplashReady = false;
|
||||
|
||||
// Safety net for setKeepOnScreenCondition: if JS never calls
|
||||
// launchSplash.ready() (boot crash, exception during config load before
|
||||
// AuthMascot mounts, network hang in useClientConfig, deep-link straight
|
||||
// into AuthLayout where the centered AuthMascot variant doesn't render,
|
||||
// …) the splash would otherwise hang indefinitely and the user can't
|
||||
// interact with anything. 5s covers normal cold boots on mid-range
|
||||
// Android (config + bundle parse + first paint typically lands inside
|
||||
// 1-2s) with comfortable headroom; past it we drop the splash and let
|
||||
// whatever the web side has rendered take over — including blank
|
||||
// AuthLayout, which is at least recoverable.
|
||||
private static final long SPLASH_SAFETY_TIMEOUT_MS = 5000L;
|
||||
|
||||
// Short debounce on the onPause→renderRegistry edge so an in-flight JS
|
||||
// removeIncomingRing bridge call (e.g. user accepted/declined, then
|
||||
// immediately pressed Home) has a chance to land before we post native
|
||||
// CallStyle for a ring that's about to be removed. 150ms covers the
|
||||
// strip-accept chain (Capacitor roundtrip + switchOrStartDmCall
|
||||
// resolve + sync-effect → bridge) on mid-range Android; imperceptible
|
||||
// as a silent-ring delay.
|
||||
private static final long RENDER_DEBOUNCE_MS = 150L;
|
||||
// Modest debounce on the onResume→cancelRenderedIncomingRings edge so JS
|
||||
// has a moment to hydrate incomingCallsAtom before the native surface
|
||||
// goes away. Covers warm resume (hook alive, /sync delivers an ADD
|
||||
// within ~100-200ms) cleanly. Cold resume (killed process, Matrix
|
||||
// client rehydration takes 1-3s) still has a "no surface" window —
|
||||
// acceptable tradeoff since tap-native flows carry call_action and
|
||||
// are handled by pendingCallActionConsumer regardless of atom state.
|
||||
private static final long CANCEL_DEBOUNCE_MS = 300L;
|
||||
private final Handler lifecycleHandler = new Handler(Looper.getMainLooper());
|
||||
private final Runnable renderRunnable = () ->
|
||||
VojoFirebaseMessagingService.renderRegistry(this);
|
||||
private final Runnable cancelRunnable = () ->
|
||||
VojoFirebaseMessagingService.cancelRenderedIncomingRings(this);
|
||||
|
||||
public static void releaseLaunchSplash() {
|
||||
launchSplashReady = true;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onCreate(Bundle savedInstanceState) {
|
||||
if (savedInstanceState == null) {
|
||||
launchSplashReady = false;
|
||||
}
|
||||
|
||||
// Custom plugins must be registered before super.onCreate so BridgeActivity
|
||||
// can wire them into the WebView bridge on load. Registering after
|
||||
// super.onCreate would make the plugin invisible to JS until the next relaunch.
|
||||
registerPlugin(FullScreenIntentPlugin.class);
|
||||
registerPlugin(CallForegroundPlugin.class);
|
||||
registerPlugin(LaunchSplashPlugin.class);
|
||||
registerPlugin(ShareTargetPlugin.class);
|
||||
registerPlugin(PollingPlugin.class);
|
||||
|
||||
// AndroidX SplashScreen must be installed before super.onCreate().
|
||||
// Keep it until the web splash confirms its first visible frame is
|
||||
// ready, OR the safety timeout elapses (see SPLASH_SAFETY_TIMEOUT_MS).
|
||||
final long splashStartMs = System.currentTimeMillis();
|
||||
SplashScreen splashScreen = SplashScreen.installSplashScreen(this);
|
||||
splashScreen.setKeepOnScreenCondition(() -> {
|
||||
if (launchSplashReady) return false;
|
||||
return System.currentTimeMillis() - splashStartMs < SPLASH_SAFETY_TIMEOUT_MS;
|
||||
});
|
||||
|
||||
EdgeToEdge.enable(this);
|
||||
super.onCreate(savedInstanceState);
|
||||
// Force light icons on both system bars: our CSS is permanently dark
|
||||
// (Dawn redesign), but EdgeToEdge.enable auto-detects icon tint from
|
||||
// the device uiMode — on a light-mode device that gives dark icons
|
||||
// over our dark bars and they vanish.
|
||||
WindowInsetsControllerCompat controller =
|
||||
WindowCompat.getInsetsController(getWindow(), getWindow().getDecorView());
|
||||
controller.setAppearanceLightStatusBars(false);
|
||||
controller.setAppearanceLightNavigationBars(false);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onResume() {
|
||||
super.onResume();
|
||||
isInForeground = true;
|
||||
// Cancel any pending render: user came back before the debounce fired,
|
||||
// JS strip will own UX, no need to surface native.
|
||||
lifecycleHandler.removeCallbacks(renderRunnable);
|
||||
// Defer the native cancel so JS strip has a moment to hydrate from
|
||||
// incomingCallsAtom. Registry entries persist — they still represent
|
||||
// live rings, just the native surfaces go.
|
||||
lifecycleHandler.removeCallbacks(cancelRunnable);
|
||||
lifecycleHandler.postDelayed(cancelRunnable, CANCEL_DEBOUNCE_MS);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onPause() {
|
||||
super.onPause();
|
||||
isInForeground = false;
|
||||
// Re-backgrounding: don't cancel a native the user will still need
|
||||
// visible. The render runnable below will re-render if needed.
|
||||
lifecycleHandler.removeCallbacks(cancelRunnable);
|
||||
// Schedule render — user is backgrounding, JS audio gate is about to
|
||||
// close, native CallStyle must surface or the ring goes silent. Debounce
|
||||
// absorbs the bridge-call race: if onResume fires within RENDER_DEBOUNCE_MS
|
||||
// (user bounce), the render is cancelled.
|
||||
lifecycleHandler.removeCallbacks(renderRunnable);
|
||||
lifecycleHandler.postDelayed(renderRunnable, RENDER_DEBOUNCE_MS);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onDestroy() {
|
||||
// Drop any pending render/cancel so runnables — which capture `this` —
|
||||
// can't fire post-destroy and land an nm.notify / nm.cancel against a
|
||||
// dead Activity context on config change (rotation) or process teardown.
|
||||
lifecycleHandler.removeCallbacksAndMessages(null);
|
||||
super.onDestroy();
|
||||
}
|
||||
}
|
||||
|
|
@ -1,147 +0,0 @@
|
|||
package chat.vojo.app;
|
||||
|
||||
import android.content.BroadcastReceiver;
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.content.SharedPreferences;
|
||||
import android.util.Log;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.net.HttpURLConnection;
|
||||
import java.net.URL;
|
||||
import java.net.URLEncoder;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
|
||||
/**
|
||||
* Handles the per-notification "Mark as read" action.
|
||||
*
|
||||
* Posts {@code POST /_matrix/client/v3/rooms/{roomId}/receipt/m.read/{eventId}}
|
||||
* using the access token saved by the polling lifecycle in
|
||||
* {@code vojo_poll_state} SharedPreferences (same storage VojoPollWorker uses;
|
||||
* keeps the credential lifecycle single-sourced). After a successful 2xx the
|
||||
* per-room MessagingStyle notification is dismissed and the
|
||||
* {@link RoomMessageCache} is cleared so the next push to that room starts a
|
||||
* fresh conversation rather than re-appending to the prior history.
|
||||
*
|
||||
* Dismiss policy: OPTIMISTIC. The per-room notification is dismissed
|
||||
* synchronously in onReceive — before the HTTP receipt PUT is even
|
||||
* attempted — so the user sees instant feedback. The async receipt POST
|
||||
* happens on a worker thread afterwards. This mirrors element-android's
|
||||
* NotificationBroadcastReceiver pattern and matches the user's mental
|
||||
* model ("I tapped, it should disappear immediately").
|
||||
*
|
||||
* Failure mode: on any non-2xx or thrown exception we accept that the
|
||||
* server-side read receipt did not land. We do NOT re-post the
|
||||
* notification or implement a flusher because:
|
||||
* - the next room open from the JS app issues a fresh read-receipt
|
||||
* for the latest visible event, catching up the server state
|
||||
* - the in-app read-marker logic is the authoritative path; this
|
||||
* receiver is a convenience for the shade-tap shortcut
|
||||
* - accumulating tombstones in prefs (the CallDeclineReceiver pattern)
|
||||
* would risk leaking historical eventIds the JS side would re-issue
|
||||
* on app resume anyway
|
||||
*
|
||||
* Null-credential edge case (fresh install + first push before any
|
||||
* saveSession bridge): no token to use, we still dismiss the notification
|
||||
* locally so the user isn't stuck looking at a "stuck" Mark-as-read
|
||||
* button. The next room open from JS covers the server view.
|
||||
*/
|
||||
public class MarkAsReadReceiver extends BroadcastReceiver {
|
||||
|
||||
public static final String ACTION_MARK_AS_READ = "chat.vojo.app.MARK_AS_READ";
|
||||
public static final String EXTRA_ROOM_ID = "room_id";
|
||||
public static final String EXTRA_EVENT_ID = "event_id";
|
||||
|
||||
private static final int CONNECT_TIMEOUT_MS = 8_000;
|
||||
private static final int READ_TIMEOUT_MS = 8_000;
|
||||
private static final String TAG = "MarkAsReadRcvr";
|
||||
|
||||
private static final ExecutorService EXECUTOR = Executors.newSingleThreadExecutor();
|
||||
|
||||
@Override
|
||||
public void onReceive(Context context, Intent intent) {
|
||||
if (intent == null) return;
|
||||
final String roomId = intent.getStringExtra(EXTRA_ROOM_ID);
|
||||
final String eventId = intent.getStringExtra(EXTRA_EVENT_ID);
|
||||
if (roomId == null || roomId.isEmpty()) {
|
||||
Log.w(TAG, "onReceive: missing room_id, abort");
|
||||
return;
|
||||
}
|
||||
|
||||
final Context appContext = context.getApplicationContext();
|
||||
// Dismiss first for instant UX feedback — HTTP latency is irrelevant
|
||||
// to the perceived "marked as read" action.
|
||||
VojoFirebaseMessagingService.dismissRoomNotification(appContext, roomId);
|
||||
|
||||
final SharedPreferences prefs = appContext.getSharedPreferences(
|
||||
VojoPollWorker.PREFS, Context.MODE_PRIVATE);
|
||||
final String token = prefs.getString(VojoPollWorker.KEY_ACCESS_TOKEN, null);
|
||||
final String homeserver = prefs.getString(VojoPollWorker.KEY_HOMESERVER_URL, null);
|
||||
if (token == null || token.isEmpty() || homeserver == null || homeserver.isEmpty()) {
|
||||
Log.w(TAG, "onReceive: no credentials in prefs, local dismiss only");
|
||||
return;
|
||||
}
|
||||
if (eventId == null || eventId.isEmpty()) {
|
||||
// Without an eventId we cannot issue a receipt PUT — the JS-side
|
||||
// read-marker handler will catch this up on the next room open.
|
||||
Log.w(TAG, "onReceive: no event_id, local dismiss only");
|
||||
return;
|
||||
}
|
||||
|
||||
final PendingResult pendingResult = goAsync();
|
||||
EXECUTOR.execute(() -> {
|
||||
try {
|
||||
int status = sendReceipt(homeserver, token, roomId, eventId);
|
||||
if (status >= 200 && status < 300) {
|
||||
if (BuildConfig.DEBUG) {
|
||||
Log.d(TAG, "receipt ok status=" + status + " room=" + roomId);
|
||||
}
|
||||
} else {
|
||||
Log.w(TAG, "receipt non-2xx status=" + status + " room=" + roomId);
|
||||
}
|
||||
} catch (Throwable t) {
|
||||
Log.w(TAG, "receipt threw room=" + roomId, t);
|
||||
} finally {
|
||||
pendingResult.finish();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private int sendReceipt(
|
||||
String baseUrl,
|
||||
String accessToken,
|
||||
String roomId,
|
||||
String eventId
|
||||
) throws IOException {
|
||||
String url = trimTrailingSlash(baseUrl)
|
||||
+ "/_matrix/client/v3/rooms/"
|
||||
+ URLEncoder.encode(roomId, "UTF-8")
|
||||
+ "/receipt/m.read/"
|
||||
+ URLEncoder.encode(eventId, "UTF-8");
|
||||
|
||||
HttpURLConnection conn = (HttpURLConnection) new URL(url).openConnection();
|
||||
try {
|
||||
conn.setRequestMethod("POST");
|
||||
conn.setRequestProperty("Authorization", "Bearer " + accessToken);
|
||||
conn.setRequestProperty("Content-Type", "application/json");
|
||||
conn.setConnectTimeout(CONNECT_TIMEOUT_MS);
|
||||
conn.setReadTimeout(READ_TIMEOUT_MS);
|
||||
conn.setDoOutput(true);
|
||||
// Empty JSON body per spec; setFixedLengthStreamingMode keeps the
|
||||
// connection on the cached path instead of chunked-transfer fallback.
|
||||
byte[] payload = "{}".getBytes("UTF-8");
|
||||
conn.setFixedLengthStreamingMode(payload.length);
|
||||
try (java.io.OutputStream os = conn.getOutputStream()) {
|
||||
os.write(payload);
|
||||
}
|
||||
return conn.getResponseCode();
|
||||
} finally {
|
||||
conn.disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
private static String trimTrailingSlash(String s) {
|
||||
return (s != null && s.endsWith("/")) ? s.substring(0, s.length() - 1) : s;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,104 +0,0 @@
|
|||
package chat.vojo.app;
|
||||
|
||||
import android.content.Context;
|
||||
import android.content.SharedPreferences;
|
||||
|
||||
import java.util.Iterator;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* Cross-source LRU dedup for rendered push event_ids.
|
||||
*
|
||||
* Both the FCM service (after a successful nm.notify) and the polling Worker
|
||||
* write into the same bounded SharedPreferences-backed set. The Worker reads
|
||||
* it to skip events FCM already delivered — which fixes the regression where
|
||||
* a user who dismissed an FCM notification before polling fired would see
|
||||
* the same event resurface up to 15 minutes later via the polling fallback.
|
||||
*
|
||||
* The native `eventId.hashCode()` notification-id slot is still the primary
|
||||
* dedup for *concurrent* render (Android NotificationManager replace), but
|
||||
* that only collapses surfaces while both notifications are still visible;
|
||||
* once the user dismisses, the slot is empty and the second render would
|
||||
* post fresh. This shared set covers that gap.
|
||||
*
|
||||
* Synchronisation: SharedPreferences read-modify-write is not atomic across
|
||||
* threads/processes, and FCM service runs on a Firebase-managed background
|
||||
* thread while the Worker runs on WorkManager's executor. We serialise all
|
||||
* mutations through a static lock. Critical sections are short (string split
|
||||
* + LinkedHashSet trim + putString) — no Binder calls.
|
||||
*/
|
||||
final class NotificationDedup {
|
||||
|
||||
// Capacity is intentionally larger than VojoPollWorker's worst-case per-run
|
||||
// event count (MAX_PAGES_PER_RUN × PAGE_LIMIT = 250). If a single fire
|
||||
// marks 250 events and the cap were 200, the 50 oldest of those would
|
||||
// already be evicted by the time we finish writing — so a sibling poll
|
||||
// resuming the same window would re-render them. 500 gives 2× headroom
|
||||
// while staying ~12 KB in SharedPreferences (negligible).
|
||||
private static final int MAX_TRACKED = 500;
|
||||
private static final Object lock = new Object();
|
||||
|
||||
private NotificationDedup() {}
|
||||
|
||||
/** Returns true iff the given event_id has been notified in a recent cycle. */
|
||||
static boolean wasNotified(Context ctx, String eventId) {
|
||||
if (eventId == null || eventId.isEmpty()) return false;
|
||||
synchronized (lock) {
|
||||
return readSet(ctx).contains(eventId);
|
||||
}
|
||||
}
|
||||
|
||||
/** Append the event_id to the LRU set, trimming the oldest when full. */
|
||||
static void markNotified(Context ctx, String eventId) {
|
||||
if (eventId == null || eventId.isEmpty()) return;
|
||||
synchronized (lock) {
|
||||
Set<String> set = readSet(ctx);
|
||||
// LinkedHashSet preserves insertion order — re-adding moves to tail
|
||||
// only if we remove-then-add. The Set#add no-op on a present entry
|
||||
// does NOT refresh position, but the simple "drop oldest" trim
|
||||
// below is adequate for our scale and matches the Worker's
|
||||
// existing semantics. Skip the disk write entirely when add()
|
||||
// returned false — the event was already in the set, persistence
|
||||
// would just churn SharedPreferences for no state change.
|
||||
if (!set.add(eventId)) return;
|
||||
if (set.size() > MAX_TRACKED) {
|
||||
Iterator<String> it = set.iterator();
|
||||
int drop = set.size() - MAX_TRACKED;
|
||||
while (it.hasNext() && drop > 0) {
|
||||
it.next();
|
||||
it.remove();
|
||||
drop -= 1;
|
||||
}
|
||||
}
|
||||
writeSet(ctx, set);
|
||||
}
|
||||
}
|
||||
|
||||
/** Caller must hold {@link #lock}. */
|
||||
private static Set<String> readSet(Context ctx) {
|
||||
SharedPreferences prefs = ctx.getSharedPreferences(
|
||||
VojoPollWorker.PREFS, Context.MODE_PRIVATE);
|
||||
String raw = prefs.getString(VojoPollWorker.KEY_NOTIFIED_IDS, "");
|
||||
Set<String> out = new LinkedHashSet<>();
|
||||
if (raw.isEmpty()) return out;
|
||||
for (String id : raw.split(",")) {
|
||||
if (!id.isEmpty()) out.add(id);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Caller must hold {@link #lock}. */
|
||||
private static void writeSet(Context ctx, Set<String> set) {
|
||||
SharedPreferences prefs = ctx.getSharedPreferences(
|
||||
VojoPollWorker.PREFS, Context.MODE_PRIVATE);
|
||||
StringBuilder sb = new StringBuilder(set.size() * 25);
|
||||
boolean first = true;
|
||||
for (String id : set) {
|
||||
if (!first) sb.append(',');
|
||||
sb.append(id);
|
||||
first = false;
|
||||
}
|
||||
prefs.edit().putString(VojoPollWorker.KEY_NOTIFIED_IDS, sb.toString()).apply();
|
||||
}
|
||||
}
|
||||
|
|
@ -1,37 +0,0 @@
|
|||
package chat.vojo.app;
|
||||
|
||||
import android.content.BroadcastReceiver;
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.util.Log;
|
||||
|
||||
/**
|
||||
* Fires when the user swipes a per-room MessagingStyle notification away.
|
||||
*
|
||||
* Without this hook, RoomMessageCache would still hold the prior messages
|
||||
* for that room — and the next push would append onto that history and
|
||||
* re-surface the messages the user just dismissed. With it, swipe clears
|
||||
* the cache so the next push starts a fresh conversation for the room.
|
||||
*
|
||||
* NOTE: this only fires for user-driven dismissals — programmatic
|
||||
* nm.cancel calls (mark-as-read, receipt-driven dismiss, channel migration)
|
||||
* already call RoomMessageCache.clear themselves and do NOT fire the
|
||||
* delete intent. There's no double-clear risk.
|
||||
*/
|
||||
public class NotificationDismissReceiver extends BroadcastReceiver {
|
||||
|
||||
public static final String ACTION_NOTIFICATION_DISMISSED =
|
||||
"chat.vojo.app.NOTIFICATION_DISMISSED";
|
||||
public static final String EXTRA_ROOM_ID = "room_id";
|
||||
|
||||
private static final String TAG = "DismissRcvr";
|
||||
|
||||
@Override
|
||||
public void onReceive(Context context, Intent intent) {
|
||||
if (intent == null) return;
|
||||
String roomId = intent.getStringExtra(EXTRA_ROOM_ID);
|
||||
if (roomId == null || roomId.isEmpty()) return;
|
||||
if (BuildConfig.DEBUG) Log.d(TAG, "swipe clear cache room=" + roomId);
|
||||
RoomMessageCache.clear(roomId);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,236 +0,0 @@
|
|||
package chat.vojo.app;
|
||||
|
||||
import android.content.Context;
|
||||
import android.content.SharedPreferences;
|
||||
import android.util.Log;
|
||||
|
||||
import androidx.work.Constraints;
|
||||
import androidx.work.ExistingPeriodicWorkPolicy;
|
||||
import androidx.work.NetworkType;
|
||||
import androidx.work.PeriodicWorkRequest;
|
||||
import androidx.work.WorkManager;
|
||||
|
||||
import com.getcapacitor.JSObject;
|
||||
import com.getcapacitor.Plugin;
|
||||
import com.getcapacitor.PluginCall;
|
||||
import com.getcapacitor.PluginMethod;
|
||||
import com.getcapacitor.annotation.CapacitorPlugin;
|
||||
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
/**
|
||||
* JS ↔ Android bridge for the WorkManager-based polling fallback.
|
||||
*
|
||||
* Lifecycle:
|
||||
* - JS calls saveSession({accessToken, homeserverUrl, userId}) on login,
|
||||
* on push (re)enable, and on visibilitychange → visible (to recover a
|
||||
* 401-cleared credentials slot without a full remount).
|
||||
* - JS calls schedule({intervalMinutes}) once push is enabled. Idempotent:
|
||||
* KEEP policy means a second schedule() call against an already-enqueued
|
||||
* worker is a no-op (the running period continues unchanged).
|
||||
* - JS calls saveRoomNames({names}) on mount + visibilitychange → visible
|
||||
* so VojoPollWorker has a local cache to resolve room_id → display name
|
||||
* without making N extra GET /rooms/{id}/state/m.room.name requests.
|
||||
* Brand-new rooms created between visibility events fall back to
|
||||
* sender_display_name in the renderer.
|
||||
* - JS calls cancel() + clearSession() on logout / push disable.
|
||||
*
|
||||
* Worker tag: a single unique periodic worker named UNIQUE_WORK_NAME — KEEP
|
||||
* policy prevents schedule churn from re-creating it. Cancel() removes it
|
||||
* by the same name.
|
||||
*/
|
||||
@CapacitorPlugin(name = "Polling")
|
||||
public class PollingPlugin extends Plugin {
|
||||
|
||||
private static final String TAG = "PollingPlugin";
|
||||
private static final String UNIQUE_WORK_NAME = "vojo_push_poll";
|
||||
|
||||
// Android's hard floor for PeriodicWorkRequest. Requests with shorter
|
||||
// intervals are silently clamped to 15 minutes. We accept the requested
|
||||
// value from JS but enforce the floor here so misuse from JS doesn't
|
||||
// produce a silently-different behavior.
|
||||
private static final long MIN_INTERVAL_MINUTES = 15;
|
||||
|
||||
@PluginMethod
|
||||
public void saveSession(PluginCall call) {
|
||||
String accessToken = call.getString("accessToken");
|
||||
String homeserverUrl = call.getString("homeserverUrl");
|
||||
if (accessToken == null || accessToken.isEmpty()
|
||||
|| homeserverUrl == null || homeserverUrl.isEmpty()) {
|
||||
call.reject("missing_accessToken_or_homeserverUrl");
|
||||
return;
|
||||
}
|
||||
String userId = call.getString("userId");
|
||||
SharedPreferences prefs = getContext()
|
||||
.getSharedPreferences(VojoPollWorker.PREFS, Context.MODE_PRIVATE);
|
||||
SharedPreferences.Editor editor = prefs.edit()
|
||||
.putString(VojoPollWorker.KEY_ACCESS_TOKEN, accessToken)
|
||||
.putString(VojoPollWorker.KEY_HOMESERVER_URL, homeserverUrl);
|
||||
if (userId != null && !userId.isEmpty()) {
|
||||
editor.putString(VojoPollWorker.KEY_USER_ID, userId);
|
||||
}
|
||||
// Seed the watermark to "now minus a small clock-skew buffer" on the
|
||||
// first saveSession after install / logout. Without seeding the
|
||||
// Worker's first fire sees watermark=0 and renders every historical
|
||||
// unread /notifications entry as a fresh push. The buffer covers the
|
||||
// case where the device clock runs ahead of the homeserver's clock —
|
||||
// event ts is server-side, so a too-fresh local seed would silently
|
||||
// skip recently-arrived events as "older than watermark" forever.
|
||||
// 60s tolerates typical NTP drift while still suppressing days-old
|
||||
// backlog on first enable. We seed only when the key is absent so
|
||||
// subsequent saveSession calls (token rotation, visibilitychange
|
||||
// re-bridge) don't reset live state.
|
||||
if (!prefs.contains(VojoPollWorker.KEY_LAST_SEEN_TS)) {
|
||||
editor.putLong(
|
||||
VojoPollWorker.KEY_LAST_SEEN_TS,
|
||||
System.currentTimeMillis() - SEED_CLOCK_SKEW_BUFFER_MS
|
||||
);
|
||||
}
|
||||
editor.apply();
|
||||
call.resolve();
|
||||
}
|
||||
|
||||
private static final long SEED_CLOCK_SKEW_BUFFER_MS = 60_000L;
|
||||
|
||||
@PluginMethod
|
||||
public void clearSession(PluginCall call) {
|
||||
getContext()
|
||||
.getSharedPreferences(VojoPollWorker.PREFS, Context.MODE_PRIVATE)
|
||||
.edit()
|
||||
.remove(VojoPollWorker.KEY_ACCESS_TOKEN)
|
||||
.remove(VojoPollWorker.KEY_HOMESERVER_URL)
|
||||
.remove(VojoPollWorker.KEY_USER_ID)
|
||||
.remove(VojoPollWorker.KEY_LAST_SEEN_TS)
|
||||
.remove(VojoPollWorker.KEY_DRAIN_CURSOR)
|
||||
.remove(VojoPollWorker.KEY_DRAIN_TARGET_TS)
|
||||
.remove(VojoPollWorker.KEY_NOTIFIED_IDS)
|
||||
.remove(VojoPollWorker.KEY_ROOM_NAMES)
|
||||
.remove(VojoPollWorker.KEY_USER_AVATARS)
|
||||
.apply();
|
||||
call.resolve();
|
||||
}
|
||||
|
||||
/**
|
||||
* user_id → MXC avatar URL snapshot. Mirrors {@link #saveRoomNames} —
|
||||
* stored as a JSON blob in vojo_poll_state for the FCM service /
|
||||
* polling Worker / ReplyReceiver to consult via
|
||||
* VojoFirebaseMessagingService.lookupUserAvatarMxc. JS dumps on the
|
||||
* same lifecycle triggers as room names (mount, visibility resume,
|
||||
* m.direct change, m.room.encryption flip).
|
||||
*/
|
||||
@PluginMethod
|
||||
public void saveUserAvatars(PluginCall call) {
|
||||
JSObject avatars = call.getObject("avatars");
|
||||
if (avatars == null) {
|
||||
call.reject("missing_avatars");
|
||||
return;
|
||||
}
|
||||
String serialized = avatars.toString();
|
||||
getContext()
|
||||
.getSharedPreferences(VojoPollWorker.PREFS, Context.MODE_PRIVATE)
|
||||
.edit()
|
||||
.putString(VojoPollWorker.KEY_USER_AVATARS, serialized)
|
||||
.apply();
|
||||
Log.i(TAG, "saveUserAvatars: " + avatars.length() + " entries, "
|
||||
+ serialized.length() + " bytes");
|
||||
call.resolve();
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
public void saveRoomNames(PluginCall call) {
|
||||
JSObject names = call.getObject("names");
|
||||
if (names == null) {
|
||||
// Empty map is also valid (user cleared all rooms) — JS passes
|
||||
// {} explicitly in that case; missing key is a contract bug.
|
||||
call.reject("missing_names");
|
||||
return;
|
||||
}
|
||||
// `JSObject extends JSONObject`, so names.toString() is already a
|
||||
// valid JSON serialisation of validated values — no need to re-parse
|
||||
// it through `new JSONObject(...)` just to re-serialise. Persist
|
||||
// verbatim.
|
||||
String serialized = names.toString();
|
||||
getContext()
|
||||
.getSharedPreferences(VojoPollWorker.PREFS, Context.MODE_PRIVATE)
|
||||
.edit()
|
||||
.putString(VojoPollWorker.KEY_ROOM_NAMES, serialized)
|
||||
.apply();
|
||||
Log.i(TAG, "saveRoomNames: " + names.length() + " entries, "
|
||||
+ serialized.length() + " bytes");
|
||||
call.resolve();
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
public void schedule(PluginCall call) {
|
||||
Integer intervalMinutes = call.getInt("intervalMinutes", 15);
|
||||
long interval = Math.max(MIN_INTERVAL_MINUTES, intervalMinutes != null ? intervalMinutes : 15);
|
||||
|
||||
Constraints constraints = new Constraints.Builder()
|
||||
.setRequiredNetworkType(NetworkType.CONNECTED)
|
||||
.build();
|
||||
|
||||
PeriodicWorkRequest req = new PeriodicWorkRequest.Builder(
|
||||
VojoPollWorker.class, interval, TimeUnit.MINUTES
|
||||
)
|
||||
.setConstraints(constraints)
|
||||
.addTag("vojo_push_poll")
|
||||
.build();
|
||||
|
||||
try {
|
||||
WorkManager.getInstance(getContext())
|
||||
.enqueueUniquePeriodicWork(
|
||||
UNIQUE_WORK_NAME,
|
||||
ExistingPeriodicWorkPolicy.KEEP,
|
||||
req
|
||||
);
|
||||
Log.d(TAG, "scheduled periodic poll every " + interval + " minutes");
|
||||
call.resolve();
|
||||
} catch (Throwable t) {
|
||||
Log.w(TAG, "schedule failed", t);
|
||||
call.reject("schedule_failed: " + t.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Dismiss the per-room MessagingStyle notification + clear the in-memory
|
||||
* RoomMessageCache for the room. Called from the JS receipt listener when
|
||||
* a server-side read receipt zeroes the unread count (the user read on
|
||||
* another device / tab). No-op if the notification was never posted or
|
||||
* has already been swiped away.
|
||||
*/
|
||||
@PluginMethod
|
||||
public void dismissRoom(PluginCall call) {
|
||||
String roomId = call.getString("roomId");
|
||||
if (roomId == null || roomId.isEmpty()) {
|
||||
call.reject("missing_roomId");
|
||||
return;
|
||||
}
|
||||
VojoFirebaseMessagingService.dismissRoomNotification(getContext(), roomId);
|
||||
call.resolve();
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
public void cancel(PluginCall call) {
|
||||
try {
|
||||
// Block on the Operation so callers awaiting cancel() see the
|
||||
// cancel committed to WorkManager's database before we resolve.
|
||||
// (NOTE: this does NOT interrupt a Worker that's already mid
|
||||
// doWork(); cooperative cancellation via isStopped() is owned
|
||||
// by VojoPollWorker itself.) Without this wait a fast
|
||||
// disable→reenable sequence races with ExistingPeriodicWorkPolicy.KEEP
|
||||
// — the second enqueueUniquePeriodicWork can land before the
|
||||
// cancel is committed and become a no-op. We're already off
|
||||
// the main thread (Capacitor dispatches plugin calls on its
|
||||
// own executor), so the blocking get() is safe here.
|
||||
WorkManager.getInstance(getContext())
|
||||
.cancelUniqueWork(UNIQUE_WORK_NAME)
|
||||
.getResult()
|
||||
.get();
|
||||
Log.d(TAG, "cancelled periodic poll");
|
||||
call.resolve();
|
||||
} catch (Throwable t) {
|
||||
Log.w(TAG, "cancel failed", t);
|
||||
call.reject("cancel_failed: " + t.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,170 +0,0 @@
|
|||
package chat.vojo.app;
|
||||
|
||||
import android.content.Context;
|
||||
import android.content.SharedPreferences;
|
||||
import android.content.res.Configuration;
|
||||
|
||||
import java.util.Locale;
|
||||
|
||||
/**
|
||||
* Locale-aware push-notification strings. Resources (values{,-ru}/
|
||||
* push_strings.xml) are auto-generated at build time by
|
||||
* scripts/gen-push-strings.mjs from public/locales/{en,ru}.json so
|
||||
* the Push namespace has a single source of truth shared with the
|
||||
* web Service Worker. Do not edit push_strings.xml by hand — it will
|
||||
* be overwritten on the next `npm run android:sync`.
|
||||
*
|
||||
* Locale selection: Vojo ships an explicit in-app language picker that
|
||||
* does NOT have to match the device locale. pushLanguageBridge.ts
|
||||
* mirrors i18next's current language into Capacitor Preferences
|
||||
* (shared_prefs/CapacitorStorage.xml, key "vojo.appLanguage") on every
|
||||
* `languageChanged` event; we read it here and force it onto a
|
||||
* Configuration-scoped Context before the getString call.
|
||||
*
|
||||
* Killed-process pushes may arrive before JS has ever booted — no pref
|
||||
* to read. In that case we fall back to the device locale, normalised
|
||||
* to {en, ru}; anything else maps to en, matching i18n.ts
|
||||
* fallbackLng: 'en' on the main thread.
|
||||
*/
|
||||
final class PushStrings {
|
||||
|
||||
private static final String PREFS_GROUP = "CapacitorStorage";
|
||||
private static final String LANG_KEY = "vojo.appLanguage";
|
||||
|
||||
private PushStrings() {}
|
||||
|
||||
static String messageFallback(Context ctx) {
|
||||
return forAppLocale(ctx).getString(R.string.push_new_message);
|
||||
}
|
||||
|
||||
static String messagesFallback(Context ctx) {
|
||||
return forAppLocale(ctx).getString(R.string.push_new_messages);
|
||||
}
|
||||
|
||||
static String inviteTitle(Context ctx) {
|
||||
return forAppLocale(ctx).getString(R.string.push_invitation);
|
||||
}
|
||||
|
||||
static String missedCallTitle(Context ctx) {
|
||||
return forAppLocale(ctx).getString(R.string.push_missed_call);
|
||||
}
|
||||
|
||||
static String missedCallBody(Context ctx, String caller) {
|
||||
String safeCaller = caller == null ? "" : caller;
|
||||
return forAppLocale(ctx).getString(R.string.push_missed_call_body, safeCaller);
|
||||
}
|
||||
|
||||
static String channelGroup(Context ctx) {
|
||||
return forAppLocale(ctx).getString(R.string.push_channel_group);
|
||||
}
|
||||
|
||||
static String channelDm(Context ctx) {
|
||||
return forAppLocale(ctx).getString(R.string.push_channel_dm);
|
||||
}
|
||||
|
||||
static String channelDmDescription(Context ctx) {
|
||||
return forAppLocale(ctx).getString(R.string.push_channel_dm_description);
|
||||
}
|
||||
|
||||
static String channelGroupRoom(Context ctx) {
|
||||
return forAppLocale(ctx).getString(R.string.push_channel_group_room);
|
||||
}
|
||||
|
||||
static String channelGroupRoomDescription(Context ctx) {
|
||||
return forAppLocale(ctx).getString(R.string.push_channel_group_room_description);
|
||||
}
|
||||
|
||||
static String selfName(Context ctx) {
|
||||
return forAppLocale(ctx).getString(R.string.push_self_name);
|
||||
}
|
||||
|
||||
static String markAsReadAction(Context ctx) {
|
||||
return forAppLocale(ctx).getString(R.string.push_action_mark_as_read);
|
||||
}
|
||||
|
||||
static String replyAction(Context ctx) {
|
||||
return forAppLocale(ctx).getString(R.string.push_action_reply);
|
||||
}
|
||||
|
||||
static String replyHint(Context ctx) {
|
||||
return forAppLocale(ctx).getString(R.string.push_reply_hint);
|
||||
}
|
||||
|
||||
static String replyFailed(Context ctx) {
|
||||
return forAppLocale(ctx).getString(R.string.push_reply_failed);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the invite-notification body from inviter + room name, falling
|
||||
* back through four variants when one or both are absent. The res IDs
|
||||
* all declare positional formatters (%1$s = inviter, %2$s = roomName)
|
||||
* to keep the order stable across locales; we always pass both args
|
||||
* even when only one is used, because Android's formatter requires
|
||||
* every referenced position to be supplied.
|
||||
*/
|
||||
static String inviteBody(Context ctx, String inviter, String roomName) {
|
||||
boolean hasInviter = inviter != null && !inviter.isEmpty();
|
||||
boolean hasRoom = roomName != null && !roomName.isEmpty();
|
||||
int resId;
|
||||
if (hasInviter && hasRoom) {
|
||||
resId = R.string.push_invite_body;
|
||||
} else if (hasInviter) {
|
||||
resId = R.string.push_invite_body_no_room;
|
||||
} else if (hasRoom) {
|
||||
resId = R.string.push_invite_body_no_inviter;
|
||||
} else {
|
||||
resId = R.string.push_invite_body_generic;
|
||||
}
|
||||
String safeInviter = hasInviter ? inviter : "";
|
||||
String safeRoom = hasRoom ? roomName : "";
|
||||
return forAppLocale(ctx).getString(resId, safeInviter, safeRoom);
|
||||
}
|
||||
|
||||
private static Context forAppLocale(Context ctx) {
|
||||
String lang = chooseLang(ctx);
|
||||
try {
|
||||
Configuration cfg = new Configuration(ctx.getResources().getConfiguration());
|
||||
cfg.setLocale(Locale.forLanguageTag(lang));
|
||||
return ctx.createConfigurationContext(cfg);
|
||||
} catch (Throwable t) {
|
||||
return ctx;
|
||||
}
|
||||
}
|
||||
|
||||
private static String chooseLang(Context ctx) {
|
||||
String fromPref = readAppLanguage(ctx);
|
||||
if (fromPref != null) return fromPref;
|
||||
try {
|
||||
Locale loc = Locale.getDefault();
|
||||
if (loc != null) return normalize(loc.getLanguage());
|
||||
} catch (Throwable ignored) {
|
||||
// Locale.getDefault is unusually robust but defensively fall through.
|
||||
}
|
||||
return "en";
|
||||
}
|
||||
|
||||
private static String readAppLanguage(Context ctx) {
|
||||
try {
|
||||
SharedPreferences prefs =
|
||||
ctx.getSharedPreferences(PREFS_GROUP, Context.MODE_PRIVATE);
|
||||
return normalize(prefs.getString(LANG_KEY, null));
|
||||
} catch (Throwable t) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Match i18next config: `fallbackLng: 'en'`, `load: 'languageOnly'`.
|
||||
// Vojo only ships en.json + ru.json; any other Configuration locale
|
||||
// (e.g. "fr") would bypass both values-ru/ and our bundled resources
|
||||
// and land on values/ anyway — we collapse to "en" explicitly so the
|
||||
// web and native surfaces render the same lingua-franca default and
|
||||
// readers of this method don't have to reason about fall-through.
|
||||
// Clamp on both write (pushLanguageBridge.ts) and read (here) so a
|
||||
// stale or tampered pref value can't leak through.
|
||||
private static String normalize(String raw) {
|
||||
if (raw == null) return null;
|
||||
String lower = raw.toLowerCase(Locale.ROOT);
|
||||
if (lower.startsWith("ru")) return "ru";
|
||||
return "en";
|
||||
}
|
||||
}
|
||||
|
|
@ -1,248 +0,0 @@
|
|||
package chat.vojo.app;
|
||||
|
||||
import android.app.NotificationManager;
|
||||
import android.content.BroadcastReceiver;
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.content.SharedPreferences;
|
||||
import android.os.Bundle;
|
||||
import android.util.Log;
|
||||
|
||||
import androidx.core.app.NotificationCompat;
|
||||
import androidx.core.app.RemoteInput;
|
||||
|
||||
import org.json.JSONObject;
|
||||
import org.json.JSONException;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.OutputStream;
|
||||
import java.net.HttpURLConnection;
|
||||
import java.net.URL;
|
||||
import java.net.URLEncoder;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
|
||||
/**
|
||||
* Handles the inline-reply RemoteInput action on a per-room MessagingStyle
|
||||
* notification.
|
||||
*
|
||||
* Flow:
|
||||
* 1. User taps reply, types text, presses send → broadcast fires here.
|
||||
* 2. We immediately append the outgoing message to RoomMessageCache and
|
||||
* re-post the notification (instant UX feedback — the message appears
|
||||
* as a self-Person bubble in the conversation while the HTTP is in
|
||||
* flight).
|
||||
* 3. PUT /_matrix/client/v3/rooms/{roomId}/send/m.room.message/{txnId}
|
||||
* with {msgtype: "m.text", body}. Uses the vojo_poll_state token (same
|
||||
* storage as Worker / MarkAsReadReceiver — single credential lifecycle).
|
||||
* 4. On 2xx: nothing further; the JS sync echo will eventually replace
|
||||
* the local-echo bubble in-app.
|
||||
* 5. On non-2xx or thrown: post a small error notification "Could not
|
||||
* send your reply" so the user knows to retry from in-app — better
|
||||
* than silently swallowing the message.
|
||||
*
|
||||
* E2EE rooms are guarded UP-STREAM in VojoFirebaseMessagingService.
|
||||
* renderMessageNotification: we don't even attach the reply action when
|
||||
* RoomMetadata.isEncrypted is true. So this receiver never has to encrypt.
|
||||
* Defense in depth: if a stale notification with the action ever survives
|
||||
* an encryption flip we still detect the failure as a non-2xx HTTP and
|
||||
* surface the error notification rather than sending cleartext (which
|
||||
* Synapse would in any case reject for an encrypted room).
|
||||
*
|
||||
* Null-credential edge case: post the error notification so the user
|
||||
* notices and retries in-app. Same logic as a network failure.
|
||||
*/
|
||||
public class ReplyReceiver extends BroadcastReceiver {
|
||||
|
||||
public static final String ACTION_REPLY = "chat.vojo.app.REPLY";
|
||||
public static final String EXTRA_ROOM_ID = "room_id";
|
||||
public static final String KEY_TEXT_REPLY = "vojo.text_reply";
|
||||
|
||||
private static final int CONNECT_TIMEOUT_MS = 8_000;
|
||||
private static final int READ_TIMEOUT_MS = 8_000;
|
||||
private static final String TAG = "ReplyRcvr";
|
||||
|
||||
private static final ExecutorService EXECUTOR = Executors.newSingleThreadExecutor();
|
||||
|
||||
@Override
|
||||
public void onReceive(Context context, Intent intent) {
|
||||
if (intent == null) return;
|
||||
final String roomId = intent.getStringExtra(EXTRA_ROOM_ID);
|
||||
if (roomId == null || roomId.isEmpty()) {
|
||||
Log.w(TAG, "onReceive: missing room_id, abort");
|
||||
return;
|
||||
}
|
||||
|
||||
Bundle remote = RemoteInput.getResultsFromIntent(intent);
|
||||
if (remote == null) {
|
||||
Log.w(TAG, "onReceive: no RemoteInput results");
|
||||
return;
|
||||
}
|
||||
CharSequence reply = remote.getCharSequence(KEY_TEXT_REPLY);
|
||||
if (reply == null) {
|
||||
Log.w(TAG, "onReceive: RemoteInput missing text");
|
||||
return;
|
||||
}
|
||||
final String text = reply.toString().trim();
|
||||
if (text.isEmpty()) return;
|
||||
|
||||
final Context appContext = context.getApplicationContext();
|
||||
|
||||
// Pre-flight validation BEFORE the optimistic echo. Posting a self
|
||||
// bubble first and then immediately stacking an error notif on top
|
||||
// is jarring UX; for predictable failures (logged out, freshly
|
||||
// encrypted room) we'd rather skip the echo and only surface the
|
||||
// error.
|
||||
final SharedPreferences prefs = appContext.getSharedPreferences(
|
||||
VojoPollWorker.PREFS, Context.MODE_PRIVATE);
|
||||
final String token = prefs.getString(VojoPollWorker.KEY_ACCESS_TOKEN, null);
|
||||
final String homeserver = prefs.getString(VojoPollWorker.KEY_HOMESERVER_URL, null);
|
||||
if (token == null || token.isEmpty() || homeserver == null || homeserver.isEmpty()) {
|
||||
Log.w(TAG, "onReceive: no credentials in prefs, surfacing error notif");
|
||||
postReplyError(appContext, roomId);
|
||||
return;
|
||||
}
|
||||
|
||||
// Race guard for E2EE flip: the per-room metadata snapshot is
|
||||
// refreshed by JS on m.room.encryption Timeline events, but a push
|
||||
// delivered in the narrow window between the encryption state
|
||||
// landing and the dump completing could still expose the reply
|
||||
// action on a freshly-encrypted room. Re-read the snapshot
|
||||
// synchronously here — Synapse does NOT enforce "no cleartext in
|
||||
// encrypted rooms" at the spec level, so without this guard we'd
|
||||
// leak the user's reply into an E2EE timeline as plaintext.
|
||||
if (isRoomEncryptedAtSendTime(prefs, roomId)) {
|
||||
Log.w(TAG, "onReceive: room flipped to encrypted between render and send, abort");
|
||||
postReplyError(appContext, roomId);
|
||||
return;
|
||||
}
|
||||
|
||||
// Optimistic local echo — appends a self-Person message to the
|
||||
// conversation and re-posts, so the user sees their reply in the
|
||||
// shade before the HTTP completes. Only happens after pre-flight
|
||||
// checks pass so the user doesn't see an echo for a reply we know
|
||||
// will fail.
|
||||
long now = System.currentTimeMillis();
|
||||
VojoFirebaseMessagingService.appendOutgoingMessage(appContext, roomId, text, now);
|
||||
|
||||
final PendingResult pendingResult = goAsync();
|
||||
final String txnId = "vojo-reply-" + UUID.randomUUID();
|
||||
EXECUTOR.execute(() -> {
|
||||
try {
|
||||
int status = sendReply(homeserver, token, roomId, txnId, text);
|
||||
if (status >= 200 && status < 300) {
|
||||
if (BuildConfig.DEBUG) {
|
||||
Log.d(TAG, "reply ok status=" + status + " room=" + roomId);
|
||||
}
|
||||
} else {
|
||||
Log.w(TAG, "reply non-2xx status=" + status + " room=" + roomId);
|
||||
postReplyError(appContext, roomId);
|
||||
}
|
||||
} catch (Throwable t) {
|
||||
Log.w(TAG, "reply threw room=" + roomId, t);
|
||||
postReplyError(appContext, roomId);
|
||||
} finally {
|
||||
pendingResult.finish();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private int sendReply(
|
||||
String baseUrl,
|
||||
String accessToken,
|
||||
String roomId,
|
||||
String txnId,
|
||||
String text
|
||||
) throws IOException {
|
||||
String url = trimTrailingSlash(baseUrl)
|
||||
+ "/_matrix/client/v3/rooms/"
|
||||
+ URLEncoder.encode(roomId, "UTF-8")
|
||||
+ "/send/m.room.message/"
|
||||
+ URLEncoder.encode(txnId, "UTF-8");
|
||||
|
||||
JSONObject body;
|
||||
try {
|
||||
body = new JSONObject();
|
||||
body.put("msgtype", "m.text");
|
||||
body.put("body", text);
|
||||
} catch (org.json.JSONException je) {
|
||||
// JSONObject.put only throws on NaN/Inf doubles, neither of
|
||||
// which we use — but keep the type contract honest.
|
||||
throw new IOException("payload encode failed", je);
|
||||
}
|
||||
byte[] payload = body.toString().getBytes("UTF-8");
|
||||
|
||||
HttpURLConnection conn = (HttpURLConnection) new URL(url).openConnection();
|
||||
try {
|
||||
conn.setRequestMethod("PUT");
|
||||
conn.setRequestProperty("Authorization", "Bearer " + accessToken);
|
||||
conn.setRequestProperty("Content-Type", "application/json");
|
||||
conn.setConnectTimeout(CONNECT_TIMEOUT_MS);
|
||||
conn.setReadTimeout(READ_TIMEOUT_MS);
|
||||
conn.setDoOutput(true);
|
||||
conn.setFixedLengthStreamingMode(payload.length);
|
||||
try (OutputStream os = conn.getOutputStream()) {
|
||||
os.write(payload);
|
||||
}
|
||||
return conn.getResponseCode();
|
||||
} finally {
|
||||
conn.disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Surface a short error notification when the reply HTTP fails so the
|
||||
* user knows the message did NOT land server-side and can retry from
|
||||
* within the app. Posted on the DM channel as a one-shot. Unique notif
|
||||
* id per room so it can't clobber the room's conversation slot.
|
||||
*/
|
||||
private static void postReplyError(Context ctx, String roomId) {
|
||||
NotificationManager nm = (NotificationManager)
|
||||
ctx.getSystemService(Context.NOTIFICATION_SERVICE);
|
||||
if (nm == null) return;
|
||||
try {
|
||||
String channel = VojoFirebaseMessagingService.CHANNEL_ID_DM;
|
||||
NotificationCompat.Builder b = new NotificationCompat.Builder(ctx, channel)
|
||||
.setSmallIcon(R.mipmap.ic_launcher)
|
||||
.setContentTitle(PushStrings.replyFailed(ctx))
|
||||
.setContentText(PushStrings.replyFailed(ctx))
|
||||
.setAutoCancel(true)
|
||||
.setPriority(NotificationCompat.PRIORITY_DEFAULT);
|
||||
int errId = ("replyErr_" + roomId).hashCode();
|
||||
nm.notify(errId, b.build());
|
||||
} catch (Throwable t) {
|
||||
Log.w(TAG, "reply error notif failed", t);
|
||||
}
|
||||
}
|
||||
|
||||
private static String trimTrailingSlash(String s) {
|
||||
return (s != null && s.endsWith("/")) ? s.substring(0, s.length() - 1) : s;
|
||||
}
|
||||
|
||||
/**
|
||||
* Synchronous re-check of the room's encryption flag at send time.
|
||||
* Mirrors VojoFirebaseMessagingService.loadRoomMetadata's tolerant
|
||||
* parse: legacy string-shape entries and missing flags both default
|
||||
* to encrypted=true (privacy-first — refusing a reply on a falsely-
|
||||
* flagged room is harmless; sending cleartext into a truly encrypted
|
||||
* room is a privacy leak).
|
||||
*/
|
||||
private static boolean isRoomEncryptedAtSendTime(SharedPreferences prefs, String roomId) {
|
||||
String raw = prefs.getString(VojoPollWorker.KEY_ROOM_NAMES, null);
|
||||
if (raw == null || raw.isEmpty()) return true;
|
||||
try {
|
||||
JSONObject map = new JSONObject(raw);
|
||||
if (!map.has(roomId) || map.isNull(roomId)) return true;
|
||||
JSONObject obj = map.optJSONObject(roomId);
|
||||
if (obj == null) {
|
||||
// Legacy string-shape predates the encryption flag —
|
||||
// assume encrypted to err on the side of privacy.
|
||||
return true;
|
||||
}
|
||||
return obj.optBoolean("isEncrypted", true);
|
||||
} catch (JSONException je) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,176 +0,0 @@
|
|||
package chat.vojo.app;
|
||||
|
||||
import androidx.core.app.NotificationCompat;
|
||||
import androidx.core.app.Person;
|
||||
|
||||
import java.util.ArrayDeque;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Deque;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
/**
|
||||
* Per-room MessagingStyle history cache.
|
||||
*
|
||||
* Stores the last N messages observed for each room so renderMessageNotification
|
||||
* can rebuild a NotificationCompat.MessagingStyle with conversation context on
|
||||
* every new event instead of posting a fresh single-message notification per
|
||||
* event. Without this every 5-message DM produced 5 distinct entries in the
|
||||
* shade; with it the user sees one expandable conversation per room — the
|
||||
* WhatsApp/Telegram convention.
|
||||
*
|
||||
* Thread-safety: ConcurrentHashMap + per-key synchronized mutation via the
|
||||
* compute() / get() pattern. Both VojoFirebaseMessagingService.onMessageReceived
|
||||
* (Firebase-managed thread) and VojoPollWorker.doWork (WorkManager executor)
|
||||
* mutate the cache; without serialization a same-room FCM + polling race could
|
||||
* lose a message. Mutations are short — only deque append + bounded trim.
|
||||
*
|
||||
* Persistence: in-memory only. After process kill the cache is empty, and
|
||||
* renderMessageNotification falls back to extractMessagingStyleFromNotification
|
||||
* to recover history from the live system shade. If the user dismissed the
|
||||
* notification too, the conversation legitimately starts fresh — no signal we
|
||||
* could recover from there anyway.
|
||||
*
|
||||
* Eviction: bounded at MAX_MESSAGES_PER_ROOM per room, with FIFO eviction
|
||||
* (oldest message at the head of the deque is dropped via pollFirst when the
|
||||
* append would exceed the cap). Map itself is unbounded; in practice the
|
||||
* dump from dismissRoom (when a server-side read receipt clears unread) keeps
|
||||
* the room count proportional to active conversations. For safety against
|
||||
* runaway growth from rooms the user never reads, we cap the map at MAX_ROOMS.
|
||||
*/
|
||||
final class RoomMessageCache {
|
||||
|
||||
// Element-android keeps a similar in-memory queue (NotificationEventQueue);
|
||||
// 20 messages per room is generous enough for an active group chat while
|
||||
// staying well under Android's MessagingStyle render budget — Android only
|
||||
// shows the last ~7 messages in the shade anyway.
|
||||
private static final int MAX_MESSAGES_PER_ROOM = 20;
|
||||
|
||||
// Hard cap on the map size so a long-running session that touches many
|
||||
// rooms without ever clearing receipts can't slowly leak memory.
|
||||
// Eviction is approximate (oldest-touched first via insertion order from
|
||||
// ConcurrentHashMap is NOT guaranteed, so we just clear the oldest by
|
||||
// arbitrary entry on overflow — acceptable for an LRU at this scale).
|
||||
private static final int MAX_ROOMS = 200;
|
||||
|
||||
private static final ConcurrentHashMap<String, Deque<Entry>> store =
|
||||
new ConcurrentHashMap<>();
|
||||
|
||||
private RoomMessageCache() {}
|
||||
|
||||
/**
|
||||
* Snapshot of a single rendered message. We can't store
|
||||
* NotificationCompat.MessagingStyle.Message directly because Person's
|
||||
* Icon field is not safely shareable across threads / not cheap to
|
||||
* rebuild on every poll. Building the Message at render time from this
|
||||
* record matches element-android's RoomGroupMessageCreator pattern.
|
||||
*/
|
||||
static final class Entry {
|
||||
// Matrix event_id when known (incoming pushes always carry one;
|
||||
// outgoing optimistic-echo entries pass null). Used by append() to
|
||||
// suppress duplicate appends when FCM retries / cross-source
|
||||
// delivery hands the same event in twice — without this the
|
||||
// MessagingStyle conversation would render the same message N
|
||||
// times in the shade.
|
||||
final String eventId;
|
||||
final String body;
|
||||
final long timestamp;
|
||||
final String senderKey;
|
||||
final String senderName;
|
||||
final boolean fromSelf;
|
||||
|
||||
Entry(
|
||||
String eventId,
|
||||
String body,
|
||||
long timestamp,
|
||||
String senderKey,
|
||||
String senderName,
|
||||
boolean fromSelf
|
||||
) {
|
||||
this.eventId = eventId;
|
||||
this.body = body;
|
||||
this.timestamp = timestamp;
|
||||
this.senderKey = senderKey;
|
||||
this.senderName = senderName;
|
||||
this.fromSelf = fromSelf;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Append a message to the room's history and return an ordered snapshot
|
||||
* including the newly-added entry. Snapshot is taken INSIDE the atomic
|
||||
* compute() so a concurrent append for the same room can't mutate the
|
||||
* deque between our addLast and our copy. Returning the deque reference
|
||||
* and copying outside is unsafe — ConcurrentHashMap.compute serialises
|
||||
* only the lambda body per key, not subsequent reads of the value.
|
||||
*/
|
||||
static List<Entry> append(String roomId, Entry entry) {
|
||||
if (roomId == null || roomId.isEmpty() || entry == null) {
|
||||
return java.util.Collections.emptyList();
|
||||
}
|
||||
final List<Entry> snapshot = new ArrayList<>();
|
||||
store.compute(roomId, (key, existing) -> {
|
||||
Deque<Entry> d = (existing != null) ? existing : new ArrayDeque<>();
|
||||
// Dedup by eventId — protects against FCM retry / cross-source
|
||||
// (FCM + polling Worker) double-delivery that would otherwise
|
||||
// append the same event twice. Only applies when both the new
|
||||
// entry and a prior one carry a non-empty eventId; outgoing
|
||||
// self-echo entries have null eventId by design and never
|
||||
// collide.
|
||||
boolean isDup = false;
|
||||
if (entry.eventId != null && !entry.eventId.isEmpty()) {
|
||||
for (Entry prior : d) {
|
||||
if (entry.eventId.equals(prior.eventId)) {
|
||||
isDup = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!isDup) {
|
||||
d.addLast(entry);
|
||||
while (d.size() > MAX_MESSAGES_PER_ROOM) {
|
||||
d.pollFirst();
|
||||
}
|
||||
}
|
||||
snapshot.addAll(d);
|
||||
return d;
|
||||
});
|
||||
// Bound the map. Iteration order of ConcurrentHashMap is unspecified
|
||||
// and the size() check is racy with concurrent puts; we accept ±1
|
||||
// eviction precision at the 200-room cap as an acceptable approximation
|
||||
// of LRU (the alternative is a global lock on every append which is
|
||||
// far more expensive than letting the cache drift by one).
|
||||
if (store.size() > MAX_ROOMS) {
|
||||
java.util.Iterator<String> it = store.keySet().iterator();
|
||||
while (it.hasNext() && store.size() > MAX_ROOMS) {
|
||||
String key = it.next();
|
||||
if (!key.equals(roomId)) it.remove();
|
||||
}
|
||||
}
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
/**
|
||||
* Seed the room's history from an already-posted MessagingStyle (recovered
|
||||
* via NotificationCompat.MessagingStyle.extractMessagingStyleFromNotification
|
||||
* after process kill). Idempotent: if the room already has cached entries
|
||||
* we leave them alone — they are by construction at least as recent.
|
||||
*/
|
||||
static void seedIfAbsent(String roomId, List<Entry> entries) {
|
||||
if (roomId == null || roomId.isEmpty() || entries == null || entries.isEmpty()) return;
|
||||
store.computeIfAbsent(roomId, key -> {
|
||||
Deque<Entry> d = new ArrayDeque<>();
|
||||
for (Entry e : entries) {
|
||||
d.addLast(e);
|
||||
while (d.size() > MAX_MESSAGES_PER_ROOM) d.pollFirst();
|
||||
}
|
||||
return d;
|
||||
});
|
||||
}
|
||||
|
||||
/** Drop all cached messages for a room (e.g. on receipt-driven dismiss). */
|
||||
static void clear(String roomId) {
|
||||
if (roomId == null || roomId.isEmpty()) return;
|
||||
store.remove(roomId);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,273 +0,0 @@
|
|||
package chat.vojo.app;
|
||||
|
||||
import android.content.ContentResolver;
|
||||
import android.content.Intent;
|
||||
import android.database.Cursor;
|
||||
import android.net.Uri;
|
||||
import android.os.Build;
|
||||
import android.provider.OpenableColumns;
|
||||
import android.util.Log;
|
||||
import android.webkit.MimeTypeMap;
|
||||
|
||||
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;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* Receives ACTION_SEND / ACTION_SEND_MULTIPLE intents from the system share-
|
||||
* sheet and surfaces them to the WebView as a pending share that JS consumes
|
||||
* via {@code pickPendingShare()} (or reacts to via the {@code shareReceived}
|
||||
* event when the app was already in the foreground).
|
||||
*
|
||||
* Cold-start flow:
|
||||
* 1. Share-sheet → Vojo → MainActivity.onCreate → super.onCreate runs
|
||||
* BridgeActivity.load(), which itself calls bridge.onNewIntent(getIntent())
|
||||
* and fans the intent out to every plugin's handleOnNewIntent. So
|
||||
* cold-start and warm-start share the SAME entry point — we don't
|
||||
* double-process via handleOnStart.
|
||||
* 2. captureFromIntent copies payload bytes into the app cache and stashes
|
||||
* the result in {@link #pendingShare}.
|
||||
* 3. JS booting up (Matrix client ready, user logged in) calls
|
||||
* pickPendingShare(); receives the JSON; opens the room-picker UI. The
|
||||
* shareReceived event fired here is dropped silently because no JS
|
||||
* listener is attached yet — that's fine, pickPendingShare drains the
|
||||
* slot regardless.
|
||||
*
|
||||
* Warm flow (app already running):
|
||||
* 1. Share-sheet → MainActivity.onNewIntent → BridgeActivity forwards to
|
||||
* plugin.handleOnNewIntent(intent).
|
||||
* 2. We re-capture the payload AND emit {@code shareReceived} so JS can
|
||||
* open the picker without polling.
|
||||
*
|
||||
* Why we copy to cache instead of handing JS a content:// URI:
|
||||
* - WebView fetch() rejects content:// schemes outright, and
|
||||
* `Capacitor.convertFileSrc()` only works on file paths.
|
||||
* - The originating app holds the read-grant only for the lifetime of the
|
||||
* launching task; routing the URI through JS+picker+RoomInput would race
|
||||
* that grant on Android 14+.
|
||||
* - Copying into our own cache means the share is self-contained: even if
|
||||
* the user backgrounds Vojo for hours before picking a chat, the bytes
|
||||
* are still there. We schedule no cleanup of our own — Android's cache
|
||||
* eviction handles long-tail garbage.
|
||||
*/
|
||||
@CapacitorPlugin(name = "ShareTarget")
|
||||
public class ShareTargetPlugin extends Plugin {
|
||||
|
||||
private static final String TAG = "ShareTargetPlugin";
|
||||
private static final String SHARE_CACHE_SUBDIR = "shared";
|
||||
|
||||
// Single-slot pending share. Multiple share-sheet invocations before JS
|
||||
// drains the slot collapse — the latest wins. JS contract is "consume
|
||||
// once, then it's gone" via pickPendingShare(consume=true). This matches
|
||||
// user intent: tapping share twice on different photos clearly means
|
||||
// "share THIS one now".
|
||||
private volatile JSObject pendingShare = null;
|
||||
|
||||
@Override
|
||||
public void handleOnNewIntent(Intent intent) {
|
||||
super.handleOnNewIntent(intent);
|
||||
captureFromIntent(intent, /* notifyJs */ true);
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
public void pickPendingShare(PluginCall call) {
|
||||
JSObject ret = new JSObject();
|
||||
JSObject snapshot = pendingShare;
|
||||
if (snapshot == null) {
|
||||
ret.put("empty", true);
|
||||
} else {
|
||||
// Default: consume on read. Lets us treat the slot like a one-shot
|
||||
// mailbox without an extra round-trip. Caller can pass consume=false
|
||||
// to peek (not used today, but cheap to keep).
|
||||
Boolean consume = call.getBoolean("consume", Boolean.TRUE);
|
||||
ret = snapshot;
|
||||
if (Boolean.TRUE.equals(consume)) {
|
||||
pendingShare = null;
|
||||
}
|
||||
}
|
||||
call.resolve(ret);
|
||||
}
|
||||
|
||||
private void captureFromIntent(Intent intent, boolean notifyJs) {
|
||||
if (intent == null) return;
|
||||
String action = intent.getAction();
|
||||
if (action == null) return;
|
||||
|
||||
// Capacitor's JSObject.put() silently swallows JSONException internally
|
||||
// (it wraps org.json.JSONObject and returns `this` on failure) so no
|
||||
// checked exception is thrown here — unlike the raw org.json API.
|
||||
JSObject share = new JSObject();
|
||||
share.put("empty", false);
|
||||
|
||||
String text = intent.getStringExtra(Intent.EXTRA_TEXT);
|
||||
String subject = intent.getStringExtra(Intent.EXTRA_SUBJECT);
|
||||
if (text != null && !text.isEmpty()) share.put("text", text);
|
||||
if (subject != null && !subject.isEmpty()) share.put("subject", subject);
|
||||
|
||||
JSArray items = new JSArray();
|
||||
List<Uri> uris = new ArrayList<>();
|
||||
if (Intent.ACTION_SEND.equals(action)) {
|
||||
Uri uri;
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
uri = intent.getParcelableExtra(Intent.EXTRA_STREAM, Uri.class);
|
||||
} else {
|
||||
// Deprecated overload — required to read EXTRA_STREAM on
|
||||
// API ≤32, where the typed variant doesn't exist.
|
||||
//noinspection deprecation
|
||||
uri = intent.getParcelableExtra(Intent.EXTRA_STREAM);
|
||||
}
|
||||
if (uri != null) uris.add(uri);
|
||||
} else if (Intent.ACTION_SEND_MULTIPLE.equals(action)) {
|
||||
List<Uri> multi;
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
multi = intent.getParcelableArrayListExtra(Intent.EXTRA_STREAM, Uri.class);
|
||||
} else {
|
||||
//noinspection deprecation
|
||||
multi = intent.getParcelableArrayListExtra(Intent.EXTRA_STREAM);
|
||||
}
|
||||
if (multi != null) uris.addAll(multi);
|
||||
}
|
||||
|
||||
String intentMime = intent.getType();
|
||||
for (Uri uri : uris) {
|
||||
JSObject item = copyUriToCache(uri, intentMime);
|
||||
if (item != null) items.put(item);
|
||||
}
|
||||
share.put("items", items);
|
||||
|
||||
// Drop pure-noise intents — neither text nor a successfully
|
||||
// copied file. Possible if a sender app handed us only a content://
|
||||
// URI we can't read (permission revoked) or an EXTRA_STREAM with a
|
||||
// null Uri. Keeps JS from showing an empty picker.
|
||||
if (text == null && subject == null && items.length() == 0) {
|
||||
Log.w(TAG, "Dropping share intent with no usable payload");
|
||||
return;
|
||||
}
|
||||
|
||||
pendingShare = share;
|
||||
if (notifyJs) {
|
||||
notifyListeners("shareReceived", new JSObject());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stream the content of {@code uri} into a fresh file under
|
||||
* cacheDir/shared/, then return {name, mimeType, size, path}. The path is
|
||||
* an absolute filesystem path — JS wraps it with
|
||||
* {@code Capacitor.convertFileSrc} before fetch().
|
||||
*/
|
||||
private JSObject copyUriToCache(Uri uri, String fallbackMime) {
|
||||
if (uri == null) return null;
|
||||
ContentResolver resolver = getContext().getContentResolver();
|
||||
|
||||
String name = queryDisplayName(resolver, uri);
|
||||
String mimeType = resolver.getType(uri);
|
||||
if (mimeType == null) mimeType = fallbackMime;
|
||||
if (mimeType == null) mimeType = "application/octet-stream";
|
||||
|
||||
if (name == null || name.isEmpty()) {
|
||||
String ext = MimeTypeMap.getSingleton().getExtensionFromMimeType(mimeType);
|
||||
name = "share-" + UUID.randomUUID() + (ext != null ? "." + ext : "");
|
||||
}
|
||||
|
||||
File dir = new File(getContext().getCacheDir(), SHARE_CACHE_SUBDIR);
|
||||
// mkdirs returns false if the directory already exists — not an error.
|
||||
// The real failure mode is the I/O exception below on FileOutputStream
|
||||
// construction, which we surface.
|
||||
if (!dir.exists() && !dir.mkdirs()) {
|
||||
Log.e(TAG, "Could not create share cache dir: " + dir);
|
||||
return null;
|
||||
}
|
||||
// Prefix with UUID so a repeated share of "IMG_1234.jpg" doesn't
|
||||
// overwrite the previous payload while the user is still picking a
|
||||
// chat for the older one (e.g. Gallery → Vojo, see room-picker open,
|
||||
// background → Gallery → re-share same file → foreground Vojo). Both
|
||||
// payloads stay independently addressable.
|
||||
File out = new File(dir, UUID.randomUUID() + "_" + safeFileName(name));
|
||||
|
||||
// Open the input first; if the sender's provider hands us back
|
||||
// null (revoked grant, gone-away ContentProvider, …) bail before
|
||||
// creating any on-disk file — otherwise the FileOutputStream
|
||||
// initializer below would create a zero-byte orphan we'd never
|
||||
// clean up (catch arm doesn't fire when we early-return).
|
||||
long size;
|
||||
try (InputStream in = resolver.openInputStream(uri)) {
|
||||
if (in == null) {
|
||||
Log.w(TAG, "openInputStream returned null for " + uri);
|
||||
return null;
|
||||
}
|
||||
try (FileOutputStream fos = new FileOutputStream(out)) {
|
||||
byte[] buf = new byte[64 * 1024];
|
||||
int n;
|
||||
long total = 0;
|
||||
while ((n = in.read(buf)) > 0) {
|
||||
fos.write(buf, 0, n);
|
||||
total += n;
|
||||
}
|
||||
size = total;
|
||||
}
|
||||
} catch (IOException e) {
|
||||
Log.e(TAG, "Failed to copy " + uri, e);
|
||||
// Drop the partial file so we don't surface a truncated
|
||||
// payload to JS as if it were valid.
|
||||
//noinspection ResultOfMethodCallIgnored
|
||||
out.delete();
|
||||
return null;
|
||||
}
|
||||
|
||||
JSObject item = new JSObject();
|
||||
item.put("name", name);
|
||||
item.put("mimeType", mimeType);
|
||||
item.put("size", size);
|
||||
item.put("path", out.getAbsolutePath());
|
||||
return item;
|
||||
}
|
||||
|
||||
private String queryDisplayName(ContentResolver resolver, Uri uri) {
|
||||
// ContentResolver.query throws if the provider rejects the URI scheme
|
||||
// (e.g. some senders pass a file:// directly — no provider involved).
|
||||
// Wrap in try/catch and fall back to the URI's last path segment.
|
||||
try (Cursor c = resolver.query(uri, new String[]{ OpenableColumns.DISPLAY_NAME }, null, null, null)) {
|
||||
if (c != null && c.moveToFirst()) {
|
||||
int idx = c.getColumnIndex(OpenableColumns.DISPLAY_NAME);
|
||||
if (idx >= 0) {
|
||||
String name = c.getString(idx);
|
||||
if (name != null && !name.isEmpty()) return name;
|
||||
}
|
||||
}
|
||||
} catch (Throwable t) {
|
||||
Log.d(TAG, "queryDisplayName failed for " + uri + ": " + t.getMessage());
|
||||
}
|
||||
String last = uri.getLastPathSegment();
|
||||
if (last != null && !last.isEmpty()) {
|
||||
// Strip any directory traversal a malicious sender might encode.
|
||||
int slash = last.lastIndexOf('/');
|
||||
return slash >= 0 ? last.substring(slash + 1) : last;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static String safeFileName(String name) {
|
||||
// Strip path separators and trim length — the on-disk name is just an
|
||||
// identifier; the display name we return to JS preserves the user's
|
||||
// original filename verbatim. Trim from the tail so the recognisable
|
||||
// head ("IMG_2025_05_16…") survives and the extension is the part
|
||||
// that gets clipped on absurdly long names; the on-disk extension
|
||||
// doesn't matter because nothing inside Vojo dispatches on it (the
|
||||
// display name carries the real extension into JS).
|
||||
String stripped = name.replaceAll("[/\\\\]", "_");
|
||||
if (stripped.length() > 120) stripped = stripped.substring(0, 120);
|
||||
return stripped;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,675 +0,0 @@
|
|||
package chat.vojo.app;
|
||||
|
||||
import android.content.Context;
|
||||
import android.content.SharedPreferences;
|
||||
import android.util.Log;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.core.app.NotificationManagerCompat;
|
||||
import androidx.work.Worker;
|
||||
import androidx.work.WorkerParameters;
|
||||
|
||||
import org.json.JSONArray;
|
||||
import org.json.JSONObject;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.net.HttpURLConnection;
|
||||
import java.net.URL;
|
||||
import java.util.HashMap;
|
||||
import java.util.Iterator;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Periodic poll of `/_matrix/client/v3/notifications` as a fallback delivery
|
||||
* channel for users whose network blocks FCM (mtalk.google.com:5228) — the
|
||||
* ~5% slice on whitelist intranets (corporate / school / government) that
|
||||
* otherwise receive zero pushes.
|
||||
*
|
||||
* Scheduling: enqueued from PollingPlugin.schedule() with a 15-minute period
|
||||
* (Android's minimum for PeriodicWorkRequest) and CONNECTED network constraint.
|
||||
* Cancelled via PollingPlugin.cancel() on logout / push disable.
|
||||
*
|
||||
* Credentials: read from SharedPreferences (saved by the JS side through
|
||||
* PollingPlugin.saveSession). Vanilla Synapse (no MAS/OIDC) issues
|
||||
* non-expiring access tokens; we do not implement refresh-token flow here.
|
||||
* If a 401 ever occurs, doWork returns Result.success() — the next foreground
|
||||
* launch re-saves the credentials and polling resumes. Retrying with a stale
|
||||
* token would just waste battery and amplify rate limits.
|
||||
*
|
||||
* Output: messages and invites route through VojoFirebaseMessagingService
|
||||
* .renderMessageNotification (shared with FCM, same notif-id slots →
|
||||
* Android dedupes by replace). RTC ring events route through
|
||||
* .renderMissedCallNotification (always stale by the time we poll — 15-min
|
||||
* cadence vs 30-second ring lifetime), so the user sees "Missed call" instead
|
||||
* of a phantom incoming-call CallStyle for a long-dead ring.
|
||||
*
|
||||
* E2EE caveat: Synapse cannot decrypt event content, so for end-to-end
|
||||
* encrypted rooms the response carries `content.algorithm`+`ciphertext`
|
||||
* with no `body`. The renderer falls through to PushStrings.messageFallback
|
||||
* (i18n "New message") with the room name as title — same UX as the web
|
||||
* Service Worker on encrypted pushes. By design — no key access from the
|
||||
* Worker.
|
||||
*
|
||||
* Dedup is two complementary mechanisms:
|
||||
* 1) A per-poll high-watermark on the latest event ts we've notified.
|
||||
* Stored as KEY_LAST_SEEN_TS; advances only after a successful render
|
||||
* (or a foreground-skipped event the user already saw in-app). Worker
|
||||
* stops walking within a run as soon as it hits ts strictly less than
|
||||
* watermark — newest-first ordering guarantees the rest are also
|
||||
* older. Same-ts events fall through to the secondary filters because
|
||||
* multiple events can share a millisecond.
|
||||
* 2) NotificationDedup — a shared cross-source bounded LRU written by
|
||||
* every renderer (FCM service after successful nm.notify, this Worker
|
||||
* after successful render, and the ring-upsert paths at seed time).
|
||||
* Lets the Worker skip events FCM already delivered even after the
|
||||
* user dismissed the FCM notification.
|
||||
*
|
||||
* Each fire starts from the HEAD of /notifications (no persistent
|
||||
* pagination cursor — the spec's `next_token` walks BACKWARDS into
|
||||
* history, so a persisted cursor silently drifts off the new events the
|
||||
* next poll should see; see matrix-js-sdk client.ts:5040 for the
|
||||
* reference traversal pattern). When a single fire's backlog exceeds
|
||||
* MAX_PAGES_PER_RUN pages the leftover next_token is saved as
|
||||
* KEY_DRAIN_CURSOR (with the head ts snapshotted in KEY_DRAIN_TARGET_TS)
|
||||
* and resumed on the next run, so big backlogs (>250 events) drain over
|
||||
* consecutive polls without being clipped.
|
||||
*/
|
||||
public class VojoPollWorker extends Worker {
|
||||
|
||||
private static final String TAG = "VojoPoll";
|
||||
|
||||
static final String PREFS = "vojo_poll_state";
|
||||
static final String KEY_ACCESS_TOKEN = "access_token";
|
||||
static final String KEY_HOMESERVER_URL = "homeserver_url";
|
||||
static final String KEY_USER_ID = "user_id";
|
||||
// High-watermark on the latest event ts we've already notified about.
|
||||
// Stored as a long-millis string. Replaces an earlier `last_from` cursor
|
||||
// experiment that misunderstood /notifications pagination direction.
|
||||
static final String KEY_LAST_SEEN_TS = "last_seen_ts";
|
||||
// Continuation cursor used when a single run hits MAX_PAGES_PER_RUN before
|
||||
// reaching the watermark. Persists the next_token across runs so a >250
|
||||
// event backlog drains over consecutive polls instead of being clipped
|
||||
// forever by the page cap. Cleared once we either reach the watermark or
|
||||
// exhaust pagination on a single run.
|
||||
static final String KEY_DRAIN_CURSOR = "drain_cursor";
|
||||
// The "head ts" we recorded when entering drain mode. After drain
|
||||
// completes the watermark is jumped to THIS value rather than the
|
||||
// (older) max ts seen during drain — otherwise the bounded LRU could
|
||||
// evict events from the original head and let the next normal run
|
||||
// re-render them. Set once on entering drain mode, untouched while
|
||||
// draining, cleared when drain completes.
|
||||
static final String KEY_DRAIN_TARGET_TS = "drain_target_ts";
|
||||
static final String KEY_NOTIFIED_IDS = "notified_ids";
|
||||
static final String KEY_ROOM_NAMES = "room_names";
|
||||
// user_id → MXC avatar URL, JSON-encoded, bridged from JS via
|
||||
// PollingPlugin.saveUserAvatars. Consumed by
|
||||
// VojoFirebaseMessagingService.lookupUserAvatarMxc for per-sender
|
||||
// Person.setIcon in MessagingStyle conversations. Bounded at 500
|
||||
// entries on the JS side; read tolerantly here.
|
||||
static final String KEY_USER_AVATARS = "user_avatars";
|
||||
|
||||
private static final int HTTP_TIMEOUT_MS = 30_000;
|
||||
// Cap pages-per-fire so an unexpectedly large backlog (server-side bug,
|
||||
// first run after a long offline window) cannot loop until Android's
|
||||
// 10-minute Worker kill timer fires. 5 pages × 50 events = up to 250
|
||||
// events per cycle — well above realistic 15-minute backlog for a single
|
||||
// user. We also break as soon as we hit ts ≤ watermark, so most polls
|
||||
// touch only a single page.
|
||||
private static final int MAX_PAGES_PER_RUN = 5;
|
||||
private static final int PAGE_LIMIT = 50;
|
||||
|
||||
private static final String RTC_NOTIFICATION_TYPE = "org.matrix.msc4075.rtc.notification";
|
||||
private static final String RTC_NOTIFICATION_TYPE_STABLE = "m.rtc.notification";
|
||||
|
||||
public VojoPollWorker(@NonNull Context context, @NonNull WorkerParameters params) {
|
||||
super(context, params);
|
||||
}
|
||||
|
||||
@NonNull
|
||||
@Override
|
||||
public Result doWork() {
|
||||
Context ctx = getApplicationContext();
|
||||
SharedPreferences prefs = ctx.getSharedPreferences(PREFS, Context.MODE_PRIVATE);
|
||||
|
||||
String token = prefs.getString(KEY_ACCESS_TOKEN, null);
|
||||
String homeserver = prefs.getString(KEY_HOMESERVER_URL, null);
|
||||
if (token == null || homeserver == null) {
|
||||
// Not logged in (or JS hasn't bridged credentials yet). Return
|
||||
// success so WorkManager keeps the periodic schedule alive —
|
||||
// we'll pick up the credentials on the next fire.
|
||||
Log.i(TAG, "poll: no credentials, bail");
|
||||
return Result.success();
|
||||
}
|
||||
|
||||
// If POST_NOTIFICATIONS was revoked we'd fetch + parse + try to
|
||||
// render and then watch every nm.notify fail with SecurityException
|
||||
// — which leaves the LRU/watermark unadvanced (correctly so for a
|
||||
// transient failure) and re-runs the same loop every 15 minutes
|
||||
// forever. Bail early to avoid burning battery on a permanent
|
||||
// user choice. The next visibility re-bridge inside the JS app
|
||||
// will pick up a re-granted permission.
|
||||
if (!NotificationManagerCompat.from(ctx).areNotificationsEnabled()) {
|
||||
Log.i(TAG, "poll: notifications disabled, bail");
|
||||
return Result.success();
|
||||
}
|
||||
|
||||
long watermark = prefs.getLong(KEY_LAST_SEEN_TS, 0L);
|
||||
String drainCursor = prefs.getString(KEY_DRAIN_CURSOR, null);
|
||||
long drainTargetTs = prefs.getLong(KEY_DRAIN_TARGET_TS, 0L);
|
||||
boolean wasDraining = drainCursor != null;
|
||||
Map<String, String> roomNames = loadRoomNamesMap(prefs);
|
||||
// Mirror the FCM service's foreground gate: if the user is actively in
|
||||
// the app, the live timeline owns the UX and a system notification for
|
||||
// a backlog event would be both stale and visually noisy. We still
|
||||
// consume state (LRU, watermark) so the same event doesn't surface
|
||||
// when the user later backgrounds the app.
|
||||
boolean inForeground = MainActivity.isInForeground;
|
||||
|
||||
Log.i(TAG, "poll: start fg=" + inForeground
|
||||
+ " watermark=" + watermark
|
||||
+ " draining=" + wasDraining);
|
||||
|
||||
int pagesFetched = 0;
|
||||
int renderedCount = 0;
|
||||
int skippedDedupCount = 0;
|
||||
long highestTsSeen = watermark;
|
||||
boolean reachedWatermark = false;
|
||||
// The continuation cursor we'd save if this run is capped. Starts as
|
||||
// the resumed drain cursor; advances with each successful page fetch
|
||||
// so a transient mid-pagination error still preserves drain progress.
|
||||
String pendingCursor = drainCursor;
|
||||
boolean paginationExhausted = false;
|
||||
|
||||
try {
|
||||
// Cursor strategy: drain cursor resumes from where a previous capped
|
||||
// run stopped; otherwise we start from the HEAD. next_token from
|
||||
// /notifications paginates BACKWARDS into history, so a stored
|
||||
// cursor must be used as a drain-only continuation, NOT as an
|
||||
// ongoing "since" mark (the latter would silently drift off new
|
||||
// events). Within a single fire we stop as soon as ts < watermark
|
||||
// (newest-first ordering means everything past that is covered).
|
||||
String nextFrom = drainCursor;
|
||||
for (int page = 0; page < MAX_PAGES_PER_RUN && !reachedWatermark; page += 1) {
|
||||
// Cooperative cancellation. WorkManager.cancelUniqueWork (called
|
||||
// from PollingPlugin.cancel during logout / push disable) only
|
||||
// marks future scheduling — it does NOT interrupt this thread.
|
||||
// Without these checks the Worker keeps fetching pages, posting
|
||||
// notifications, and (worst of all) running the final
|
||||
// editor.apply() with stale state written AFTER clearSession
|
||||
// wiped prefs — leaking watermark / drain cursor from the
|
||||
// logged-out account into the next login.
|
||||
if (isStopped()) return Result.success();
|
||||
|
||||
JSONObject body = fetchNotifications(homeserver, token, nextFrom);
|
||||
// fetchNotifications throws on every failure path; a null
|
||||
// return is unreachable in current code. The early-break here
|
||||
// is a defensive belt-and-suspenders — keep paginationExhausted
|
||||
// consistent so the drain-bookkeeping below clears the cursor
|
||||
// instead of replaying the same empty page forever.
|
||||
if (body == null) {
|
||||
paginationExhausted = true;
|
||||
pendingCursor = null;
|
||||
break;
|
||||
}
|
||||
|
||||
JSONArray notifications = body.optJSONArray("notifications");
|
||||
if (notifications == null || notifications.length() == 0) {
|
||||
// Server returned no entries for this page. Treat as
|
||||
// end-of-pagination so a drain in progress can complete
|
||||
// (otherwise pendingCursor would keep its old value and
|
||||
// we'd re-fetch the same empty page next cycle forever).
|
||||
paginationExhausted = true;
|
||||
pendingCursor = null;
|
||||
break;
|
||||
}
|
||||
|
||||
for (int i = 0; i < notifications.length(); i += 1) {
|
||||
if (isStopped()) return Result.success();
|
||||
JSONObject entry = notifications.optJSONObject(i);
|
||||
if (entry == null) continue;
|
||||
String eventId = extractEventId(entry);
|
||||
if (eventId == null) continue;
|
||||
|
||||
// ts gate: server returns newest-first, so once we hit
|
||||
// ts STRICTLY less than the watermark we know the rest of
|
||||
// the page (and every subsequent page) is already covered.
|
||||
// Same-ts events fall through to the LRU/read filters
|
||||
// below — multiple events can share a millisecond, and
|
||||
// collapsing them at the ts boundary would silently drop
|
||||
// a fresh sibling of a previously-rendered one.
|
||||
long ts = entry.optLong("ts", 0L);
|
||||
if (ts > 0 && ts < watermark) {
|
||||
reachedWatermark = true;
|
||||
break;
|
||||
}
|
||||
|
||||
// Skip notifications the user already read on another
|
||||
// client (web tab, Element, second device). Spec marks
|
||||
// `read` as a required boolean on each entry.
|
||||
if (entry.optBoolean("read", false)) {
|
||||
if (ts > highestTsSeen) highestTsSeen = ts;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip events the push rules said don't notify (muted
|
||||
// rooms, dont_notify overrides). Without this gate
|
||||
// polling would re-surface events Sygnal already
|
||||
// suppressed for the FCM path — the mute toggle
|
||||
// wouldn't actually mute on whitelist networks.
|
||||
if (!notifyAllowed(entry)) {
|
||||
if (ts > highestTsSeen) highestTsSeen = ts;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Cross-source dedup via NotificationDedup: FCM writes
|
||||
// into this set after every successful render, so the
|
||||
// Worker correctly skips events the FCM service already
|
||||
// delivered — even if the user dismissed the FCM
|
||||
// notification before this cycle fired.
|
||||
if (NotificationDedup.wasNotified(ctx, eventId)) {
|
||||
skippedDedupCount += 1;
|
||||
if (ts > highestTsSeen) highestTsSeen = ts;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Three outcomes for marking + watermark advance:
|
||||
// foreground → mark + advance (skip render
|
||||
// but consume state, otherwise
|
||||
// next bg poll would replay)
|
||||
// background + posted → mark + advance
|
||||
// background + !posted → DON'T mark, DON'T advance
|
||||
// (transient render failure
|
||||
// should be retried next poll)
|
||||
boolean posted = false;
|
||||
boolean treatAsNotRenderable = false;
|
||||
if (!inForeground) {
|
||||
Map<String, String> flattened = flattenNotification(entry, roomNames);
|
||||
String type = flattened.get("type");
|
||||
boolean isRtcType = RTC_NOTIFICATION_TYPE.equals(type)
|
||||
|| RTC_NOTIFICATION_TYPE_STABLE.equals(type);
|
||||
boolean isRing = "ring".equals(flattened.get("content_notification_type"));
|
||||
|
||||
if (isRtcType && isRing) {
|
||||
// Composite session dedup: if FCM already alerted
|
||||
// for this call session (different ring event,
|
||||
// same parent), skip posting a duplicate
|
||||
// missed-call. Without this, a session with one
|
||||
// FCM live-alert ring + one re-ring through
|
||||
// polling would surface as both a CallStyle and
|
||||
// a missed-call card. Helpers live in
|
||||
// VojoFirebaseMessagingService so the key shape
|
||||
// stays in lock-step across FCM and polling.
|
||||
String roomIdField = flattened.get("room_id");
|
||||
String sessionId = VojoFirebaseMessagingService
|
||||
.extractCallSessionId(flattened);
|
||||
String composite = null;
|
||||
if (roomIdField != null && sessionId != null) {
|
||||
composite = VojoFirebaseMessagingService
|
||||
.compositeCallDedupKey(roomIdField, sessionId);
|
||||
if (NotificationDedup.wasNotified(ctx, composite)) {
|
||||
if (ts > highestTsSeen) highestTsSeen = ts;
|
||||
treatAsNotRenderable = true;
|
||||
}
|
||||
}
|
||||
if (!treatAsNotRenderable) {
|
||||
// Stale ring (call lifetime is 30 seconds; we
|
||||
// poll every 15 minutes). Show "Missed call"
|
||||
// so the user knows somebody tried, without
|
||||
// phantom-ringing a long-dead call via
|
||||
// CallStyle.
|
||||
posted = VojoFirebaseMessagingService
|
||||
.renderMissedCallNotification(ctx, flattened);
|
||||
if (posted && composite != null) {
|
||||
// Mark the composite so the next polling
|
||||
// cycle observing a re-ring for the same
|
||||
// session doesn't double-post.
|
||||
NotificationDedup.markNotified(ctx, composite);
|
||||
}
|
||||
}
|
||||
} else if (isRtcType) {
|
||||
// Non-ring RTC sub-type. MSC4075 defines at least
|
||||
// "ring" and "notification" — the latter is the
|
||||
// chat-style alert variant which doesn't make
|
||||
// sense to surface as a stale "missed" entry from
|
||||
// a 15-minute poll. Falling through to
|
||||
// renderMessageNotification would post a generic
|
||||
// "New message" with no body (no content.body on
|
||||
// RTC events). Skip rendering but still mark seen
|
||||
// so we don't re-walk it next poll.
|
||||
treatAsNotRenderable = true;
|
||||
} else {
|
||||
posted = VojoFirebaseMessagingService
|
||||
.renderMessageNotification(ctx, flattened, null);
|
||||
}
|
||||
}
|
||||
// Mark + advance ts whenever we've consumed the event
|
||||
// (foreground-skipped, non-ring-RTC skipped, or
|
||||
// successfully rendered). Render-failure (bg branch where
|
||||
// posted==false) is intentionally excluded so the next
|
||||
// poll retries it.
|
||||
if (inForeground || posted || treatAsNotRenderable) {
|
||||
NotificationDedup.markNotified(ctx, eventId);
|
||||
if (ts > highestTsSeen) highestTsSeen = ts;
|
||||
if (posted) renderedCount += 1;
|
||||
}
|
||||
}
|
||||
|
||||
pagesFetched += 1;
|
||||
// optString returns the fallback only when the key is absent;
|
||||
// a literal JSON `null` becomes the string "null" — guard
|
||||
// against the rare server quirk so we don't loop on it.
|
||||
String rawNext = body.optString("next_token", null);
|
||||
if (rawNext == null || rawNext.isEmpty() || "null".equals(rawNext)) {
|
||||
nextFrom = null;
|
||||
} else {
|
||||
nextFrom = rawNext;
|
||||
}
|
||||
pendingCursor = nextFrom;
|
||||
if (nextFrom == null) {
|
||||
paginationExhausted = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
} catch (UnauthorizedException e) {
|
||||
Log.w(TAG, "poll: 401 — clearing credentials, awaiting next foreground re-bridge");
|
||||
prefs.edit()
|
||||
.remove(KEY_ACCESS_TOKEN)
|
||||
.apply();
|
||||
return Result.success();
|
||||
} catch (ForbiddenException e) {
|
||||
// 403 from Synapse is usually rate-limit or a transient server
|
||||
// policy reject, not a dead token. Don't clear credentials —
|
||||
// just let the next periodic fire retry. Avoid Result.retry()
|
||||
// because we don't want an immediate accelerated retry that
|
||||
// amplifies the rate-limit cause.
|
||||
Log.w(TAG, "poll: 403/429 — skipping this cycle, will retry on next scheduled fire");
|
||||
return Result.success();
|
||||
} catch (Throwable t) {
|
||||
Log.w(TAG, "poll: failed at page " + pagesFetched, t);
|
||||
return Result.retry();
|
||||
}
|
||||
|
||||
// Final stopped-check before persisting state. If cancellation landed
|
||||
// between the last in-loop check and here, do NOT apply: the
|
||||
// accumulated editor writes would otherwise overwrite KEY_LAST_SEEN_TS
|
||||
// and KEY_DRAIN_CURSOR AFTER JS clearSession wiped them, leaking
|
||||
// stale state from the just-logged-out account into the next login.
|
||||
if (isStopped()) return Result.success();
|
||||
|
||||
SharedPreferences.Editor editor = prefs.edit();
|
||||
// Drain-mode bookkeeping. Three transitions:
|
||||
// - normal → normal (cap not hit): advance watermark to highestTsSeen.
|
||||
// - normal → drain (cap hit, no prior drain): save continuation
|
||||
// cursor AND snapshot drainTargetTs = highestTsSeen. The current
|
||||
// run's highest ts becomes the "fast-forward" target for when
|
||||
// drain eventually completes — without this, the bounded LRU
|
||||
// could evict the original head events and let the post-drain
|
||||
// normal run re-render them.
|
||||
// - drain → drain (still capped): keep cursor + target unchanged.
|
||||
// Don't overwrite drainTargetTs with this run's highestTsSeen,
|
||||
// because drain pages are always OLDER than the original head.
|
||||
// - drain → normal (drain complete): clear cursor + target. Advance
|
||||
// watermark to drainTargetTs — drain pages always walk backwards
|
||||
// (older than the snapshotted head), so highestTsSeen accumulated
|
||||
// during drain is by construction ≤ drainTargetTs.
|
||||
boolean cappedWithMore = !reachedWatermark && !paginationExhausted && pendingCursor != null;
|
||||
long newWatermark = watermark;
|
||||
String drainState;
|
||||
if (cappedWithMore) {
|
||||
editor.putString(KEY_DRAIN_CURSOR, pendingCursor);
|
||||
if (!wasDraining) {
|
||||
// First run entering drain mode — snapshot the head ts.
|
||||
editor.putLong(KEY_DRAIN_TARGET_TS, highestTsSeen);
|
||||
drainState = "drain-entered";
|
||||
} else {
|
||||
drainState = "drain-continued";
|
||||
}
|
||||
} else {
|
||||
editor.remove(KEY_DRAIN_CURSOR);
|
||||
editor.remove(KEY_DRAIN_TARGET_TS);
|
||||
long advanceTo = wasDraining ? drainTargetTs : highestTsSeen;
|
||||
if (advanceTo > watermark) {
|
||||
editor.putLong(KEY_LAST_SEEN_TS, advanceTo);
|
||||
newWatermark = advanceTo;
|
||||
}
|
||||
drainState = wasDraining ? "drain-exited" : "normal";
|
||||
}
|
||||
editor.apply();
|
||||
|
||||
Log.i(TAG, "poll: done pages=" + pagesFetched
|
||||
+ " rendered=" + renderedCount
|
||||
+ " dedupSkipped=" + skippedDedupCount
|
||||
+ " watermark=" + newWatermark
|
||||
+ " state=" + drainState);
|
||||
return Result.success();
|
||||
}
|
||||
|
||||
// Returns true iff at least one element of entry.actions is the literal
|
||||
// string "notify". Per Matrix spec §13.13.1, tweak objects
|
||||
// (`{set_tweak: ...}`) only MODIFY a notification produced by a separate
|
||||
// `"notify"` action — they do not by themselves imply notify. "dont_notify"
|
||||
// or an empty actions array means the push rule explicitly suppressed
|
||||
// this event (most commonly: a muted room).
|
||||
private static boolean notifyAllowed(JSONObject entry) {
|
||||
JSONArray actions = entry.optJSONArray("actions");
|
||||
if (actions == null || actions.length() == 0) return false;
|
||||
for (int i = 0; i < actions.length(); i += 1) {
|
||||
Object a = actions.opt(i);
|
||||
if ((a instanceof String) && "notify".equals(a)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────
|
||||
// HTTP
|
||||
// ────────────────────────────────────────────────────────────────────
|
||||
|
||||
private static final class UnauthorizedException extends IOException {
|
||||
UnauthorizedException() {
|
||||
super("401 Unauthorized");
|
||||
}
|
||||
}
|
||||
|
||||
// 403 from Synapse is most commonly a rate-limit or a transient policy
|
||||
// reject (M_LIMIT_EXCEEDED, M_FORBIDDEN). It is NOT "token died" — we
|
||||
// surface it as a distinct exception so doWork can skip this cycle
|
||||
// without clearing credentials and without an accelerated Result.retry()
|
||||
// that would amplify the rate-limit cause.
|
||||
private static final class ForbiddenException extends IOException {
|
||||
ForbiddenException() {
|
||||
super("403 Forbidden");
|
||||
}
|
||||
}
|
||||
|
||||
private JSONObject fetchNotifications(String homeserverUrl, String token, String fromCursor)
|
||||
throws IOException {
|
||||
StringBuilder url = new StringBuilder(homeserverUrl);
|
||||
if (!homeserverUrl.endsWith("/")) url.append('/');
|
||||
url.append("_matrix/client/v3/notifications?limit=").append(PAGE_LIMIT);
|
||||
if (fromCursor != null && !fromCursor.isEmpty()) {
|
||||
url.append("&from=").append(java.net.URLEncoder.encode(fromCursor, "UTF-8"));
|
||||
}
|
||||
|
||||
HttpURLConnection conn = (HttpURLConnection) new URL(url.toString()).openConnection();
|
||||
try {
|
||||
conn.setRequestMethod("GET");
|
||||
conn.setRequestProperty("Authorization", "Bearer " + token);
|
||||
conn.setRequestProperty("Accept", "application/json");
|
||||
// Identifiable UA so server logs can attribute polling traffic
|
||||
// (some WAFs also flag bare "Java/<version>" as suspicious).
|
||||
conn.setRequestProperty("User-Agent", "Vojo-Android-Poll/" + BuildConfig.VERSION_NAME);
|
||||
conn.setConnectTimeout(HTTP_TIMEOUT_MS);
|
||||
conn.setReadTimeout(HTTP_TIMEOUT_MS);
|
||||
int code = conn.getResponseCode();
|
||||
if (code == 401) throw new UnauthorizedException();
|
||||
// Treat 429 (rate limited) and 403 (Synapse policy reject) the
|
||||
// same: skip this cycle, don't retry-storm. Result.retry()'s 30s
|
||||
// backoff would amplify the rate-limit cause; the next periodic
|
||||
// fire in 15 minutes is well past any realistic Retry-After
|
||||
// window from a Matrix homeserver.
|
||||
if (code == 403 || code == 429) throw new ForbiddenException();
|
||||
if (code < 200 || code >= 300) {
|
||||
throw new IOException("HTTP " + code);
|
||||
}
|
||||
try (InputStream in = conn.getInputStream()) {
|
||||
return new JSONObject(readAll(in));
|
||||
} catch (org.json.JSONException je) {
|
||||
throw new IOException("malformed JSON", je);
|
||||
}
|
||||
} finally {
|
||||
conn.disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
private static String readAll(InputStream in) throws IOException {
|
||||
// Accumulate raw bytes, then decode the whole buffer as a single UTF-8
|
||||
// string. Decoding each 8 KB chunk separately would corrupt multi-byte
|
||||
// sequences that straddle a chunk boundary — for a Russian-content
|
||||
// notification body that crosses ~8 KB, the result is U+FFFD in place
|
||||
// of a Cyrillic character. Also use != -1 rather than > 0 for the
|
||||
// read loop: InputStream.read(byte[]) is contractually allowed to
|
||||
// return 0 without indicating EOF.
|
||||
java.io.ByteArrayOutputStream out = new java.io.ByteArrayOutputStream();
|
||||
byte[] buf = new byte[8 * 1024];
|
||||
int n;
|
||||
while ((n = in.read(buf)) != -1) {
|
||||
if (n > 0) out.write(buf, 0, n);
|
||||
}
|
||||
return out.toString("UTF-8");
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────
|
||||
// Payload shaping
|
||||
//
|
||||
// The /notifications response shape is structured (event{type,sender,
|
||||
// content{}}, room_id, ts, read, actions) — different from Sygnal's
|
||||
// flattened FCM payload. We flatten into the Sygnal-shape Map<String,
|
||||
// String> so the shared renderer in VojoFirebaseMessagingService can
|
||||
// stay source-agnostic. Keys we set: event_id, room_id, sender, type,
|
||||
// content_membership, content_body, content_notification_type,
|
||||
// content_sender_ts, content_lifetime, room_name (from local cache).
|
||||
//
|
||||
// NOTE: sender_display_name is NOT set here — /notifications returns the
|
||||
// raw event without the Sygnal-side profile resolution that gives FCM
|
||||
// its `sender_display_name`. The renderer's title-fallback chain
|
||||
// (room_name → sender_display_name → sender → "Vojo") therefore lands
|
||||
// on `sender` (a raw MXID) when the room name isn't cached. The renderer
|
||||
// strips the MXID to its local-part as a final cosmetic guard so users
|
||||
// see "alice" instead of "@alice:hs.tld".
|
||||
// ────────────────────────────────────────────────────────────────────
|
||||
|
||||
private static Map<String, String> flattenNotification(
|
||||
JSONObject entry, Map<String, String> roomNames
|
||||
) {
|
||||
Map<String, String> out = new HashMap<>();
|
||||
String roomId = entry.optString("room_id", null);
|
||||
if (roomId != null) out.put("room_id", roomId);
|
||||
|
||||
JSONObject event = entry.optJSONObject("event");
|
||||
if (event != null) {
|
||||
putIfPresent(out, event, "event_id", "event_id");
|
||||
putIfPresent(out, event, "sender", "sender");
|
||||
putIfPresent(out, event, "type", "type");
|
||||
JSONObject content = event.optJSONObject("content");
|
||||
if (content != null) {
|
||||
putIfPresent(out, content, "membership", "content_membership");
|
||||
putIfPresent(out, content, "body", "content_body");
|
||||
putIfPresent(out, content, "notification_type", "content_notification_type");
|
||||
if (content.has("sender_ts")) {
|
||||
out.put("content_sender_ts", String.valueOf(content.optLong("sender_ts")));
|
||||
}
|
||||
if (content.has("lifetime")) {
|
||||
out.put("content_lifetime", String.valueOf(content.optLong("lifetime")));
|
||||
}
|
||||
// Parent call event_id for session-level dedup. The shared
|
||||
// FCM renderer reads this from the flattened key
|
||||
// `content_m.relates_to_event_id` (mirroring one of Sygnal's
|
||||
// flatten shapes); writing the literal-dot variant here keeps
|
||||
// FCM and polling on the same key.
|
||||
JSONObject relates = content.optJSONObject("m.relates_to");
|
||||
if (relates != null) {
|
||||
String parentEventId = relates.optString("event_id", null);
|
||||
if (parentEventId != null && !parentEventId.isEmpty()) {
|
||||
out.put("content_m.relates_to_event_id", parentEventId);
|
||||
}
|
||||
}
|
||||
// Legacy MSC2746 call_id fallback. Modern MSC4075 sessions
|
||||
// surface via m.relates_to above; this branch is a no-op for
|
||||
// them but keeps the shape symmetric for older deployments.
|
||||
if (content.has("call_id")) {
|
||||
String callId = content.optString("call_id", null);
|
||||
if (callId != null && !callId.isEmpty()) {
|
||||
out.put("content_call_id", callId);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Room name from the snapshot the JS side pushes through
|
||||
// PollingPlugin.saveRoomNames, parsed once at the start of doWork().
|
||||
// Brand-new rooms (not yet observed by JS at last bridge time) miss
|
||||
// the cache — the renderer falls back to sender / "Vojo".
|
||||
if (roomId != null) {
|
||||
String roomName = roomNames.get(roomId);
|
||||
if (roomName != null && !roomName.isEmpty()) out.put("room_name", roomName);
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
// Parse the SharedPreferences-stored room-name JSON snapshot once per
|
||||
// doWork() so we don't redo the parse for every event in the page (up to
|
||||
// PAGE_LIMIT × MAX_PAGES_PER_RUN = 250 events).
|
||||
//
|
||||
// The snapshot shape evolved: legacy was {roomId: "Display name"}, current
|
||||
// is {roomId: {name, isDirect, isEncrypted, avatarMxc?}}. We parse both
|
||||
// tolerantly — for the structured shape we extract `name`, for the legacy
|
||||
// shape we use the string verbatim. A naive optString on the structured
|
||||
// entry serialises the whole object as JSON ("{name:Alice,...}") and that
|
||||
// string leaked into the missed-call / message title on the polling
|
||||
// path — visible bug.
|
||||
private static Map<String, String> loadRoomNamesMap(SharedPreferences prefs) {
|
||||
Map<String, String> out = new HashMap<>();
|
||||
String raw = prefs.getString(KEY_ROOM_NAMES, null);
|
||||
if (raw == null || raw.isEmpty()) return out;
|
||||
try {
|
||||
JSONObject map = new JSONObject(raw);
|
||||
for (Iterator<String> it = map.keys(); it.hasNext(); ) {
|
||||
String roomId = it.next();
|
||||
if (map.isNull(roomId)) continue;
|
||||
JSONObject obj = map.optJSONObject(roomId);
|
||||
String name = obj != null
|
||||
? obj.optString("name", null)
|
||||
: map.optString(roomId, null);
|
||||
if (name != null && !name.isEmpty()) out.put(roomId, name);
|
||||
}
|
||||
} catch (org.json.JSONException je) {
|
||||
// Corrupt blob — return empty map. Renderer falls back to sender.
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
private static void putIfPresent(
|
||||
Map<String, String> out, JSONObject src, String srcKey, String dstKey
|
||||
) {
|
||||
// Guard against a literal JSON null at the key: JSONObject.optString
|
||||
// returns the *fallback* only when the key is absent, but on a
|
||||
// present-but-null key it coerces JSONObject.NULL to the four-char
|
||||
// string "null", which would leak as "null" into a notification body.
|
||||
if (!src.has(srcKey) || src.isNull(srcKey)) return;
|
||||
String v = src.optString(srcKey, null);
|
||||
if (v != null && !v.isEmpty()) out.put(dstKey, v);
|
||||
}
|
||||
|
||||
private static String extractEventId(JSONObject entry) {
|
||||
JSONObject event = entry.optJSONObject("event");
|
||||
if (event == null) return null;
|
||||
if (!event.has("event_id") || event.isNull("event_id")) return null;
|
||||
String eventId = event.optString("event_id", null);
|
||||
if (eventId == null || eventId.isEmpty()) return null;
|
||||
return eventId;
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
|
@ -1,34 +0,0 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:aapt="http://schemas.android.com/aapt"
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:viewportHeight="108"
|
||||
android:viewportWidth="108">
|
||||
<path
|
||||
android:fillType="evenOdd"
|
||||
android:pathData="M32,64C32,64 38.39,52.99 44.13,50.95C51.37,48.37 70.14,49.57 70.14,49.57L108.26,87.69L108,109.01L75.97,107.97L32,64Z"
|
||||
android:strokeColor="#00000000"
|
||||
android:strokeWidth="1">
|
||||
<aapt:attr name="android:fillColor">
|
||||
<gradient
|
||||
android:endX="78.5885"
|
||||
android:endY="90.9159"
|
||||
android:startX="48.7653"
|
||||
android:startY="61.0927"
|
||||
android:type="linear">
|
||||
<item
|
||||
android:color="#44000000"
|
||||
android:offset="0.0" />
|
||||
<item
|
||||
android:color="#00000000"
|
||||
android:offset="1.0" />
|
||||
</gradient>
|
||||
</aapt:attr>
|
||||
</path>
|
||||
<path
|
||||
android:fillColor="#FFFFFF"
|
||||
android:fillType="nonZero"
|
||||
android:pathData="M66.94,46.02L66.94,46.02C72.44,50.07 76,56.61 76,64L32,64C32,56.61 35.56,50.11 40.98,46.06L36.18,41.19C35.45,40.45 35.45,39.3 36.18,38.56C36.91,37.81 38.05,37.81 38.78,38.56L44.25,44.05C47.18,42.57 50.48,41.71 54,41.71C57.48,41.71 60.78,42.57 63.68,44.05L69.11,38.56C69.84,37.81 70.98,37.81 71.71,38.56C72.44,39.3 72.44,40.45 71.71,41.19L66.94,46.02ZM62.94,56.92C64.08,56.92 65,56.01 65,54.88C65,53.76 64.08,52.85 62.94,52.85C61.8,52.85 60.88,53.76 60.88,54.88C60.88,56.01 61.8,56.92 62.94,56.92ZM45.06,56.92C46.2,56.92 47.13,56.01 47.13,54.88C47.13,53.76 46.2,52.85 45.06,52.85C43.92,52.85 43,53.76 43,54.88C43,56.01 43.92,56.92 45.06,56.92Z"
|
||||
android:strokeColor="#00000000"
|
||||
android:strokeWidth="1" />
|
||||
</vector>
|
||||
|
|
@ -1,170 +0,0 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:viewportHeight="108"
|
||||
android:viewportWidth="108">
|
||||
<path
|
||||
android:fillColor="#26A69A"
|
||||
android:pathData="M0,0h108v108h-108z" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M9,0L9,108"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,0L19,108"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M29,0L29,108"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M39,0L39,108"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M49,0L49,108"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M59,0L59,108"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M69,0L69,108"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M79,0L79,108"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M89,0L89,108"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M99,0L99,108"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,9L108,9"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,19L108,19"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,29L108,29"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,39L108,39"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,49L108,49"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,59L108,59"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,69L108,69"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,79L108,79"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,89L108,89"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,99L108,99"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,29L89,29"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,39L89,39"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,49L89,49"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,59L89,59"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,69L89,69"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,79L89,79"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M29,19L29,89"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M39,19L39,89"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M49,19L49,89"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M59,19L59,89"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M69,19L69,89"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M79,19L79,89"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
</vector>
|
||||
|
Before Width: | Height: | Size: 164 KiB |
|
|
@ -1,12 +0,0 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
tools:context=".MainActivity">
|
||||
|
||||
<WebView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent" />
|
||||
</androidx.coordinatorlayout.widget.CoordinatorLayout>
|
||||
|
|
@ -1,5 +0,0 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@color/ic_launcher_background"/>
|
||||
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
|
||||
</adaptive-icon>
|
||||
|
|
@ -1,5 +0,0 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@color/ic_launcher_background"/>
|
||||
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
|
||||
</adaptive-icon>
|
||||
|
Before Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 5.8 KiB |
|
Before Width: | Height: | Size: 9.7 KiB |
|
Before Width: | Height: | Size: 5.8 KiB |
|
Before Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 36 KiB |
|
Before Width: | Height: | Size: 45 KiB |
|
Before Width: | Height: | Size: 36 KiB |
|
Before Width: | Height: | Size: 52 KiB |
|
Before Width: | Height: | Size: 66 KiB |
|
Before Width: | Height: | Size: 52 KiB |
|
|
@ -1,7 +0,0 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<!-- Matches web safe-area / DM 1:1 chat background (DAWN.bg2) so the
|
||||
native splash, the WebView body, and the in-app AuthSplashScreen all
|
||||
share a single backdrop and read as one continuous splash. -->
|
||||
<color name="splash_bg">#0d0e11</color>
|
||||
</resources>
|
||||
|
|
@ -1,4 +0,0 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="ic_launcher_background">#121314</color>
|
||||
</resources>
|
||||
|
|
@ -1,7 +0,0 @@
|
|||
<?xml version='1.0' encoding='utf-8'?>
|
||||
<resources>
|
||||
<string name="app_name">Vojo</string>
|
||||
<string name="title_activity_main">Vojo</string>
|
||||
<string name="package_name">chat.vojo.app</string>
|
||||
<string name="custom_url_scheme">chat.vojo.app</string>
|
||||
</resources>
|
||||
|
|
@ -1,53 +0,0 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
|
||||
<!-- Base application theme. -->
|
||||
<style name="AppTheme" parent="Theme.AppCompat.DayNight.NoActionBar">
|
||||
<item name="colorPrimary">@color/colorPrimary</item>
|
||||
<item name="colorPrimaryDark">@color/colorPrimaryDark</item>
|
||||
<item name="colorAccent">@color/colorAccent</item>
|
||||
<item name="windowActionBar">false</item>
|
||||
<item name="windowNoTitle">true</item>
|
||||
</style>
|
||||
|
||||
<style name="AppTheme.NoActionBar" parent="Theme.AppCompat.DayNight.NoActionBar">
|
||||
<item name="windowActionBar">false</item>
|
||||
<item name="windowNoTitle">true</item>
|
||||
<item name="android:background">@null</item>
|
||||
<item name="android:windowLayoutInDisplayCutoutMode">shortEdges</item>
|
||||
<!-- Bridges the gap between native splash exit and the WebView's first
|
||||
body paint: without this the window paints transparent/black for
|
||||
~200ms while the bundle hydrates, producing a visible black flash
|
||||
between the native and the in-app splash. Matches splash_bg so
|
||||
cold start reads as one continuous backdrop. -->
|
||||
<item name="android:windowBackground">@color/splash_bg</item>
|
||||
</style>
|
||||
|
||||
<!-- Launch theme: Android 12+ system splash (Theme.SplashScreen via
|
||||
androidx.core.splashscreen). Renders the mascot centered on the same
|
||||
#0d0e11 backdrop the web AuthSplashScreen uses, so cold start reads
|
||||
as one continuous splash (native → WebView mount → web splash) instead
|
||||
of three visual jumps. MainActivity installs AndroidX SplashScreen
|
||||
before super.onCreate() and keeps it visible until Capacitor's local
|
||||
WebView has loaded the app shell. -->
|
||||
<style name="AppTheme.NoActionBarLaunch" parent="Theme.SplashScreen">
|
||||
<!-- Theme.SplashScreen only sets the native android:windowActionBar /
|
||||
android:windowNoTitle attrs. Capacitor's BridgeActivity extends
|
||||
AppCompatActivity, whose ActionBar delegate reads the un-prefixed
|
||||
AppCompat attrs — without these two overrides, AppCompat keeps
|
||||
its ActionBar enabled, paints the activity label ("Vojo" from
|
||||
strings.xml/title_activity_main) at the top of the WebView, and
|
||||
persists past the splash exit. -->
|
||||
<item name="windowActionBar">false</item>
|
||||
<item name="windowNoTitle">true</item>
|
||||
<item name="windowSplashScreenBackground">@color/splash_bg</item>
|
||||
<item name="windowSplashScreenAnimatedIcon">@drawable/vojo_mascot_splash</item>
|
||||
<!-- Intentionally NO windowSplashScreenIconBackgroundColor: setting it
|
||||
switches the system to the "with-background" canvas, which is
|
||||
actually 240dp (vs 288dp without) — the colored ring would just
|
||||
shrink the visible icon zone. Background already matches via
|
||||
windowSplashScreenBackground above. -->
|
||||
<item name="postSplashScreenTheme">@style/AppTheme.NoActionBar</item>
|
||||
<item name="android:windowLayoutInDisplayCutoutMode">shortEdges</item>
|
||||
</style>
|
||||
</resources>
|
||||
|
|
@ -1,5 +0,0 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<paths xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<external-path name="my_images" path="." />
|
||||
<cache-path name="my_cache_images" path="." />
|
||||
</paths>
|
||||
|
|
@ -1,18 +0,0 @@
|
|||
package com.getcapacitor.myapp;
|
||||
|
||||
import static org.junit.Assert.*;
|
||||
|
||||
import org.junit.Test;
|
||||
|
||||
/**
|
||||
* Example local unit test, which will execute on the development machine (host).
|
||||
*
|
||||
* @see <a href="http://d.android.com/tools/testing">Testing documentation</a>
|
||||
*/
|
||||
public class ExampleUnitTest {
|
||||
|
||||
@Test
|
||||
public void addition_isCorrect() throws Exception {
|
||||
assertEquals(4, 2 + 2);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,29 +0,0 @@
|
|||
// Top-level build file where you can add configuration options common to all sub-projects/modules.
|
||||
|
||||
buildscript {
|
||||
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
}
|
||||
dependencies {
|
||||
classpath 'com.android.tools.build:gradle:8.13.0'
|
||||
classpath 'com.google.gms:google-services:4.4.4'
|
||||
|
||||
// NOTE: Do not place your application dependencies here; they belong
|
||||
// in the individual module build.gradle files
|
||||
}
|
||||
}
|
||||
|
||||
apply from: "variables.gradle"
|
||||
|
||||
allprojects {
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
}
|
||||
}
|
||||
|
||||
task clean(type: Delete) {
|
||||
delete rootProject.buildDir
|
||||
}
|
||||
|
|
@ -1,18 +0,0 @@
|
|||
// DO NOT EDIT THIS FILE! IT IS GENERATED EACH TIME "capacitor update" IS RUN
|
||||
include ':capacitor-android'
|
||||
project(':capacitor-android').projectDir = new File('../node_modules/@capacitor/android/capacitor')
|
||||
|
||||
include ':capacitor-app'
|
||||
project(':capacitor-app').projectDir = new File('../node_modules/@capacitor/app/android')
|
||||
|
||||
include ':capacitor-browser'
|
||||
project(':capacitor-browser').projectDir = new File('../node_modules/@capacitor/browser/android')
|
||||
|
||||
include ':capacitor-preferences'
|
||||
project(':capacitor-preferences').projectDir = new File('../node_modules/@capacitor/preferences/android')
|
||||
|
||||
include ':capacitor-push-notifications'
|
||||
project(':capacitor-push-notifications').projectDir = new File('../node_modules/@capacitor/push-notifications/android')
|
||||
|
||||
include ':capacitor-toast'
|
||||
project(':capacitor-toast').projectDir = new File('../node_modules/@capacitor/toast/android')
|
||||
|
|
@ -1,22 +0,0 @@
|
|||
# Project-wide Gradle settings.
|
||||
|
||||
# IDE (e.g. Android Studio) users:
|
||||
# Gradle settings configured through the IDE *will override*
|
||||
# any settings specified in this file.
|
||||
|
||||
# For more details on how to configure your build environment visit
|
||||
# http://www.gradle.org/docs/current/userguide/build_environment.html
|
||||
|
||||
# Specifies the JVM arguments used for the daemon process.
|
||||
# The setting is particularly useful for tweaking memory settings.
|
||||
org.gradle.jvmargs=-Xmx1536m
|
||||
|
||||
# When configured, Gradle will run in incubating parallel mode.
|
||||
# This option should only be used with decoupled projects. More details, visit
|
||||
# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects
|
||||
# org.gradle.parallel=true
|
||||
|
||||
# AndroidX package structure to make it clearer which packages are bundled with the
|
||||
# Android operating system, and which are packaged with your app's APK
|
||||
# https://developer.android.com/topic/libraries/support-library/androidx-rn
|
||||
android.useAndroidX=true
|
||||
BIN
android/gradle/wrapper/gradle-wrapper.jar
vendored
|
|
@ -1,7 +0,0 @@
|
|||
distributionBase=GRADLE_USER_HOME
|
||||
distributionPath=wrapper/dists
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.3-all.zip
|
||||
networkTimeout=10000
|
||||
validateDistributionUrl=true
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
zipStorePath=wrapper/dists
|
||||