mirror of
https://github.com/vernu/textbee.git
synced 2026-09-03 03:29:58 +03:00
Compare commits
278
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
670d0819b8 | ||
|
|
dcb12c6b59 | ||
|
|
bd808333b3 | ||
|
|
9c0826016a | ||
|
|
82973444c2 | ||
|
|
290149c8fb | ||
|
|
06295ae7f1 | ||
|
|
5bae4ffd9a | ||
|
|
3e4597294c | ||
|
|
c1942a4ba3 | ||
|
|
366a47913b | ||
|
|
469aa0025c | ||
|
|
5d43866847 | ||
|
|
e75dbc7a67 | ||
|
|
b2acba4c2c | ||
|
|
2b766e8019 | ||
|
|
022a6bd2a6 | ||
|
|
ad7bcd99a5 | ||
|
|
f65af5003b | ||
|
|
6da80ac4e7 | ||
|
|
05143b8752 | ||
|
|
1349134107 | ||
|
|
628e5b09d1 | ||
|
|
1999593e07 | ||
|
|
9913b7cbe7 | ||
|
|
8c818351d6 | ||
|
|
4cdcce6324 | ||
|
|
ffbbabdbd3 | ||
|
|
9cb1213efc | ||
|
|
1979e2f0b5 | ||
|
|
99a3a79b3b | ||
|
|
a949729c07 | ||
|
|
02bd1d42e0 | ||
|
|
0ea85c05e0 | ||
|
|
50d0902535 | ||
|
|
4ce6d00456 | ||
|
|
60e59f0221 | ||
|
|
f5815e1069 | ||
|
|
8a5f12a6eb | ||
|
|
bdae87aaf8 | ||
|
|
516e626213 | ||
|
|
6c3636104e | ||
|
|
e6bb6e2385 | ||
|
|
dda38204fa | ||
|
|
79e6dce8f7 | ||
|
|
eba534d208 | ||
|
|
2623118df3 | ||
|
|
d1548b4001 | ||
|
|
9111ca0975 | ||
|
|
e34dd7ac10 | ||
|
|
f821f480c7 | ||
|
|
6fe0a5dde0 | ||
|
|
c1ac054cd6 | ||
|
|
d906fb1b97 | ||
|
|
55ad2a6a5a | ||
|
|
dbea68ab30 | ||
|
|
497a14074a | ||
|
|
89f2f78706 | ||
|
|
8072904ea2 | ||
|
|
ea1b00ce3f | ||
|
|
c92740c401 | ||
|
|
95caebfdb9 | ||
|
|
92e52679fb | ||
|
|
eda97c154f | ||
|
|
c200ec375d | ||
|
|
ef45421150 | ||
|
|
d6c41a43c6 | ||
|
|
de8df1c53c | ||
|
|
bfc1e8c966 | ||
|
|
164124d616 | ||
|
|
1c4a2d6528 | ||
|
|
c846279d41 | ||
|
|
4fdca11cde | ||
|
|
fd7e6cac3f | ||
|
|
db7880db0d | ||
|
|
3f1037373f | ||
|
|
288ad7d15a | ||
|
|
4753d357ab | ||
|
|
6ce9fce9a2 | ||
|
|
243bbdd1d0 | ||
|
|
9b629d3291 | ||
|
|
954f337b2f | ||
|
|
f6a8f21e64 | ||
|
|
5389117fcd | ||
|
|
08d9015048 | ||
|
|
260cf50780 | ||
|
|
c235526583 | ||
|
|
3bef5f90a0 | ||
|
|
0e4c7481b9 | ||
|
|
3f7864c754 | ||
|
|
110bdbc3ef | ||
|
|
267996ad90 | ||
|
|
93ac880dd7 | ||
|
|
35a98f3288 | ||
|
|
b1c8585e11 | ||
|
|
f4e23ba3d3 | ||
|
|
1b35e76d82 | ||
|
|
8046324c48 | ||
|
|
31a69e62ee | ||
|
|
336bb65e0a | ||
|
|
c952040b2b | ||
|
|
cf02c3c50d | ||
|
|
004f50d82d | ||
|
|
4f321108ff | ||
|
|
f6556ea512 | ||
|
|
b57a55121f | ||
|
|
7e9c036ec1 | ||
|
|
37f99ae662 | ||
|
|
1a5e095c56 | ||
|
|
677b2dda64 | ||
|
|
6f5190aade | ||
|
|
11ddcf3bd7 | ||
|
|
aea2d27fbb | ||
|
|
14b91f19d7 | ||
|
|
9586712712 | ||
|
|
9bf7074de3 | ||
|
|
77f2f38686 | ||
|
|
bd6bcfd985 | ||
|
|
5467a85fb0 | ||
|
|
e3723054b6 | ||
|
|
9b6e044aa2 | ||
|
|
97e60f4ff9 | ||
|
|
8286938b9e | ||
|
|
f5985ceccb | ||
|
|
f64a520b86 | ||
|
|
c5630b30e9 | ||
|
|
4da8570cd2 | ||
|
|
839ad24c83 | ||
|
|
af03623396 | ||
|
|
fb3b2c1165 | ||
|
|
ab76237fbb | ||
|
|
7326bd7b3b | ||
|
|
01af5a9786 | ||
|
|
ef4e42a204 | ||
|
|
7bec9e8acf | ||
|
|
cb616c6bf5 | ||
|
|
002ac9c144 | ||
|
|
ffa66bed9b | ||
|
|
87006c226f | ||
|
|
9d4ec8186b | ||
|
|
dafc433575 | ||
|
|
a87094498c | ||
|
|
c488c16f59 | ||
|
|
2c6e3b8e6c | ||
|
|
60808db77a | ||
|
|
a43cf11bcd | ||
|
|
7a56692586 | ||
|
|
d613217efb | ||
|
|
598d697306 | ||
|
|
5ae010f1c3 | ||
|
|
94a0cd8c0a | ||
|
|
2ec9207779 | ||
|
|
51cb1ca198 | ||
|
|
e49f3954c3 | ||
|
|
17351e2148 | ||
|
|
8eec17ba1b | ||
|
|
25c70eb09d | ||
|
|
acde97f080 | ||
|
|
e14b71a790 | ||
|
|
5023f80f0f | ||
|
|
2bdcdeb9a9 | ||
|
|
b42a681816 | ||
|
|
14fd2f9b03 | ||
|
|
540bb9068b | ||
|
|
3061bd8d66 | ||
|
|
338fb665bc | ||
|
|
71475f0db8 | ||
|
|
40b0df297c | ||
|
|
863aac04e8 | ||
|
|
6e7ed42fe8 | ||
|
|
8fd06f06d1 | ||
|
|
0144cff16e | ||
|
|
7ff6ea23fc | ||
|
|
fd4969c92c | ||
|
|
4d1b53e247 | ||
|
|
c668530188 | ||
|
|
3cd1d94b20 | ||
|
|
30591c4847 | ||
|
|
f5bef39cec | ||
|
|
482049a764 | ||
|
|
ac65907453 | ||
|
|
584e2c5bec | ||
|
|
9685ecb7b3 | ||
|
|
d8460bcb8b | ||
|
|
eb94085f6e | ||
|
|
b1281d4ec1 | ||
|
|
46fa5cda2e | ||
|
|
709f05f33d | ||
|
|
844a815813 | ||
|
|
b8d277a563 | ||
|
|
d335f76088 | ||
|
|
42abcec94b | ||
|
|
014ae354c6 | ||
|
|
2692d4a154 | ||
|
|
1b24cd9754 | ||
|
|
e6ad6a18dc | ||
|
|
d51693d26d | ||
|
|
07f80fdf84 | ||
|
|
b762c6e2ef | ||
|
|
c1952d2030 | ||
|
|
ebc90d8105 | ||
|
|
fd0d86efab | ||
|
|
32b463a10c | ||
|
|
ed55406687 | ||
|
|
b6dde1a096 | ||
|
|
312e3a4fd4 | ||
|
|
3939502db7 | ||
|
|
9ae9f5978b | ||
|
|
58e6c73a72 | ||
|
|
f1c8530257 | ||
|
|
ede8e4c210 | ||
|
|
a6df612bfa | ||
|
|
4b4f3bace9 | ||
|
|
8a42b95e8f | ||
|
|
e0e6be9379 | ||
|
|
0665aa5432 | ||
|
|
dc8ca9d9a7 | ||
|
|
541f32406e | ||
|
|
141b8b334a | ||
|
|
f9fed4d3d8 | ||
|
|
4ef65b08ff | ||
|
|
86060456fa | ||
|
|
ca0ac8cf6e | ||
|
|
ba0437ff01 | ||
|
|
99a9ee3cc1 | ||
|
|
f2ca0cecf6 | ||
|
|
0ff96f4857 | ||
|
|
e2cf5d2e37 | ||
|
|
d6d52e80cf | ||
|
|
30691e2e0d | ||
|
|
7308ab6721 | ||
|
|
fd184a4700 | ||
|
|
af19f6c75a | ||
|
|
10ead48014 | ||
|
|
463747b82f | ||
|
|
d99b53f84c | ||
|
|
bbbe85e0fd | ||
|
|
df0ef06495 | ||
|
|
130a6934d6 | ||
|
|
3ea8c0d5a0 | ||
|
|
fd5ba1c538 | ||
|
|
91ac0b3eb0 | ||
|
|
c26ac378f6 | ||
|
|
bf66e94e6d | ||
|
|
552a90685a | ||
|
|
e2246cf8e8 | ||
|
|
455696275b | ||
|
|
0ffa945668 | ||
|
|
6c40e2311e | ||
|
|
fbac1fae0b | ||
|
|
127d1f4127 | ||
|
|
7ce4efff70 | ||
|
|
dd9ec0aacc | ||
|
|
6d081c7489 | ||
|
|
500d880aea | ||
|
|
4995b6023b | ||
|
|
ea8c21ff21 | ||
|
|
a9b1cd6549 | ||
|
|
e3745d5071 | ||
|
|
82aa3cb425 | ||
|
|
b57ebc7500 | ||
|
|
740b691f67 | ||
|
|
5e509b1b57 | ||
|
|
111dbc43f2 | ||
|
|
d704b04e3d | ||
|
|
0d5f9a7cec | ||
|
|
f66d2855a7 | ||
|
|
fe588ea05a | ||
|
|
704a8dbf95 | ||
|
|
4f64880cf8 | ||
|
|
63733651d5 | ||
|
|
88e0b3df63 | ||
|
|
9b98a9e509 | ||
|
|
52565cc55c | ||
|
|
023df71d3c | ||
|
|
bce6b3be94 | ||
|
|
3227fb6696 | ||
|
|
e5b63dbb6a |
@@ -38,12 +38,12 @@ jobs:
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: '18'
|
||||
node-version: '20'
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v2
|
||||
with:
|
||||
version: 8
|
||||
version: 9
|
||||
run_install: false
|
||||
|
||||
- name: Get pnpm store directory
|
||||
@@ -66,11 +66,51 @@ jobs:
|
||||
pnpm run build
|
||||
pnpm test
|
||||
|
||||
- name: Build web
|
||||
# Web previously ran `build` only, so its unit and e2e suites never
|
||||
# gated a merge. Lint, typecheck and unit tests are fast and
|
||||
# deterministic, so they block.
|
||||
- name: Install web dependencies
|
||||
run: |
|
||||
cd web
|
||||
pnpm install
|
||||
pnpm run build
|
||||
|
||||
- name: Lint web
|
||||
run: |
|
||||
cd web
|
||||
pnpm lint
|
||||
|
||||
- name: Typecheck web
|
||||
run: |
|
||||
cd web
|
||||
pnpm typecheck
|
||||
|
||||
- name: Unit test web
|
||||
run: |
|
||||
cd web
|
||||
pnpm test
|
||||
|
||||
- name: Build web
|
||||
run: |
|
||||
cd web
|
||||
pnpm run build
|
||||
|
||||
# E2e is fully mocked (e2e/mock-api.ts intercepts every backend call),
|
||||
# but it drives a real browser, so it is reported without blocking until
|
||||
# it has proven stable across a few merges.
|
||||
#
|
||||
# TODO: remove continue-on-error once that is established. It is here to
|
||||
# avoid wedging the merge queue on a browser flake, not because e2e
|
||||
# failures are acceptable.
|
||||
- name: Install Playwright browser
|
||||
run: |
|
||||
cd web
|
||||
pnpm exec playwright install --with-deps chromium
|
||||
|
||||
- name: E2e test web (non-blocking for now)
|
||||
continue-on-error: true
|
||||
run: |
|
||||
cd web
|
||||
pnpm test:e2e
|
||||
|
||||
build-and-test-android:
|
||||
name: Build and Test Android
|
||||
|
||||
@@ -45,19 +45,19 @@ jobs:
|
||||
ref: ${{ github.event.inputs.git_ref || github.ref }}
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v2
|
||||
uses: docker/setup-qemu-action@v3
|
||||
with:
|
||||
platforms: "arm64,amd64"
|
||||
|
||||
# Workaround: https://github.com/docker/build-push-action/issues/461
|
||||
- name: Setup Docker buildx
|
||||
uses: docker/setup-buildx-action@v2
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
# Login against a Docker registry except on PR
|
||||
# https://github.com/docker/login-action
|
||||
- name: Log into registry ${{ env.REGISTRY }}
|
||||
if: github.event_name != 'pull_request'
|
||||
uses: docker/login-action@v2
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
@@ -68,7 +68,7 @@ jobs:
|
||||
- name: Extract Docker metadata for API
|
||||
id: meta-api
|
||||
if: ${{ github.event.inputs.image_name == 'api' || github.event.inputs.image_name == 'both' || github.event_name != 'workflow_dispatch' }}
|
||||
uses: docker/metadata-action@v5
|
||||
uses: docker/metadata-action@v6
|
||||
with:
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
images: ${{ env.REGISTRY }}/${{ github.repository }}/api
|
||||
@@ -84,7 +84,7 @@ jobs:
|
||||
- name: Build and push Docker image for API
|
||||
id: build-and-push-api
|
||||
if: ${{ github.event.inputs.image_name == 'api' || github.event.inputs.image_name == 'both' || github.event_name != 'workflow_dispatch' }}
|
||||
uses: docker/build-push-action@v5
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: api
|
||||
build-args: VERSION=${{ github.event.inputs.git_ref || github.ref_name }}
|
||||
@@ -98,7 +98,7 @@ jobs:
|
||||
- name: Extract Docker metadata for Web
|
||||
id: meta-web
|
||||
if: ${{ github.event.inputs.image_name == 'web' || github.event.inputs.image_name == 'both' || github.event_name != 'workflow_dispatch' }}
|
||||
uses: docker/metadata-action@v5
|
||||
uses: docker/metadata-action@v6
|
||||
with:
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
images: ${{ env.REGISTRY }}/${{ github.repository }}/web
|
||||
@@ -114,7 +114,7 @@ jobs:
|
||||
- name: Build and push Docker image for Web
|
||||
id: build-and-push-web
|
||||
if: ${{ github.event.inputs.image_name == 'web' || github.event.inputs.image_name == 'both' || github.event_name != 'workflow_dispatch' }}
|
||||
uses: docker/build-push-action@v5
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: web
|
||||
build-args: VERSION=${{ github.event.inputs.git_ref || github.ref_name }}
|
||||
|
||||
+3
-1
@@ -2,4 +2,6 @@
|
||||
.DS_Store
|
||||
*-monitor
|
||||
|
||||
.cursor
|
||||
.cursor
|
||||
.claude/
|
||||
.omc/
|
||||
@@ -1,6 +1,6 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2024 Israel Abebe Kokiso
|
||||
Copyright (c) 2022 - 2026 textbee.dev
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
|
||||
@@ -1,55 +1,88 @@
|
||||

|
||||

|
||||

|
||||
[](https://discord.gg/d7vyfBpWbQ)
|
||||
|
||||
# textbee.dev - android sms gateway
|
||||
|
||||
textbee.dev is an open-source SMS gateway that enables users to send and receive SMS messages via a web dashboard or a REST API. Perfect for businesses, developers, and hobbyists who need a reliable and cost-effective way to automate SMS messaging.
|
||||
Send and receive SMS messages using your own Android phone - no Twilio, no per-message fees. Free, open-source, and self-hostable.
|
||||
|
||||
- **Technology stack**: React, Next.js, Node.js, NestJs, MongoDB, Android, Java
|
||||
- **Link**: [https://textbee.dev](https://textbee.dev/)
|
||||
Manage SMS messages through a web dashboard or a REST API. textbee is ideal for businesses, developers, and hobbyists looking for a reliable and cost-effective solution to automate SMS messaging.
|
||||
|
||||
**Website:** [https://textbee.dev](https://textbee.dev?ref=gh-readme)
|
||||
|
||||

|
||||
|
||||
|
||||
|
||||
## Why textbee?
|
||||
|
||||
| | textbee | Twilio & similar APIs |
|
||||
|---|---|---|
|
||||
| Cost per SMS | Your carrier plan (often free/unlimited) | ~$0.008+ per message |
|
||||
| Phone number | Your own SIM | Rented number |
|
||||
| Self-hostable | ✅ Full control over your data | ❌ |
|
||||
| Open source | ✅ | ❌ |
|
||||
| Setup time | ~2 minutes | Account approval, compliance forms |
|
||||
|
||||
## Features
|
||||
|
||||
|
||||
- Send & receive SMS messages via API & dashboard
|
||||
- Use your own Android phone as an SMS gateway
|
||||
- REST API for easy integration with apps & services
|
||||
- Send Bulk SMS with CSV file
|
||||
- Send bulk SMS with CSV file
|
||||
- Multi-device support for higher SMS throughput
|
||||
- Secure API authentication with API keys
|
||||
- Webhook support
|
||||
- Webhook support for incoming messages
|
||||
- Self-hosting support for full control over your data
|
||||
|
||||
|
||||
|
||||
|
||||
## Getting Started
|
||||
|
||||
|
||||
1. Go to [textbee.dev](https://textbee.dev) and register or login with your account
|
||||
2. Install the app on your android phone from [textbee.dev/download](https://textbee.dev/download)
|
||||
2. Install the app on your Android phone from [textbee.dev/download](https://textbee.dev/download)
|
||||
3. Open the app and grant the permissions for SMS
|
||||
4. Go to [textbee.dev/dashboard](https://textbee.dev/dashboard) and click register device/ generate API Key
|
||||
4. Go to [textbee.dev/dashboard](https://textbee.dev/dashboard) and click register device / generate API key
|
||||
5. Scan the QR code with the app or enter the API key manually
|
||||
6. You are ready to send SMS messages from the dashboard or from your application via the REST API
|
||||
6. You're ready to send SMS from the dashboard or from your application via the REST API
|
||||
|
||||
**Code Snippet**: Few lines of code showing how to send an SMS message via the REST API
|
||||
|
||||
|
||||
### Sending an SMS
|
||||
|
||||
```javascript
|
||||
const API_KEY = 'YOUR_API_KEY';
|
||||
const DEVICE_ID = 'YOUR_DEVICE_ID';
|
||||
|
||||
|
||||
await axios.post(`https://api.textbee.dev/api/v1/gateway/devices/${DEVICE_ID}/send-sms`, {
|
||||
recipients: [ '+251912345678' ],
|
||||
message: 'Hello World!',
|
||||
}, {
|
||||
headers: {
|
||||
'x-api-key': API_KEY,
|
||||
},
|
||||
headers: { 'x-api-key': API_KEY },
|
||||
});
|
||||
|
||||
```
|
||||
|
||||
**Code Snippet**: Curl command to send an SMS message via the REST API
|
||||
|
||||
|
||||
<details>
|
||||
<summary><b>Python</b></summary>
|
||||
```python
|
||||
import requests
|
||||
|
||||
API_KEY = 'YOUR_API_KEY'
|
||||
DEVICE_ID = 'YOUR_DEVICE_ID'
|
||||
|
||||
requests.post(
|
||||
f'https://api.textbee.dev/api/v1/gateway/devices/{DEVICE_ID}/send-sms',
|
||||
json={
|
||||
'recipients': ['+251912345678'],
|
||||
'message': 'Hello World!',
|
||||
},
|
||||
headers={'x-api-key': API_KEY},
|
||||
)
|
||||
```
|
||||
|
||||
</details>
|
||||
<details>
|
||||
<summary><b>curl</b></summary>
|
||||
```bash
|
||||
curl -X POST "https://api.textbee.dev/api/v1/gateway/devices/YOUR_DEVICE_ID/send-sms" \
|
||||
-H 'x-api-key: YOUR_API_KEY' \
|
||||
@@ -59,39 +92,70 @@ curl -X POST "https://api.textbee.dev/api/v1/gateway/devices/YOUR_DEVICE_ID/send
|
||||
"message": "Hello World!"
|
||||
}'
|
||||
```
|
||||
|
||||
### Receiving SMS Messages
|
||||
|
||||
To receive SMS messages, you can enable the feature from the mobile app. You can then fetch the received SMS messages via the REST API or view them in the dashboard. (Webhook notifications are coming soon)
|
||||
|
||||
**Code Snippet**: Few lines of code showing how to fetch received SMS messages via the REST API
|
||||
|
||||
|
||||
</details>
|
||||
### Receiving SMS
|
||||
|
||||
Enable SMS receiving in the mobile app, then access incoming messages via the REST API, the dashboard, or webhook notifications delivered to your preferred URL.
|
||||
|
||||
```javascript
|
||||
const API_KEY = 'YOUR_API_KEY';
|
||||
const DEVICE_ID = 'YOUR_DEVICE_ID';
|
||||
|
||||
|
||||
await axios.get(`https://api.textbee.dev/api/v1/gateway/devices/${DEVICE_ID}/get-received-sms`, {
|
||||
headers: {
|
||||
'x-api-key': API_KEY,
|
||||
},
|
||||
headers: { 'x-api-key': API_KEY },
|
||||
});
|
||||
|
||||
```
|
||||
|
||||
**Code Snippet**: Curl command to fetch received SMS messages
|
||||
|
||||
|
||||
<details>
|
||||
<summary><b>curl</b></summary>
|
||||
```bash
|
||||
curl -X GET "https://api.textbee.dev/api/v1/gateway/devices/YOUR_DEVICE_ID/get-received-sms"\
|
||||
curl -X GET "https://api.textbee.dev/api/v1/gateway/devices/YOUR_DEVICE_ID/get-received-sms" \
|
||||
-H "x-api-key: YOUR_API_KEY"
|
||||
```
|
||||
|
||||
</details>
|
||||
## Use Cases
|
||||
|
||||
- OTP / 2FA delivery for your app
|
||||
- Order and appointment notifications
|
||||
- Alerts from servers, cron jobs, and home automation
|
||||
- Form-to-SMS and lead follow-ups
|
||||
- Bulk announcements to a contact list (CSV upload)
|
||||
## FAQ
|
||||
|
||||
<details>
|
||||
<summary><b>Will my carrier block my number for sending too many messages?</b></summary>
|
||||
Carriers apply their own rate limits and anti-spam policies, which vary by country and plan. For personal and low-volume use this is rarely an issue. For higher throughput, use multiple devices/SIMs and keep sending rates reasonable. You are responsible for staying within your carrier's terms.
|
||||
|
||||
</details>
|
||||
<details>
|
||||
<summary><b>Is it legal to send marketing SMS this way?</b></summary>
|
||||
SMS marketing is regulated in most countries (e.g., TCPA in the US, GDPR/ePrivacy in the EU). textbee is a tool — you are responsible for obtaining consent and complying with the laws that apply to you and your recipients.
|
||||
|
||||
</details>
|
||||
<details>
|
||||
<summary><b>Does my phone need to stay on?</b></summary>
|
||||
Yes — messages are sent through your phone, so it needs to be powered on with the app running and connected to the internet. A spare Android phone plugged into a charger works great as a dedicated gateway.
|
||||
|
||||
</details>
|
||||
<details>
|
||||
<summary><b>Is there a limit on the cloud-hosted version?</b></summary>
|
||||
See [textbee.dev](https://textbee.dev) for current plans and limits. You can always self-host for full control.
|
||||
|
||||
</details>
|
||||
|
||||
|
||||
## Self-Hosting
|
||||
|
||||
**Technology stack**: React, Next.js, Node.js, NestJS, MongoDB, Android, Kotlin, Jetpack Compose, Java (legacy)
|
||||
|
||||
### Setting Up Database
|
||||
|
||||
1. **Install MongoDB on Your Server**: Follow the official MongoDB installation guide for your operating system.
|
||||
2. **Using MongoDB Atlas**: Alternatively, you can create a free database on MongoDB Atlas. Sign up at [MongoDB Atlas](https://www.mongodb.com/cloud/atlas) and follow the instructions to set up your database.
|
||||
|
||||
|
||||
### Firebase Setup
|
||||
|
||||
1. Create a Firebase project.
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
# Android Build Variants Setup (Dev vs Prod)
|
||||
|
||||
This document explains how to use the dev and prod build variants for the SMS Gateway Android app.
|
||||
|
||||
## Overview
|
||||
|
||||
The app now supports two build variants:
|
||||
- **Dev**: For development and testing
|
||||
- **Prod**: For production releases
|
||||
|
||||
## Key Differences
|
||||
|
||||
| Feature | Dev | Prod |
|
||||
|---------|-----|------|
|
||||
| Package Name | `com.vernu.sms.dev` | `com.vernu.sms` |
|
||||
| App Name | "SMS Gateway (Dev)" | "SMS Gateway" |
|
||||
| API Base URL | `https://api-dev.textbee.dev/api/v1/` | `https://api.textbee.dev/api/v1/` |
|
||||
| Firebase Config | `app/src/dev/google-services.json` | `app/src/prod/google-services.json` |
|
||||
| Version Suffix | `-dev` appended | No suffix |
|
||||
|
||||
## Setup Instructions
|
||||
|
||||
### 1. Firebase Configuration
|
||||
|
||||
#### For Production:
|
||||
- The current `google-services.json` has been moved to `app/src/prod/google-services.json`
|
||||
- No changes needed if you're already using the production Firebase project
|
||||
|
||||
#### For Development:
|
||||
- Create a new Firebase project for development
|
||||
- Download the `google-services.json` for your dev project
|
||||
- **Important**: Make sure the package name in Firebase is set to `com.vernu.sms.dev`
|
||||
- Replace the template file at `app/src/dev/google-services.json` with your actual dev configuration
|
||||
|
||||
### 2. API Configuration
|
||||
|
||||
The API base URLs are now configured via build variants:
|
||||
- **Dev**: `https://api-dev.textbee.dev/api/v1/`
|
||||
- **Prod**: `https://api.textbee.dev/api/v1/`
|
||||
|
||||
To change the dev API URL, edit the `buildConfigField` in `app/build.gradle`:
|
||||
```gradle
|
||||
dev {
|
||||
buildConfigField "String", "API_BASE_URL", '"https://api-dev.textbee.dev/api/v1/"'
|
||||
}
|
||||
```
|
||||
|
||||
## Building the App
|
||||
|
||||
### Using Android Studio:
|
||||
1. Open the "Build Variants" panel (View → Tool Windows → Build Variants)
|
||||
2. Select the desired variant:
|
||||
- `devDebug` - Development build for debugging
|
||||
- `devRelease` - Development release build
|
||||
- `prodDebug` - Production build for debugging
|
||||
- `prodRelease` - Production release build
|
||||
|
||||
### Using Command Line:
|
||||
```bash
|
||||
# Build dev debug
|
||||
./gradlew assembleDevDebug
|
||||
|
||||
# Build dev release
|
||||
./gradlew assembleDevRelease
|
||||
|
||||
# Build prod debug
|
||||
./gradlew assembleProdDebug
|
||||
|
||||
# Build prod release
|
||||
./gradlew assembleProdRelease
|
||||
```
|
||||
|
||||
## Installation
|
||||
|
||||
Both variants can be installed simultaneously on the same device since they have different package names:
|
||||
- Dev app will show as "SMS Gateway (Dev)"
|
||||
- Prod app will show as "SMS Gateway"
|
||||
|
||||
## Environment Detection
|
||||
|
||||
You can detect which environment the app is running in using:
|
||||
```java
|
||||
if (BuildConfig.ENVIRONMENT.equals("development")) {
|
||||
// Development-specific code
|
||||
} else {
|
||||
// Production code
|
||||
}
|
||||
```
|
||||
|
||||
## Important Notes
|
||||
|
||||
1. **Different Package Names**: Dev and prod apps are completely separate and can coexist
|
||||
2. **Separate Data**: Each variant maintains its own app data and preferences
|
||||
3. **Firebase Projects**: Use separate Firebase projects for dev and prod
|
||||
4. **API Endpoints**: Ensure your backend has corresponding dev/prod endpoints
|
||||
5. **Testing**: Always test the prod build before releasing
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Build Errors:
|
||||
- Ensure both `google-services.json` files are properly configured
|
||||
- Check that package names match between Firebase console and app configuration
|
||||
- Verify API URLs are accessible
|
||||
|
||||
### Firebase Issues:
|
||||
- Confirm the package name in Firebase matches the variant (`com.vernu.sms.dev` for dev)
|
||||
- Ensure SHA-1 fingerprints are added to Firebase if using authentication
|
||||
@@ -0,0 +1,191 @@
|
||||
# Android Kotlin/Compose Migration
|
||||
|
||||
## Overview
|
||||
|
||||
TextBee Android is mid-migration from a Java/XML legacy codebase to Kotlin + Jetpack Compose. The new UI runs in parallel with the legacy UI — users can switch between them via Settings. The SplashActivity routes to the appropriate UI on launch.
|
||||
|
||||
---
|
||||
|
||||
## What's Done
|
||||
|
||||
### Theme
|
||||
| File | Notes |
|
||||
|---|---|
|
||||
| `ui/theme/Color.kt` | Brand orange (`#C4620A`), full light/dark palette |
|
||||
| `ui/theme/Theme.kt` | `TextBeeTheme` wrapper, `dynamicColor = false` to preserve brand color |
|
||||
| `ui/theme/Type.kt` | Material3 typography scale |
|
||||
|
||||
### Infrastructure
|
||||
| File | Notes |
|
||||
|---|---|
|
||||
| `ApiManagerKt.kt` | Kotlin singleton Retrofit client, mirrors `ApiManager.java` |
|
||||
| `services/GatewayApiServiceKt.kt` | Kotlin suspend-function Retrofit interface (all new UI endpoints) |
|
||||
| `dtos/GatewayStatsResponse.kt` | Nullable stats DTO |
|
||||
| `dtos/SubscriptionResponse.kt` | Plan, usage, renewal DTO |
|
||||
| `dtos/UserProfileResponse.kt` | `name`, `email` from `/auth/who-am-i` |
|
||||
| `dtos/MessagesResponse.kt` | `SmsMessage`, `PaginationMeta`, `SendSmsRequest` |
|
||||
|
||||
### UI — Onboarding
|
||||
| File | Notes |
|
||||
|---|---|
|
||||
| `ui/onboarding/OnboardingActivity.kt` | Compose NavHost shell |
|
||||
| `ui/onboarding/OnboardingViewModel.kt` | Registration state + API calls |
|
||||
| `ui/onboarding/screens/WelcomeScreen.kt` | Get Started / I have a Device ID |
|
||||
| `ui/onboarding/screens/CredentialsScreen.kt` | QR scan + manual API key entry |
|
||||
| `ui/onboarding/screens/DeviceSetupScreen.kt` | Register or reconnect device |
|
||||
| `ui/onboarding/screens/PermissionsScreen.kt` | SMS + phone state permissions |
|
||||
| `ui/onboarding/screens/SetupCompleteScreen.kt` | Success + navigate to dashboard |
|
||||
| `ui/onboarding/screens/OnboardingComponents.kt` | Shared step indicator, etc. |
|
||||
|
||||
### UI — Main App
|
||||
| File | Notes |
|
||||
|---|---|
|
||||
| `ui/splash/SplashActivity.kt` | Routes to legacy/onboarding/main based on SharedPrefs |
|
||||
| `ui/main/NewMainActivity.kt` | Bottom nav (Dashboard / Messages / Settings) + compose + filters routes |
|
||||
| `ui/dashboard/DashboardScreen.kt` | Device card, stats, subscription, quick actions |
|
||||
| `ui/dashboard/DashboardViewModel.kt` | Stats, subscription, user profile, gateway toggle |
|
||||
| `ui/messages/MessagesScreen.kt` | Filter chips, message list, detail dialog, FAB |
|
||||
| `ui/messages/MessagesViewModel.kt` | Paginated message fetch, filter state |
|
||||
| `ui/messages/ComposeScreen.kt` | Multi-recipient input, message field, send with snackbar feedback |
|
||||
| `ui/messages/ComposeViewModel.kt` | Send SMS, error body parsing, success/error state |
|
||||
| `ui/settings/SettingsScreen.kt` | Account, Gateway, SMS, Legal, System, UI sections |
|
||||
| `ui/settings/SettingsViewModel.kt` | Device name save, gateway toggle, SIM picker |
|
||||
|
||||
### UI — Settings (Phase 3) ✅
|
||||
| File | Notes |
|
||||
|---|---|
|
||||
| `ui/settings/SMSFilterScreen.kt` | Full Compose SMS filter screen: enable switch, allow/block mode chips, rule list with FAB, add/edit dialog |
|
||||
| `ui/settings/SMSFilterViewModel.kt` | `AndroidViewModel` — loads/saves `FilterConfig` via StateFlow; deep-copies config on each mutation |
|
||||
|
||||
`SMSFilterActivity.java` is still present for the legacy UI. The new UI navigates to the `"filters"` composable route inside `NewMainActivity`; bottom bar is hidden on both `"compose"` and `"filters"` routes.
|
||||
|
||||
### Data Layer — Kotlin Stubs (Phase 4) ✅
|
||||
|
||||
**Room DB (block-commented — feature not yet enabled):**
|
||||
| File | Notes |
|
||||
|---|---|
|
||||
| `database/local/Sms.kt` | Replaces `SMS.java`; `@Entity` data class inside `/* */` block comment |
|
||||
| `database/local/SmsDao.kt` | Replaces `SMSDao.java`; `@Dao` interface with suspend funs, block-commented |
|
||||
| `database/local/AppDatabase.kt` | Replaces `AppDatabase.java`; singleton with companion object, block-commented |
|
||||
| `database/local/DateConverter.kt` | Replaces `DateConverter.java`; `object` with `@TypeConverter`, block-commented |
|
||||
|
||||
**DTOs (all Java originals deleted):**
|
||||
| File | Notes |
|
||||
|---|---|
|
||||
| `dtos/RegisterDeviceInputDTO.kt` | `class` with `var`; `@get:JvmName("isEnabled")` so Java/Kotlin callers get `isEnabled()` not `getEnabled()` |
|
||||
| `dtos/RegisterDeviceResponseDTO.kt` | Regular `class` with `@JvmField var` — `MainActivity.java` accesses `.data`/`.error` as direct fields |
|
||||
| `dtos/SMSDTO.kt` | Regular `class`; `message: String = ""` to avoid null default |
|
||||
| `dtos/HeartbeatInputDTO.kt` | `var isCharging: Boolean?` (nullable) — generates `getIsCharging()`/`setIsCharging()` matching Java non-standard getter name |
|
||||
| `dtos/HeartbeatResponseDTO.kt` | `@JvmField var` on all properties — `HeartbeatHelper` accesses fields directly (`.fcmTokenUpdated`) |
|
||||
| `dtos/SimInfoDTO.kt` | `subscriptionId: Int = 0` (was primitive in Java) |
|
||||
| `dtos/SimInfoCollectionDTO.kt` | `sims: MutableList<SimInfoDTO>? = null` |
|
||||
| `dtos/SMSForwardResponseDTO.kt` | Empty class body |
|
||||
|
||||
### Helpers & Models (Phase 5) ✅
|
||||
|
||||
All helpers are Kotlin `object` with `@JvmStatic` on every public method — at the time of porting, Java workers/receivers called them; those callers were ported in Phase 6.
|
||||
|
||||
| File | Notes |
|
||||
|---|---|
|
||||
| `helpers/SharedPreferenceHelper.kt` | Replaces `SharedPreferenceHelper.java`; `PREF_FILE = "PREF"`, 7 methods |
|
||||
| `helpers/SMSFilterHelper.kt` | Replaces `SMSFilterHelper.java`; nested `FilterMode` enum + `FilterConfig` class; Gson-compatible field names |
|
||||
| `helpers/SMSHelper.kt` | Replaces `SMSHelper.java`; `FLAG_MUTABLE` on API >= S; private PendingIntent helpers |
|
||||
| `helpers/HeartbeatHelper.kt` | Replaces `HeartbeatHelper.java`; `CountDownLatch` FCM token wait, `@Suppress("DEPRECATION")` for legacy network API |
|
||||
| `helpers/HeartbeatManager.kt` | Replaces `HeartbeatManager.java`; `PeriodicWorkRequest.Builder(HeartbeatWorker::class.java, ...)` |
|
||||
| `models/SMSFilterRule.kt` | Replaces `SMSFilterRule.java`; `@JvmOverloads constructor` for Java callers; nested `MatchType` + `FilterTarget` enums |
|
||||
| `models/SMSPayload.kt` | Replaces `SMSPayload.java`; keeps legacy `receivers` + `smsBody` fields |
|
||||
|
||||
---
|
||||
|
||||
## What's Left (Java / Legacy)
|
||||
|
||||
### Core App
|
||||
| File | Priority | Notes |
|
||||
|---|---|---|
|
||||
| `AppConstants.java` | Low | Constants only — convert when touching other things |
|
||||
| `SMSGatewayApplication.java` | Low | Application class, minimal logic |
|
||||
| `TextBeeUtils.java` | Medium | Heavily used utility; convert once helpers are stable |
|
||||
| `ApiManager.java` | Low | Still used by legacy UI; delete after legacy removal |
|
||||
|
||||
### Activities (Legacy UI)
|
||||
| File | Priority | Notes |
|
||||
|---|---|---|
|
||||
| `activities/MainActivity.java` | High | Legacy main UI — remove after full Compose rollout |
|
||||
| `activities/SMSFilterActivity.java` | Medium | Legacy filter screen — still reachable from legacy UI only |
|
||||
|
||||
### Helpers
|
||||
| File | Priority | Notes |
|
||||
|---|---|---|
|
||||
| `helpers/VersionTracker.java` | Low | Update check logic — left for later |
|
||||
|
||||
### Services
|
||||
| File | Priority | Notes |
|
||||
|---|---|---|
|
||||
| `services/GatewayApiService.java` | High | Java Retrofit interface — delete after legacy UI removed |
|
||||
|
||||
---
|
||||
|
||||
## Migration Roadmap
|
||||
|
||||
### Phase 3 — SMS Filter Screen ✅ Complete
|
||||
Ported `SMSFilterActivity.java` to `SMSFilterScreen.kt` (Compose). Integrated as a nested `"filters"` route inside `NewMainActivity`. Legacy `SMSFilterActivity.java` unchanged — still reachable from legacy UI.
|
||||
|
||||
---
|
||||
|
||||
### Phase 4 — Data Layer ✅ Complete
|
||||
All DTOs ported to Kotlin; Java originals deleted. Room DB ported to Kotlin stubs with all logic still inside `/* */` block comments (feature remains disabled).
|
||||
|
||||
---
|
||||
|
||||
### Phase 5 — Helpers & Utilities ✅ Complete
|
||||
All helpers and models ported to Kotlin `object`s with `@JvmStatic`. Java originals deleted. Java callers (workers, receivers — Phase 6) continue to work unchanged via `@JvmStatic` interop.
|
||||
|
||||
---
|
||||
|
||||
### Phase 6 — Background Services & Receivers ✅ Complete
|
||||
All workers, receivers, and services ported to Kotlin; Java originals deleted.
|
||||
|
||||
| File | Notes |
|
||||
|---|---|
|
||||
| `receivers/BootCompletedReceiver.kt` | Restarts sticky notification + schedules heartbeat on boot |
|
||||
| `receivers/SMSBroadcastReceiver.kt` | Deduplication fingerprint cache; Kotlin property access on `SMSDTO` |
|
||||
| `receivers/SMSStatusReceiver.kt` | `setFailed()` private helper avoids `errorMessage` property shadowing |
|
||||
| `workers/HeartbeatWorker.kt` | Simple `Worker` subclass; delegates to `HeartbeatHelper` |
|
||||
| `workers/SmsSendWorker.kt` | SIM resolution priority chain; `Thread.sleep` rate limiting |
|
||||
| `workers/SMSReceivedWorker.kt` | Fingerprint-based unique work name for deduplication |
|
||||
| `workers/SMSStatusUpdateWorker.kt` | Exponential backoff, max 5 retries |
|
||||
| `services/StickyNotificationService.kt` | Broad `Exception` catch replaces API-31-only `ForegroundServiceStartNotAllowedException` |
|
||||
| `services/FCMService.kt` | Handles `heartbeat_check` type + SMS payload dispatch |
|
||||
|
||||
**Sticky notification fix**: Added service restart to `DashboardViewModel.loadLocalState()` — on every app launch, if gateway + sticky notification are enabled, the service is restarted. This matches legacy `MainActivity` behaviour and fixes the notification disappearing after Android kills the service on newer OS versions.
|
||||
|
||||
---
|
||||
|
||||
### Phase 7 — Legacy UI Removal
|
||||
Once Compose UI is stable and rolled out to all users, remove the legacy UI entirely.
|
||||
|
||||
**Steps:**
|
||||
1. Remove the "Switch to Legacy UI" row from `SettingsScreen.kt`
|
||||
2. Remove `USE_NEW_UI_KEY` logic from `SplashActivity.kt` (always route to new UI)
|
||||
3. Delete `activities/MainActivity.java` and its XML layouts
|
||||
4. Delete `activities/SMSFilterActivity.java`
|
||||
5. Delete `services/GatewayApiService.java` (Java Retrofit interface)
|
||||
6. Delete `ApiManager.java`
|
||||
7. Remove "Try New UI" button from any remaining legacy layout XML
|
||||
8. Clean up `AppConstants.java` — remove `SHARED_PREFS_USE_NEW_UI_KEY`
|
||||
9. Convert `SMSGatewayApplication.java` → Kotlin
|
||||
|
||||
---
|
||||
|
||||
## Key Constraints to Keep in Mind
|
||||
|
||||
- **`dynamicColor = false`** in `Theme.kt` — Material You overrides the brand orange on Android 12+; must stay false
|
||||
- **`primaryContainer` avoided** in TopAppBar/nav — causes orange-on-orange in dark mode; use `surface` for bars, `surfaceVariant` for nav indicator
|
||||
- **Java/Kotlin interop** — Only `ApiManager.java`, `TextBeeUtils.java`, legacy activities, and `GatewayApiService.java` remain Java; all others are Kotlin
|
||||
- **WorkManager workers** — kept as `Worker` subclass (not `CoroutineWorker`) to avoid adding `work-runtime-ktx`; straightforward conversion candidate in a future cleanup
|
||||
- **Sticky notification on Android 12+** — `ForegroundServiceStartNotAllowedException` is caught broadly; `DashboardViewModel` restarts service on every launch to compensate for OS killing it in the background
|
||||
- **Room DB** — all DB logic remains commented out; do not uncomment until the feature is explicitly re-enabled
|
||||
- **`@JvmField`** on `HeartbeatResponseDTO` — `HeartbeatHelper.kt` accesses `.fcmTokenUpdated`/`.name` as fields; `@JvmField` keeps direct field access instead of generating getters
|
||||
- **`@JvmField`** on `RegisterDeviceResponseDTO` — `MainActivity.java` (legacy, still Java) accesses `.data`/`.error` as direct fields; remove once Phase 7 deletes the legacy activity
|
||||
- **`@JvmOverloads`** on `SMSFilterRule` — generates no-arg and partial constructors needed by Gson deserialization of persisted filter config JSON
|
||||
- **`@get:JvmName("isEnabled")`** on `RegisterDeviceInputDTO.enabled` and `@get:JvmName("isCaseSensitive")`on `SMSFilterRule.caseSensitive` — renames generated getter to match Java boolean convention; `MainActivity.java` and `SMSFilterActivity.java` use `isEnabled()`/`isCaseSensitive()`
|
||||
@@ -1,16 +1,18 @@
|
||||
plugins {
|
||||
id 'com.android.application'
|
||||
id 'com.google.gms.google-services'
|
||||
id 'com.google.firebase.crashlytics'
|
||||
id 'org.jetbrains.kotlin.android'
|
||||
}
|
||||
|
||||
android {
|
||||
compileSdk 32
|
||||
compileSdk 34
|
||||
|
||||
defaultConfig {
|
||||
minSdk 24
|
||||
targetSdk 32
|
||||
versionCode 15
|
||||
versionName "2.6.2"
|
||||
targetSdk 34
|
||||
versionCode 18
|
||||
versionName "2.8.0"
|
||||
|
||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||
|
||||
@@ -68,6 +70,18 @@ android {
|
||||
sourceCompatibility JavaVersion.VERSION_1_8
|
||||
targetCompatibility JavaVersion.VERSION_1_8
|
||||
}
|
||||
|
||||
kotlinOptions {
|
||||
jvmTarget = '1.8'
|
||||
}
|
||||
|
||||
buildFeatures {
|
||||
compose true
|
||||
}
|
||||
|
||||
composeOptions {
|
||||
kotlinCompilerExtensionVersion '1.4.8'
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
@@ -88,13 +102,29 @@ dependencies {
|
||||
implementation 'com.google.code.gson:gson:2.9.0'
|
||||
implementation 'com.squareup.retrofit2:retrofit:2.9.0'
|
||||
implementation 'com.squareup.retrofit2:converter-gson:2.9.0'
|
||||
implementation 'com.journeyapps:zxing-android-embedded:4.1.0'
|
||||
implementation 'com.journeyapps:zxing-android-embedded:4.3.0'
|
||||
|
||||
implementation 'androidx.work:work-runtime:2.7.1'
|
||||
|
||||
// Jetpack Compose
|
||||
implementation platform('androidx.compose:compose-bom:2023.08.00')
|
||||
implementation 'androidx.compose.ui:ui'
|
||||
implementation 'androidx.compose.material3:material3'
|
||||
implementation 'androidx.compose.ui:ui-tooling-preview'
|
||||
implementation 'androidx.compose.material:material-icons-extended'
|
||||
debugImplementation 'androidx.compose.ui:ui-tooling'
|
||||
|
||||
// Compose integration
|
||||
implementation 'androidx.activity:activity-compose:1.7.2'
|
||||
implementation 'androidx.lifecycle:lifecycle-viewmodel-compose:2.6.1'
|
||||
implementation 'androidx.navigation:navigation-compose:2.7.2'
|
||||
|
||||
// Coroutines + Lifecycle
|
||||
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3'
|
||||
implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.6.1'
|
||||
|
||||
// def room_version = "2.4.2"
|
||||
// implementation "androidx.room:room-runtime:$room_version"
|
||||
// annotationProcessor "androidx.room:room-compiler:$room_version"
|
||||
}
|
||||
|
||||
apply plugin: 'com.google.gms.google-services'
|
||||
@@ -9,12 +9,12 @@
|
||||
|
||||
<uses-permission android:name="android.permission.READ_PHONE_STATE" />
|
||||
<uses-permission android:name="android.permission.SEND_SMS" />
|
||||
<uses-permission android:name="android.permission.READ_SMS" />
|
||||
<uses-permission android:name="android.permission.RECEIVE_SMS" />
|
||||
<uses-permission android:name="android.provider.Telephony.SMS_RECEIVED" />
|
||||
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED"/>
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE"/>
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_REMOTE_MESSAGING"/>
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
<application
|
||||
android:name=".SMSGatewayApplication"
|
||||
android:allowBackup="true"
|
||||
@@ -22,8 +22,7 @@
|
||||
android:label="@string/app_name"
|
||||
android:roundIcon="@mipmap/ic_launcher_round"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/Theme.SMSGateway"
|
||||
android:usesCleartextTraffic="true" >
|
||||
android:theme="@style/Theme.SMSGateway" >
|
||||
|
||||
<provider
|
||||
android:name="androidx.startup.InitializationProvider"
|
||||
@@ -41,13 +40,14 @@
|
||||
<service
|
||||
android:name=".services.StickyNotificationService"
|
||||
android:enabled="true"
|
||||
android:exported="false">
|
||||
android:exported="false"
|
||||
android:foregroundServiceType="remoteMessaging">
|
||||
</service>
|
||||
<receiver
|
||||
android:name=".receivers.SMSBroadcastReceiver"
|
||||
android:exported="true">
|
||||
<intent-filter
|
||||
android:priority="2147483647">
|
||||
android:priority="1000">
|
||||
<action android:name="android.provider.Telephony.SMS_RECEIVED"/>
|
||||
</intent-filter>
|
||||
</receiver>
|
||||
@@ -71,15 +71,31 @@
|
||||
</receiver>
|
||||
|
||||
<activity
|
||||
android:name=".activities.MainActivity"
|
||||
android:name=".ui.splash.SplashActivity"
|
||||
android:exported="true"
|
||||
android:theme="@style/Theme.SMSGateway.NoActionBar">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
<activity
|
||||
android:name=".ui.onboarding.OnboardingActivity"
|
||||
android:exported="false"
|
||||
android:theme="@style/Theme.SMSGateway.NoActionBar" />
|
||||
<activity
|
||||
android:name=".ui.main.NewMainActivity"
|
||||
android:exported="false"
|
||||
android:theme="@style/Theme.SMSGateway.NoActionBar" />
|
||||
<activity
|
||||
android:name=".activities.MainActivity"
|
||||
android:exported="true"
|
||||
android:theme="@style/Theme.SMSGateway.NoActionBar" />
|
||||
<activity
|
||||
android:name=".activities.SMSFilterActivity"
|
||||
android:exported="false"
|
||||
android:theme="@style/Theme.SMSGateway.NoActionBar"
|
||||
android:parentActivityName=".activities.MainActivity" />
|
||||
<activity
|
||||
android:name="com.journeyapps.barcodescanner.CaptureActivity"
|
||||
android:screenOrientation="portrait"
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
package com.vernu.sms
|
||||
|
||||
import com.vernu.sms.services.GatewayApiServiceKt
|
||||
import retrofit2.Retrofit
|
||||
import retrofit2.converter.gson.GsonConverterFactory
|
||||
|
||||
object ApiManagerKt {
|
||||
@Volatile
|
||||
private var instance: GatewayApiServiceKt? = null
|
||||
|
||||
fun getApiService(): GatewayApiServiceKt =
|
||||
instance ?: synchronized(this) {
|
||||
instance ?: Retrofit.Builder()
|
||||
.baseUrl(AppConstants.API_BASE_URL)
|
||||
.addConverterFactory(GsonConverterFactory.create())
|
||||
.build()
|
||||
.create(GatewayApiServiceKt::class.java)
|
||||
.also { instance = it }
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,6 @@ public class AppConstants {
|
||||
public static final String API_BASE_URL = BuildConfig.API_BASE_URL;
|
||||
public static final String[] requiredPermissions = new String[]{
|
||||
Manifest.permission.SEND_SMS,
|
||||
Manifest.permission.READ_SMS,
|
||||
Manifest.permission.RECEIVE_SMS,
|
||||
Manifest.permission.READ_PHONE_STATE
|
||||
};
|
||||
@@ -19,4 +18,14 @@ public class AppConstants {
|
||||
public static final String SHARED_PREFS_LAST_VERSION_CODE_KEY = "LAST_VERSION_CODE";
|
||||
public static final String SHARED_PREFS_LAST_VERSION_NAME_KEY = "LAST_VERSION_NAME";
|
||||
public static final String SHARED_PREFS_STICKY_NOTIFICATION_ENABLED_KEY = "STICKY_NOTIFICATION_ENABLED";
|
||||
public static final String HEARTBEAT_WORK_TAG = "heartbeat";
|
||||
public static final String SHARED_PREFS_HEARTBEAT_ENABLED_KEY = "HEARTBEAT_ENABLED";
|
||||
public static final String SHARED_PREFS_HEARTBEAT_INTERVAL_MINUTES_KEY = "HEARTBEAT_INTERVAL_MINUTES";
|
||||
public static final String SHARED_PREFS_SMS_FILTER_CONFIG_KEY = "SMS_FILTER_CONFIG";
|
||||
public static final String SHARED_PREFS_DEVICE_NAME_KEY = "DEVICE_NAME";
|
||||
public static final String SHARED_PREFS_SMS_SEND_DELAY_SECONDS_KEY = "SMS_SEND_DELAY_SECONDS";
|
||||
/** Default delay between SMS sends (seconds). 5s helps avoid carrier/device throttling. */
|
||||
public static final int DEFAULT_SMS_SEND_DELAY_SECONDS = 5;
|
||||
public static final String SHARED_PREFS_USE_NEW_UI_KEY = "USE_NEW_UI";
|
||||
public static final String SHARED_PREFS_LAST_HEARTBEAT_MS_KEY = "LAST_HEARTBEAT_MS";
|
||||
}
|
||||
|
||||
@@ -1,14 +1,28 @@
|
||||
package com.vernu.sms;
|
||||
|
||||
import android.app.Application;
|
||||
import android.util.Log;
|
||||
|
||||
import androidx.work.Configuration;
|
||||
import androidx.work.WorkManager;
|
||||
|
||||
public class SMSGatewayApplication extends Application implements Configuration.Provider {
|
||||
private static final String TAG = "SMSGatewayApplication";
|
||||
|
||||
@Override
|
||||
public void onCreate() {
|
||||
super.onCreate();
|
||||
|
||||
// Initialize WorkManager early to ensure it's ready for background work
|
||||
// This is important for background tasks like heartbeat
|
||||
try {
|
||||
WorkManager.initialize(this, getWorkManagerConfiguration());
|
||||
Log.d(TAG, "WorkManager initialized successfully");
|
||||
} catch (IllegalStateException e) {
|
||||
// WorkManager might already be initialized (e.g., by androidx.startup)
|
||||
// This is fine, we can continue
|
||||
Log.d(TAG, "WorkManager already initialized or will be initialized automatically");
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
|
||||
@@ -15,6 +15,7 @@ import androidx.core.content.ContextCompat;
|
||||
import com.google.firebase.crashlytics.FirebaseCrashlytics;
|
||||
import com.vernu.sms.services.StickyNotificationService;
|
||||
import com.vernu.sms.helpers.SharedPreferenceHelper;
|
||||
import com.vernu.sms.dtos.SimInfoDTO;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
@@ -117,4 +118,188 @@ public class TextBeeUtils {
|
||||
public static void logException(Throwable throwable, String message) {
|
||||
logException(throwable, message, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Collects all available SIM information (physical SIMs and eSIMs) from the device
|
||||
*
|
||||
* @param context The application context
|
||||
* @return List of SimInfoDTO objects containing SIM information, or empty list if permission not granted
|
||||
*/
|
||||
public static List<SimInfoDTO> collectSimInfo(Context context) {
|
||||
List<SimInfoDTO> simInfoList = new ArrayList<>();
|
||||
|
||||
if (ActivityCompat.checkSelfPermission(context, Manifest.permission.READ_PHONE_STATE) != PackageManager.PERMISSION_GRANTED) {
|
||||
Log.w(TAG, "READ_PHONE_STATE permission not granted, cannot collect SIM info");
|
||||
return simInfoList;
|
||||
}
|
||||
|
||||
try {
|
||||
SubscriptionManager subscriptionManager = SubscriptionManager.from(context);
|
||||
List<SubscriptionInfo> subscriptionInfoList = subscriptionManager.getActiveSubscriptionInfoList();
|
||||
|
||||
if (subscriptionInfoList == null) {
|
||||
Log.d(TAG, "No active subscriptions found");
|
||||
return simInfoList;
|
||||
}
|
||||
|
||||
for (SubscriptionInfo subscriptionInfo : subscriptionInfoList) {
|
||||
SimInfoDTO simInfo = new SimInfoDTO();
|
||||
simInfo.setSubscriptionId(subscriptionInfo.getSubscriptionId());
|
||||
|
||||
// Get ICCID (may be null for eSIM)
|
||||
try {
|
||||
String iccId = subscriptionInfo.getIccId();
|
||||
if (iccId != null && !iccId.isEmpty()) {
|
||||
simInfo.setIccId(iccId);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
Log.d(TAG, "Could not get ICCID for subscription " + subscriptionInfo.getSubscriptionId());
|
||||
}
|
||||
|
||||
// Get Card ID
|
||||
try {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
int cardId = subscriptionInfo.getCardId();
|
||||
// INVALID_CARD_ID is -1, check for valid card ID (>= 0)
|
||||
if (cardId >= 0) {
|
||||
simInfo.setCardId(cardId);
|
||||
}
|
||||
}
|
||||
} catch (Exception e) {
|
||||
Log.d(TAG, "Could not get Card ID for subscription " + subscriptionInfo.getSubscriptionId());
|
||||
}
|
||||
|
||||
// Get carrier name
|
||||
try {
|
||||
CharSequence carrierName = subscriptionInfo.getCarrierName();
|
||||
if (carrierName != null) {
|
||||
simInfo.setCarrierName(carrierName.toString());
|
||||
}
|
||||
} catch (Exception e) {
|
||||
Log.d(TAG, "Could not get carrier name for subscription " + subscriptionInfo.getSubscriptionId());
|
||||
}
|
||||
|
||||
// Get display name
|
||||
try {
|
||||
CharSequence displayName = subscriptionInfo.getDisplayName();
|
||||
if (displayName != null) {
|
||||
simInfo.setDisplayName(displayName.toString());
|
||||
}
|
||||
} catch (Exception e) {
|
||||
Log.d(TAG, "Could not get display name for subscription " + subscriptionInfo.getSubscriptionId());
|
||||
}
|
||||
|
||||
// Get SIM slot index
|
||||
try {
|
||||
int simSlotIndex = subscriptionInfo.getSimSlotIndex();
|
||||
if (simSlotIndex >= 0) {
|
||||
simInfo.setSimSlotIndex(simSlotIndex);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
Log.d(TAG, "Could not get SIM slot index for subscription " + subscriptionInfo.getSubscriptionId());
|
||||
}
|
||||
|
||||
// Get MCC (getMccString() is API 29+; use getMcc() on older devices)
|
||||
try {
|
||||
String mcc = null;
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
mcc = subscriptionInfo.getMccString();
|
||||
} else {
|
||||
int mccInt = subscriptionInfo.getMcc();
|
||||
if (mccInt != Integer.MAX_VALUE) {
|
||||
mcc = String.format("%03d", mccInt);
|
||||
}
|
||||
}
|
||||
if (mcc != null && !mcc.isEmpty()) {
|
||||
simInfo.setMcc(mcc);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
Log.d(TAG, "Could not get MCC for subscription " + subscriptionInfo.getSubscriptionId());
|
||||
}
|
||||
|
||||
// Get MNC (getMncString() is API 29+; use getMnc() on older devices)
|
||||
try {
|
||||
String mnc = null;
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
mnc = subscriptionInfo.getMncString();
|
||||
} else {
|
||||
int mncInt = subscriptionInfo.getMnc();
|
||||
if (mncInt != Integer.MAX_VALUE) {
|
||||
mnc = String.valueOf(mncInt);
|
||||
}
|
||||
}
|
||||
if (mnc != null && !mnc.isEmpty()) {
|
||||
simInfo.setMnc(mnc);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
Log.d(TAG, "Could not get MNC for subscription " + subscriptionInfo.getSubscriptionId());
|
||||
}
|
||||
|
||||
// Get country ISO
|
||||
try {
|
||||
String countryIso = subscriptionInfo.getCountryIso();
|
||||
if (countryIso != null && !countryIso.isEmpty()) {
|
||||
simInfo.setCountryIso(countryIso);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
Log.d(TAG, "Could not get country ISO for subscription " + subscriptionInfo.getSubscriptionId());
|
||||
}
|
||||
|
||||
// Get subscription type (0 = physical SIM, 1 = eSIM)
|
||||
try {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
int subscriptionType = subscriptionInfo.getSubscriptionType();
|
||||
if (subscriptionType == SubscriptionManager.SUBSCRIPTION_TYPE_LOCAL_SIM) {
|
||||
simInfo.setSubscriptionType("PHYSICAL_SIM");
|
||||
} else if (subscriptionType == SubscriptionManager.SUBSCRIPTION_TYPE_REMOTE_SIM) {
|
||||
simInfo.setSubscriptionType("ESIM");
|
||||
}
|
||||
} else {
|
||||
// For older Android versions, default to PHYSICAL_SIM
|
||||
simInfo.setSubscriptionType("PHYSICAL_SIM");
|
||||
}
|
||||
} catch (Exception e) {
|
||||
Log.d(TAG, "Could not get subscription type for subscription " + subscriptionInfo.getSubscriptionId());
|
||||
}
|
||||
|
||||
simInfoList.add(simInfo);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
Log.e(TAG, "Error collecting SIM info: " + e.getMessage(), e);
|
||||
}
|
||||
|
||||
return simInfoList;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates if a subscription ID exists in the active subscriptions
|
||||
*
|
||||
* @param context The application context
|
||||
* @param subscriptionId The subscription ID to validate
|
||||
* @return true if the subscription ID exists, false otherwise
|
||||
*/
|
||||
public static boolean isValidSubscriptionId(Context context, int subscriptionId) {
|
||||
if (ActivityCompat.checkSelfPermission(context, Manifest.permission.READ_PHONE_STATE) != PackageManager.PERMISSION_GRANTED) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
SubscriptionManager subscriptionManager = SubscriptionManager.from(context);
|
||||
List<SubscriptionInfo> subscriptionInfoList = subscriptionManager.getActiveSubscriptionInfoList();
|
||||
|
||||
if (subscriptionInfoList == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
for (SubscriptionInfo subscriptionInfo : subscriptionInfoList) {
|
||||
if (subscriptionInfo.getSubscriptionId() == subscriptionId) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
} catch (Exception e) {
|
||||
Log.e(TAG, "Error validating subscription ID: " + e.getMessage(), e);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,8 +7,13 @@ import android.content.ClipData;
|
||||
import android.content.ClipboardManager;
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import com.vernu.sms.activities.SMSFilterActivity;
|
||||
import android.os.Build;
|
||||
import android.os.Bundle;
|
||||
import android.os.Handler;
|
||||
import android.os.Looper;
|
||||
import android.text.Editable;
|
||||
import android.text.TextWatcher;
|
||||
import android.util.Log;
|
||||
import android.view.View;
|
||||
import android.widget.Button;
|
||||
@@ -30,9 +35,14 @@ import com.vernu.sms.TextBeeUtils;
|
||||
import com.vernu.sms.R;
|
||||
import com.vernu.sms.dtos.RegisterDeviceInputDTO;
|
||||
import com.vernu.sms.dtos.RegisterDeviceResponseDTO;
|
||||
import com.vernu.sms.dtos.SimInfoCollectionDTO;
|
||||
import com.vernu.sms.helpers.SharedPreferenceHelper;
|
||||
import com.vernu.sms.helpers.VersionTracker;
|
||||
import com.vernu.sms.helpers.HeartbeatManager;
|
||||
import com.google.firebase.crashlytics.FirebaseCrashlytics;
|
||||
import com.google.gson.Gson;
|
||||
import okhttp3.ResponseBody;
|
||||
import java.io.IOException;
|
||||
import java.util.Arrays;
|
||||
import java.util.Objects;
|
||||
import retrofit2.Call;
|
||||
@@ -43,13 +53,16 @@ public class MainActivity extends AppCompatActivity {
|
||||
|
||||
private Context mContext;
|
||||
private Switch gatewaySwitch, receiveSMSSwitch, stickyNotificationSwitch;
|
||||
private EditText apiKeyEditText, fcmTokenEditText, deviceIdEditText;
|
||||
private Button registerDeviceBtn, grantSMSPermissionBtn, scanQRBtn, checkUpdatesBtn;
|
||||
private EditText apiKeyEditText, fcmTokenEditText, deviceIdEditText, deviceNameEditText, smsSendDelayEditText;
|
||||
private Button registerDeviceBtn, grantSMSPermissionBtn, scanQRBtn, checkUpdatesBtn, configureFilterBtn, tryNewUIBtn;
|
||||
private ImageButton copyDeviceIdImgBtn;
|
||||
private TextView deviceBrandAndModelTxt, deviceIdTxt, appVersionNameTxt, appVersionCodeTxt;
|
||||
private RadioGroup defaultSimSlotRadioGroup;
|
||||
private static final int SCAN_QR_REQUEST_CODE = 49374;
|
||||
private static final int PERMISSION_REQUEST_CODE = 0;
|
||||
private static final long SMS_DELAY_SAVE_DEBOUNCE_MS = 3000L;
|
||||
private final Handler smsDelaySaveHandler = new Handler(Looper.getMainLooper());
|
||||
private Runnable smsDelaySaveRunnable;
|
||||
private String deviceId = null;
|
||||
private static final String TAG = "MainActivity";
|
||||
|
||||
@@ -66,6 +79,7 @@ public class MainActivity extends AppCompatActivity {
|
||||
apiKeyEditText = findViewById(R.id.apiKeyEditText);
|
||||
fcmTokenEditText = findViewById(R.id.fcmTokenEditText);
|
||||
deviceIdEditText = findViewById(R.id.deviceIdEditText);
|
||||
deviceNameEditText = findViewById(R.id.deviceNameEditText);
|
||||
registerDeviceBtn = findViewById(R.id.registerDeviceBtn);
|
||||
grantSMSPermissionBtn = findViewById(R.id.grantSMSPermissionBtn);
|
||||
scanQRBtn = findViewById(R.id.scanQRButton);
|
||||
@@ -76,6 +90,9 @@ public class MainActivity extends AppCompatActivity {
|
||||
appVersionNameTxt = findViewById(R.id.appVersionNameTxt);
|
||||
appVersionCodeTxt = findViewById(R.id.appVersionCodeTxt);
|
||||
checkUpdatesBtn = findViewById(R.id.checkUpdatesBtn);
|
||||
configureFilterBtn = findViewById(R.id.configureFilterBtn);
|
||||
smsSendDelayEditText = findViewById(R.id.smsSendDelayEditText);
|
||||
tryNewUIBtn = findViewById(R.id.tryNewUIBtn);
|
||||
|
||||
deviceIdTxt.setText(deviceId);
|
||||
deviceIdEditText.setText(deviceId);
|
||||
@@ -107,6 +124,12 @@ public class MainActivity extends AppCompatActivity {
|
||||
Log.d(TAG, "Starting sticky notification service on app start");
|
||||
}
|
||||
|
||||
// Schedule heartbeat if device is enabled and registered
|
||||
if (gatewayEnabled && !deviceId.isEmpty()) {
|
||||
HeartbeatManager.scheduleHeartbeat(mContext);
|
||||
Log.d(TAG, "Scheduling heartbeat on app start");
|
||||
}
|
||||
|
||||
if (deviceId == null || deviceId.isEmpty()) {
|
||||
registerDeviceBtn.setText("Register");
|
||||
} else {
|
||||
@@ -134,6 +157,12 @@ public class MainActivity extends AppCompatActivity {
|
||||
});
|
||||
|
||||
apiKeyEditText.setText(SharedPreferenceHelper.getSharedPreferenceString(mContext, AppConstants.SHARED_PREFS_API_KEY_KEY, ""));
|
||||
String storedDeviceName = SharedPreferenceHelper.getSharedPreferenceString(mContext, AppConstants.SHARED_PREFS_DEVICE_NAME_KEY, "");
|
||||
if (storedDeviceName.isEmpty()) {
|
||||
deviceNameEditText.setText(Build.BRAND + " " + Build.MODEL);
|
||||
} else {
|
||||
deviceNameEditText.setText(storedDeviceName);
|
||||
}
|
||||
gatewaySwitch.setChecked(SharedPreferenceHelper.getSharedPreferenceBoolean(mContext, AppConstants.SHARED_PREFS_GATEWAY_ENABLED_KEY, false));
|
||||
gatewaySwitch.setOnCheckedChangeListener((compoundButton, isCheked) -> {
|
||||
View view = compoundButton.getRootView();
|
||||
@@ -151,7 +180,7 @@ public class MainActivity extends AppCompatActivity {
|
||||
public void onResponse(Call<RegisterDeviceResponseDTO> call, Response<RegisterDeviceResponseDTO> response) {
|
||||
Log.d(TAG, response.toString());
|
||||
if (!response.isSuccessful()) {
|
||||
Snackbar.make(view, response.message().isEmpty() ? "An error occurred :( "+ response.code() : response.message(), Snackbar.LENGTH_LONG).show();
|
||||
Snackbar.make(view, extractErrorMessage(response), Snackbar.LENGTH_LONG).show();
|
||||
compoundButton.setEnabled(true);
|
||||
return;
|
||||
}
|
||||
@@ -164,8 +193,12 @@ public class MainActivity extends AppCompatActivity {
|
||||
if (SharedPreferenceHelper.getSharedPreferenceBoolean(mContext, AppConstants.SHARED_PREFS_STICKY_NOTIFICATION_ENABLED_KEY, false)) {
|
||||
TextBeeUtils.startStickyNotificationService(mContext);
|
||||
}
|
||||
// Schedule heartbeat
|
||||
HeartbeatManager.scheduleHeartbeat(mContext);
|
||||
} else {
|
||||
TextBeeUtils.stopStickyNotificationService(mContext);
|
||||
// Cancel heartbeat
|
||||
HeartbeatManager.cancelHeartbeat(mContext);
|
||||
}
|
||||
compoundButton.setEnabled(true);
|
||||
}
|
||||
@@ -226,6 +259,75 @@ public class MainActivity extends AppCompatActivity {
|
||||
Intent browserIntent = new Intent(Intent.ACTION_VIEW, android.net.Uri.parse(downloadUrl));
|
||||
startActivity(browserIntent);
|
||||
});
|
||||
|
||||
configureFilterBtn.setOnClickListener(view -> {
|
||||
Intent filterIntent = new Intent(MainActivity.this, SMSFilterActivity.class);
|
||||
startActivity(filterIntent);
|
||||
});
|
||||
|
||||
tryNewUIBtn.setOnClickListener(view -> {
|
||||
SharedPreferenceHelper.setSharedPreferenceBoolean(mContext, AppConstants.SHARED_PREFS_USE_NEW_UI_KEY, true);
|
||||
Intent intent = new Intent(MainActivity.this, com.vernu.sms.ui.splash.SplashActivity.class);
|
||||
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TASK);
|
||||
startActivity(intent);
|
||||
});
|
||||
|
||||
// SMS Send Delay setting: save 3 seconds after user stops typing
|
||||
int currentDelay = SharedPreferenceHelper.getSharedPreferenceInt(
|
||||
mContext, AppConstants.SHARED_PREFS_SMS_SEND_DELAY_SECONDS_KEY, AppConstants.DEFAULT_SMS_SEND_DELAY_SECONDS);
|
||||
smsSendDelayEditText.setText(String.valueOf(currentDelay));
|
||||
smsDelaySaveRunnable = this::saveSendDelay;
|
||||
smsSendDelayEditText.addTextChangedListener(new TextWatcher() {
|
||||
@Override
|
||||
public void beforeTextChanged(CharSequence s, int start, int count, int after) {}
|
||||
|
||||
@Override
|
||||
public void onTextChanged(CharSequence s, int start, int before, int count) {}
|
||||
|
||||
@Override
|
||||
public void afterTextChanged(Editable s) {
|
||||
smsDelaySaveHandler.removeCallbacks(smsDelaySaveRunnable);
|
||||
smsDelaySaveHandler.postDelayed(smsDelaySaveRunnable, SMS_DELAY_SAVE_DEBOUNCE_MS);
|
||||
}
|
||||
});
|
||||
smsSendDelayEditText.setOnEditorActionListener((v, actionId, event) -> {
|
||||
smsDelaySaveHandler.removeCallbacks(smsDelaySaveRunnable);
|
||||
saveSendDelay();
|
||||
return false;
|
||||
});
|
||||
}
|
||||
|
||||
private void saveSendDelay() {
|
||||
String text = smsSendDelayEditText.getText().toString().trim();
|
||||
if (text.isEmpty()) {
|
||||
int defaultDelay = AppConstants.DEFAULT_SMS_SEND_DELAY_SECONDS;
|
||||
smsSendDelayEditText.setText(String.valueOf(defaultDelay));
|
||||
SharedPreferenceHelper.setSharedPreferenceInt(mContext, AppConstants.SHARED_PREFS_SMS_SEND_DELAY_SECONDS_KEY, defaultDelay);
|
||||
Snackbar.make(smsSendDelayEditText, "SMS send delay saved (" + defaultDelay + " sec)", Snackbar.LENGTH_SHORT).show();
|
||||
return;
|
||||
}
|
||||
try {
|
||||
int value = Integer.parseInt(text);
|
||||
if (value < 0) {
|
||||
value = 0;
|
||||
smsSendDelayEditText.setText("0");
|
||||
SharedPreferenceHelper.setSharedPreferenceInt(mContext, AppConstants.SHARED_PREFS_SMS_SEND_DELAY_SECONDS_KEY, 0);
|
||||
Snackbar.make(smsSendDelayEditText, "Minimum delay is 0 seconds. Saved.", Snackbar.LENGTH_SHORT).show();
|
||||
} else if (value > 3600) {
|
||||
value = 3600;
|
||||
smsSendDelayEditText.setText("3600");
|
||||
SharedPreferenceHelper.setSharedPreferenceInt(mContext, AppConstants.SHARED_PREFS_SMS_SEND_DELAY_SECONDS_KEY, 3600);
|
||||
Snackbar.make(smsSendDelayEditText, "Maximum delay is 3600 seconds. Saved.", Snackbar.LENGTH_SHORT).show();
|
||||
} else {
|
||||
SharedPreferenceHelper.setSharedPreferenceInt(mContext, AppConstants.SHARED_PREFS_SMS_SEND_DELAY_SECONDS_KEY, value);
|
||||
Snackbar.make(smsSendDelayEditText, "SMS send delay saved (" + value + " sec)", Snackbar.LENGTH_SHORT).show();
|
||||
}
|
||||
} catch (NumberFormatException e) {
|
||||
int defaultDelay = AppConstants.DEFAULT_SMS_SEND_DELAY_SECONDS;
|
||||
smsSendDelayEditText.setText(String.valueOf(defaultDelay));
|
||||
SharedPreferenceHelper.setSharedPreferenceInt(mContext, AppConstants.SHARED_PREFS_SMS_SEND_DELAY_SECONDS_KEY, defaultDelay);
|
||||
Snackbar.make(smsSendDelayEditText, "Invalid value. Reset to " + defaultDelay + " sec.", Snackbar.LENGTH_SHORT).show();
|
||||
}
|
||||
}
|
||||
|
||||
private void renderAvailableSimOptions() {
|
||||
@@ -245,7 +347,8 @@ public class MainActivity extends AppCompatActivity {
|
||||
|
||||
// Create radio buttons for each SIM with proper styling
|
||||
TextBeeUtils.getAvailableSimSlots(mContext).forEach(subscriptionInfo -> {
|
||||
String simInfo = "SIM " + (subscriptionInfo.getSimSlotIndex() + 1) + " (" + subscriptionInfo.getDisplayName() + ")";
|
||||
String displayName = subscriptionInfo.getDisplayName() != null ? subscriptionInfo.getDisplayName().toString() : "Unknown";
|
||||
String simInfo = displayName + " (Subscription ID: " + subscriptionInfo.getSubscriptionId() + ")";
|
||||
RadioButton radioButton = new RadioButton(mContext);
|
||||
radioButton.setText(simInfo);
|
||||
radioButton.setId(subscriptionInfo.getSubscriptionId());
|
||||
@@ -280,6 +383,44 @@ public class MainActivity extends AppCompatActivity {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts error message from API response, trying multiple sources:
|
||||
* 1. Error message from response body (error field)
|
||||
* 2. Response message from HTTP headers
|
||||
* 3. Generic error with status code as fallback
|
||||
*/
|
||||
private String extractErrorMessage(Response<?> response) {
|
||||
// Try to parse error from response body
|
||||
try {
|
||||
ResponseBody errorBody = response.errorBody();
|
||||
if (errorBody != null) {
|
||||
String errorBodyString = errorBody.string();
|
||||
if (errorBodyString != null && !errorBodyString.isEmpty()) {
|
||||
try {
|
||||
Gson gson = new Gson();
|
||||
RegisterDeviceResponseDTO errorResponse = gson.fromJson(errorBodyString, RegisterDeviceResponseDTO.class);
|
||||
if (errorResponse != null && errorResponse.error != null && !errorResponse.error.isEmpty()) {
|
||||
return errorResponse.error;
|
||||
}
|
||||
} catch (Exception e) {
|
||||
// If JSON parsing fails, try to extract message from raw string
|
||||
Log.d(TAG, "Could not parse error response as JSON: " + errorBodyString);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (IOException e) {
|
||||
Log.d(TAG, "Could not read error body: " + e.getMessage());
|
||||
}
|
||||
|
||||
// Fall back to response message
|
||||
if (response.message() != null && !response.message().isEmpty()) {
|
||||
return response.message();
|
||||
}
|
||||
|
||||
// Final fallback to generic error with status code
|
||||
return "An error occurred :( " + response.code();
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply the custom radio button style to a programmatically created radio button
|
||||
*/
|
||||
@@ -364,6 +505,19 @@ public class MainActivity extends AppCompatActivity {
|
||||
registerDeviceInput.setAppVersionCode(BuildConfig.VERSION_CODE);
|
||||
registerDeviceInput.setAppVersionName(BuildConfig.VERSION_NAME);
|
||||
|
||||
// Get device name from input field or default to "brand model"
|
||||
String deviceName = deviceNameEditText.getText().toString().trim();
|
||||
if (deviceName.isEmpty()) {
|
||||
deviceName = Build.BRAND + " " + Build.MODEL;
|
||||
}
|
||||
registerDeviceInput.setName(deviceName);
|
||||
|
||||
// Collect SIM information
|
||||
SimInfoCollectionDTO simInfoCollection = new SimInfoCollectionDTO();
|
||||
simInfoCollection.setLastUpdated(System.currentTimeMillis());
|
||||
simInfoCollection.setSims(TextBeeUtils.collectSimInfo(mContext));
|
||||
registerDeviceInput.setSimInfo(simInfoCollection);
|
||||
|
||||
// If the user provided a device ID, use it for updating instead of creating new
|
||||
if (!deviceIdInput.isEmpty()) {
|
||||
Log.d(TAG, "Updating device with deviceId: "+ deviceIdInput);
|
||||
@@ -373,7 +527,7 @@ public class MainActivity extends AppCompatActivity {
|
||||
public void onResponse(Call<RegisterDeviceResponseDTO> call, Response<RegisterDeviceResponseDTO> response) {
|
||||
Log.d(TAG, response.toString());
|
||||
if (!response.isSuccessful()) {
|
||||
Snackbar.make(view, response.message().isEmpty() ? "An error occurred :( "+ response.code() : response.message(), Snackbar.LENGTH_LONG).show();
|
||||
Snackbar.make(view, extractErrorMessage(response), Snackbar.LENGTH_LONG).show();
|
||||
registerDeviceBtn.setEnabled(true);
|
||||
registerDeviceBtn.setText("Update");
|
||||
return;
|
||||
@@ -389,6 +543,29 @@ public class MainActivity extends AppCompatActivity {
|
||||
SharedPreferenceHelper.setSharedPreferenceString(mContext, AppConstants.SHARED_PREFS_DEVICE_ID_KEY, deviceId);
|
||||
SharedPreferenceHelper.setSharedPreferenceBoolean(mContext, AppConstants.SHARED_PREFS_GATEWAY_ENABLED_KEY, registerDeviceInput.isEnabled());
|
||||
gatewaySwitch.setChecked(registerDeviceInput.isEnabled());
|
||||
|
||||
// Sync heartbeatIntervalMinutes from server response
|
||||
if (response.body().data.get("heartbeatIntervalMinutes") != null) {
|
||||
Object intervalObj = response.body().data.get("heartbeatIntervalMinutes");
|
||||
if (intervalObj instanceof Number) {
|
||||
int intervalMinutes = ((Number) intervalObj).intValue();
|
||||
SharedPreferenceHelper.setSharedPreferenceInt(mContext, AppConstants.SHARED_PREFS_HEARTBEAT_INTERVAL_MINUTES_KEY, intervalMinutes);
|
||||
Log.d(TAG, "Synced heartbeat interval from server: " + intervalMinutes + " minutes");
|
||||
}
|
||||
}
|
||||
|
||||
// Sync device name from server response
|
||||
if (response.body().data.get("name") != null) {
|
||||
String deviceName = response.body().data.get("name").toString();
|
||||
SharedPreferenceHelper.setSharedPreferenceString(mContext, AppConstants.SHARED_PREFS_DEVICE_NAME_KEY, deviceName);
|
||||
deviceNameEditText.setText(deviceName);
|
||||
Log.d(TAG, "Synced device name from server: " + deviceName);
|
||||
}
|
||||
|
||||
// Schedule heartbeat if device is enabled
|
||||
if (registerDeviceInput.isEnabled()) {
|
||||
HeartbeatManager.scheduleHeartbeat(mContext);
|
||||
}
|
||||
}
|
||||
|
||||
// Update stored version information
|
||||
@@ -412,18 +589,18 @@ public class MainActivity extends AppCompatActivity {
|
||||
}
|
||||
|
||||
Call<RegisterDeviceResponseDTO> apiCall = ApiManager.getApiService().registerDevice(newKey, registerDeviceInput);
|
||||
apiCall.enqueue(new Callback<RegisterDeviceResponseDTO>() {
|
||||
@Override
|
||||
public void onResponse(Call<RegisterDeviceResponseDTO> call, Response<RegisterDeviceResponseDTO> response) {
|
||||
Log.d(TAG, response.toString());
|
||||
if (!response.isSuccessful()) {
|
||||
Snackbar.make(view, response.message().isEmpty() ? "An error occurred :( "+ response.code() : response.message(), Snackbar.LENGTH_LONG).show();
|
||||
registerDeviceBtn.setEnabled(true);
|
||||
registerDeviceBtn.setText("Update");
|
||||
return;
|
||||
}
|
||||
SharedPreferenceHelper.setSharedPreferenceString(mContext, AppConstants.SHARED_PREFS_API_KEY_KEY, newKey);
|
||||
Snackbar.make(view, "Device Registration Successful :)", Snackbar.LENGTH_LONG).show();
|
||||
apiCall.enqueue(new Callback<RegisterDeviceResponseDTO>() {
|
||||
@Override
|
||||
public void onResponse(Call<RegisterDeviceResponseDTO> call, Response<RegisterDeviceResponseDTO> response) {
|
||||
Log.d(TAG, response.toString());
|
||||
if (!response.isSuccessful()) {
|
||||
Snackbar.make(view, extractErrorMessage(response), Snackbar.LENGTH_LONG).show();
|
||||
registerDeviceBtn.setEnabled(true);
|
||||
registerDeviceBtn.setText("Update");
|
||||
return;
|
||||
}
|
||||
SharedPreferenceHelper.setSharedPreferenceString(mContext, AppConstants.SHARED_PREFS_API_KEY_KEY, newKey);
|
||||
Snackbar.make(view, "Device Registration Successful :)", Snackbar.LENGTH_LONG).show();
|
||||
|
||||
if (response.body() != null && response.body().data != null && response.body().data.get("_id") != null) {
|
||||
deviceId = response.body().data.get("_id").toString();
|
||||
@@ -432,6 +609,29 @@ public class MainActivity extends AppCompatActivity {
|
||||
SharedPreferenceHelper.setSharedPreferenceString(mContext, AppConstants.SHARED_PREFS_DEVICE_ID_KEY, deviceId);
|
||||
SharedPreferenceHelper.setSharedPreferenceBoolean(mContext, AppConstants.SHARED_PREFS_GATEWAY_ENABLED_KEY, registerDeviceInput.isEnabled());
|
||||
gatewaySwitch.setChecked(registerDeviceInput.isEnabled());
|
||||
|
||||
// Sync heartbeatIntervalMinutes from server response
|
||||
if (response.body().data.get("heartbeatIntervalMinutes") != null) {
|
||||
Object intervalObj = response.body().data.get("heartbeatIntervalMinutes");
|
||||
if (intervalObj instanceof Number) {
|
||||
int intervalMinutes = ((Number) intervalObj).intValue();
|
||||
SharedPreferenceHelper.setSharedPreferenceInt(mContext, AppConstants.SHARED_PREFS_HEARTBEAT_INTERVAL_MINUTES_KEY, intervalMinutes);
|
||||
Log.d(TAG, "Synced heartbeat interval from server: " + intervalMinutes + " minutes");
|
||||
}
|
||||
}
|
||||
|
||||
// Sync device name from server response
|
||||
if (response.body().data.get("name") != null) {
|
||||
String deviceName = response.body().data.get("name").toString();
|
||||
SharedPreferenceHelper.setSharedPreferenceString(mContext, AppConstants.SHARED_PREFS_DEVICE_NAME_KEY, deviceName);
|
||||
deviceNameEditText.setText(deviceName);
|
||||
Log.d(TAG, "Synced device name from server: " + deviceName);
|
||||
}
|
||||
|
||||
// Schedule heartbeat if device is enabled
|
||||
if (registerDeviceInput.isEnabled()) {
|
||||
HeartbeatManager.scheduleHeartbeat(mContext);
|
||||
}
|
||||
}
|
||||
|
||||
// Update stored version information
|
||||
@@ -484,13 +684,26 @@ public class MainActivity extends AppCompatActivity {
|
||||
updateDeviceInput.setAppVersionCode(BuildConfig.VERSION_CODE);
|
||||
updateDeviceInput.setAppVersionName(BuildConfig.VERSION_NAME);
|
||||
|
||||
// Get device name from input field or default to "brand model"
|
||||
String deviceName = deviceNameEditText.getText().toString().trim();
|
||||
if (deviceName.isEmpty()) {
|
||||
deviceName = Build.BRAND + " " + Build.MODEL;
|
||||
}
|
||||
updateDeviceInput.setName(deviceName);
|
||||
|
||||
// Collect SIM information
|
||||
SimInfoCollectionDTO simInfoCollection = new SimInfoCollectionDTO();
|
||||
simInfoCollection.setLastUpdated(System.currentTimeMillis());
|
||||
simInfoCollection.setSims(TextBeeUtils.collectSimInfo(mContext));
|
||||
updateDeviceInput.setSimInfo(simInfoCollection);
|
||||
|
||||
Call<RegisterDeviceResponseDTO> apiCall = ApiManager.getApiService().updateDevice(deviceIdToUse, apiKey, updateDeviceInput);
|
||||
apiCall.enqueue(new Callback<RegisterDeviceResponseDTO>() {
|
||||
@Override
|
||||
public void onResponse(Call<RegisterDeviceResponseDTO> call, Response<RegisterDeviceResponseDTO> response) {
|
||||
Log.d(TAG, response.toString());
|
||||
if (!response.isSuccessful()) {
|
||||
Snackbar.make(view, response.message().isEmpty() ? "An error occurred :( "+ response.code() : response.message(), Snackbar.LENGTH_LONG).show();
|
||||
Snackbar.make(view, extractErrorMessage(response), Snackbar.LENGTH_LONG).show();
|
||||
registerDeviceBtn.setEnabled(true);
|
||||
registerDeviceBtn.setText("Update");
|
||||
return;
|
||||
@@ -503,6 +716,29 @@ public class MainActivity extends AppCompatActivity {
|
||||
SharedPreferenceHelper.setSharedPreferenceString(mContext, AppConstants.SHARED_PREFS_DEVICE_ID_KEY, deviceId);
|
||||
deviceIdTxt.setText(deviceId);
|
||||
deviceIdEditText.setText(deviceId);
|
||||
|
||||
// Sync heartbeatIntervalMinutes from server response
|
||||
if (response.body().data.get("heartbeatIntervalMinutes") != null) {
|
||||
Object intervalObj = response.body().data.get("heartbeatIntervalMinutes");
|
||||
if (intervalObj instanceof Number) {
|
||||
int intervalMinutes = ((Number) intervalObj).intValue();
|
||||
SharedPreferenceHelper.setSharedPreferenceInt(mContext, AppConstants.SHARED_PREFS_HEARTBEAT_INTERVAL_MINUTES_KEY, intervalMinutes);
|
||||
Log.d(TAG, "Synced heartbeat interval from server: " + intervalMinutes + " minutes");
|
||||
}
|
||||
}
|
||||
|
||||
// Sync device name from server response
|
||||
if (response.body().data.get("name") != null) {
|
||||
String deviceName = response.body().data.get("name").toString();
|
||||
SharedPreferenceHelper.setSharedPreferenceString(mContext, AppConstants.SHARED_PREFS_DEVICE_NAME_KEY, deviceName);
|
||||
deviceNameEditText.setText(deviceName);
|
||||
Log.d(TAG, "Synced device name from server: " + deviceName);
|
||||
}
|
||||
|
||||
// Schedule heartbeat if device is enabled
|
||||
if (updateDeviceInput.isEnabled()) {
|
||||
HeartbeatManager.scheduleHeartbeat(mContext);
|
||||
}
|
||||
}
|
||||
|
||||
// Update stored version information
|
||||
|
||||
@@ -0,0 +1,267 @@
|
||||
package com.vernu.sms.activities;
|
||||
|
||||
import android.app.AlertDialog;
|
||||
import android.content.Context;
|
||||
import android.os.Bundle;
|
||||
import android.view.LayoutInflater;
|
||||
import android.view.View;
|
||||
import android.view.ViewGroup;
|
||||
import android.widget.ArrayAdapter;
|
||||
import android.widget.Button;
|
||||
import android.widget.ImageButton;
|
||||
import android.widget.RadioButton;
|
||||
import android.widget.RadioGroup;
|
||||
import android.widget.Spinner;
|
||||
import android.widget.Switch;
|
||||
import android.widget.TextView;
|
||||
import android.widget.Toast;
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.appcompat.app.AppCompatActivity;
|
||||
import androidx.recyclerview.widget.LinearLayoutManager;
|
||||
import androidx.recyclerview.widget.RecyclerView;
|
||||
import com.google.android.material.floatingactionbutton.FloatingActionButton;
|
||||
import com.google.android.material.textfield.TextInputEditText;
|
||||
import com.vernu.sms.R;
|
||||
import com.vernu.sms.helpers.SMSFilterHelper;
|
||||
import com.vernu.sms.models.SMSFilterRule;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
public class SMSFilterActivity extends AppCompatActivity {
|
||||
private Context mContext;
|
||||
private Switch filterEnabledSwitch;
|
||||
private RadioGroup filterModeRadioGroup;
|
||||
private RadioButton allowListRadio;
|
||||
private RadioButton blockListRadio;
|
||||
private RecyclerView filterRulesRecyclerView;
|
||||
private FloatingActionButton addRuleFab;
|
||||
private FilterRulesAdapter adapter;
|
||||
private SMSFilterHelper.FilterConfig filterConfig;
|
||||
|
||||
@Override
|
||||
protected void onCreate(Bundle savedInstanceState) {
|
||||
super.onCreate(savedInstanceState);
|
||||
setContentView(R.layout.activity_sms_filter);
|
||||
|
||||
mContext = getApplicationContext();
|
||||
|
||||
// Initialize views
|
||||
ImageButton backButton = findViewById(R.id.backButton);
|
||||
filterEnabledSwitch = findViewById(R.id.filterEnabledSwitch);
|
||||
filterModeRadioGroup = findViewById(R.id.filterModeRadioGroup);
|
||||
allowListRadio = findViewById(R.id.allowListRadio);
|
||||
blockListRadio = findViewById(R.id.blockListRadio);
|
||||
filterRulesRecyclerView = findViewById(R.id.filterRulesRecyclerView);
|
||||
addRuleFab = findViewById(R.id.addRuleFab);
|
||||
|
||||
// Setup back button
|
||||
backButton.setOnClickListener(v -> finish());
|
||||
|
||||
// Load filter config
|
||||
filterConfig = SMSFilterHelper.loadFilterConfig(mContext);
|
||||
|
||||
// Setup RecyclerView
|
||||
adapter = new FilterRulesAdapter(filterConfig.getRules());
|
||||
filterRulesRecyclerView.setLayoutManager(new LinearLayoutManager(this));
|
||||
filterRulesRecyclerView.setAdapter(adapter);
|
||||
|
||||
// Load current settings
|
||||
filterEnabledSwitch.setChecked(filterConfig.isEnabled());
|
||||
// Default to block list if mode is not set
|
||||
if (filterConfig.getMode() == SMSFilterHelper.FilterMode.ALLOW_LIST) {
|
||||
allowListRadio.setChecked(true);
|
||||
} else {
|
||||
blockListRadio.setChecked(true);
|
||||
}
|
||||
|
||||
// Setup listeners
|
||||
filterEnabledSwitch.setOnCheckedChangeListener((buttonView, isChecked) -> {
|
||||
filterConfig.setEnabled(isChecked);
|
||||
saveFilterConfig();
|
||||
});
|
||||
|
||||
filterModeRadioGroup.setOnCheckedChangeListener((group, checkedId) -> {
|
||||
if (checkedId == R.id.allowListRadio) {
|
||||
filterConfig.setMode(SMSFilterHelper.FilterMode.ALLOW_LIST);
|
||||
} else {
|
||||
filterConfig.setMode(SMSFilterHelper.FilterMode.BLOCK_LIST);
|
||||
}
|
||||
saveFilterConfig();
|
||||
});
|
||||
|
||||
addRuleFab.setOnClickListener(v -> showAddEditRuleDialog(-1));
|
||||
}
|
||||
|
||||
private void saveFilterConfig() {
|
||||
SMSFilterHelper.saveFilterConfig(mContext, filterConfig);
|
||||
}
|
||||
|
||||
private void showAddEditRuleDialog(int position) {
|
||||
AlertDialog.Builder builder = new AlertDialog.Builder(this);
|
||||
View dialogView = LayoutInflater.from(this).inflate(R.layout.dialog_add_filter_rule, null);
|
||||
builder.setView(dialogView);
|
||||
|
||||
TextInputEditText patternEditText = dialogView.findViewById(R.id.patternEditText);
|
||||
Spinner filterTargetSpinner = dialogView.findViewById(R.id.filterTargetSpinner);
|
||||
Spinner matchTypeSpinner = dialogView.findViewById(R.id.matchTypeSpinner);
|
||||
Switch caseSensitiveSwitch = dialogView.findViewById(R.id.caseSensitiveSwitch);
|
||||
Button cancelButton = dialogView.findViewById(R.id.cancelButton);
|
||||
Button saveButton = dialogView.findViewById(R.id.saveButton);
|
||||
|
||||
// Setup filter target spinner
|
||||
String[] filterTargets = {"Sender", "Message", "Both"};
|
||||
ArrayAdapter<String> targetAdapter = new ArrayAdapter<>(this, android.R.layout.simple_spinner_item, filterTargets);
|
||||
targetAdapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item);
|
||||
filterTargetSpinner.setAdapter(targetAdapter);
|
||||
|
||||
// Setup match type spinner
|
||||
String[] matchTypes = {"Exact Match", "Starts With", "Ends With", "Contains"};
|
||||
ArrayAdapter<String> spinnerAdapter = new ArrayAdapter<>(this, android.R.layout.simple_spinner_item, matchTypes);
|
||||
spinnerAdapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item);
|
||||
matchTypeSpinner.setAdapter(spinnerAdapter);
|
||||
|
||||
// If editing, populate fields
|
||||
boolean isEdit = position >= 0;
|
||||
TextView dialogTitle = dialogView.findViewById(R.id.dialogTitle);
|
||||
if (isEdit) {
|
||||
SMSFilterRule rule = filterConfig.getRules().get(position);
|
||||
patternEditText.setText(rule.getPattern());
|
||||
filterTargetSpinner.setSelection(rule.getFilterTarget().ordinal());
|
||||
matchTypeSpinner.setSelection(rule.getMatchType().ordinal());
|
||||
caseSensitiveSwitch.setChecked(rule.isCaseSensitive());
|
||||
if (dialogTitle != null) {
|
||||
dialogTitle.setText("Edit Filter Rule");
|
||||
}
|
||||
} else {
|
||||
// Default to case insensitive
|
||||
caseSensitiveSwitch.setChecked(false);
|
||||
}
|
||||
|
||||
AlertDialog dialog = builder.create();
|
||||
|
||||
cancelButton.setOnClickListener(v -> dialog.dismiss());
|
||||
|
||||
saveButton.setOnClickListener(v -> {
|
||||
String pattern = patternEditText.getText() != null ? patternEditText.getText().toString().trim() : "";
|
||||
if (pattern.isEmpty()) {
|
||||
Toast.makeText(this, "Please enter a pattern", Toast.LENGTH_SHORT).show();
|
||||
return;
|
||||
}
|
||||
|
||||
SMSFilterRule.FilterTarget filterTarget = SMSFilterRule.FilterTarget.values()[filterTargetSpinner.getSelectedItemPosition()];
|
||||
SMSFilterRule.MatchType matchType = SMSFilterRule.MatchType.values()[matchTypeSpinner.getSelectedItemPosition()];
|
||||
boolean caseSensitive = caseSensitiveSwitch.isChecked();
|
||||
|
||||
if (isEdit) {
|
||||
SMSFilterRule rule = filterConfig.getRules().get(position);
|
||||
rule.setPattern(pattern);
|
||||
rule.setFilterTarget(filterTarget);
|
||||
rule.setMatchType(matchType);
|
||||
rule.setCaseSensitive(caseSensitive);
|
||||
adapter.notifyItemChanged(position);
|
||||
} else {
|
||||
SMSFilterRule newRule = new SMSFilterRule(pattern, matchType, filterTarget, caseSensitive);
|
||||
filterConfig.getRules().add(newRule);
|
||||
adapter.notifyItemInserted(filterConfig.getRules().size() - 1);
|
||||
}
|
||||
|
||||
saveFilterConfig();
|
||||
dialog.dismiss();
|
||||
});
|
||||
|
||||
dialog.show();
|
||||
}
|
||||
|
||||
private void deleteRule(int position) {
|
||||
new AlertDialog.Builder(this)
|
||||
.setTitle("Delete Rule")
|
||||
.setMessage("Are you sure you want to delete this filter rule?")
|
||||
.setPositiveButton("Delete", (dialog, which) -> {
|
||||
filterConfig.getRules().remove(position);
|
||||
adapter.notifyItemRemoved(position);
|
||||
saveFilterConfig();
|
||||
})
|
||||
.setNegativeButton("Cancel", null)
|
||||
.show();
|
||||
}
|
||||
|
||||
private class FilterRulesAdapter extends RecyclerView.Adapter<FilterRulesAdapter.ViewHolder> {
|
||||
private List<SMSFilterRule> rules;
|
||||
|
||||
public FilterRulesAdapter(List<SMSFilterRule> rules) {
|
||||
this.rules = rules != null ? rules : new ArrayList<>();
|
||||
}
|
||||
|
||||
@NonNull
|
||||
@Override
|
||||
public ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
|
||||
View view = LayoutInflater.from(parent.getContext())
|
||||
.inflate(R.layout.item_filter_rule, parent, false);
|
||||
return new ViewHolder(view);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onBindViewHolder(@NonNull ViewHolder holder, int position) {
|
||||
SMSFilterRule rule = rules.get(position);
|
||||
holder.patternText.setText(rule.getPattern());
|
||||
|
||||
String matchTypeText = "";
|
||||
switch (rule.getMatchType()) {
|
||||
case EXACT:
|
||||
matchTypeText = "Exact Match";
|
||||
break;
|
||||
case STARTS_WITH:
|
||||
matchTypeText = "Starts With";
|
||||
break;
|
||||
case ENDS_WITH:
|
||||
matchTypeText = "Ends With";
|
||||
break;
|
||||
case CONTAINS:
|
||||
matchTypeText = "Contains";
|
||||
break;
|
||||
}
|
||||
holder.matchTypeText.setText(matchTypeText);
|
||||
|
||||
String filterTargetText = "";
|
||||
switch (rule.getFilterTarget()) {
|
||||
case SENDER:
|
||||
filterTargetText = "Filter: Sender";
|
||||
break;
|
||||
case MESSAGE:
|
||||
filterTargetText = "Filter: Message";
|
||||
break;
|
||||
case BOTH:
|
||||
filterTargetText = "Filter: Sender or Message";
|
||||
break;
|
||||
}
|
||||
String caseText = rule.isCaseSensitive() ? " (Case Sensitive)" : " (Case Insensitive)";
|
||||
holder.filterTargetText.setText(filterTargetText + caseText);
|
||||
|
||||
holder.editButton.setOnClickListener(v -> showAddEditRuleDialog(position));
|
||||
holder.deleteButton.setOnClickListener(v -> deleteRule(position));
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getItemCount() {
|
||||
return rules.size();
|
||||
}
|
||||
|
||||
class ViewHolder extends RecyclerView.ViewHolder {
|
||||
TextView patternText;
|
||||
TextView matchTypeText;
|
||||
TextView filterTargetText;
|
||||
ImageButton editButton;
|
||||
ImageButton deleteButton;
|
||||
|
||||
ViewHolder(View itemView) {
|
||||
super(itemView);
|
||||
patternText = itemView.findViewById(R.id.patternText);
|
||||
matchTypeText = itemView.findViewById(R.id.matchTypeText);
|
||||
filterTargetText = itemView.findViewById(R.id.filterTargetText);
|
||||
editButton = itemView.findViewById(R.id.editButton);
|
||||
deleteButton = itemView.findViewById(R.id.deleteButton);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
//package com.vernu.sms.database.local;
|
||||
//
|
||||
//import android.content.Context;
|
||||
//import androidx.room.Database;
|
||||
//import androidx.room.Room;
|
||||
//import androidx.room.RoomDatabase;
|
||||
//
|
||||
//@Database(entities = {SMS.class}, version = 2)
|
||||
//public abstract class AppDatabase extends RoomDatabase {
|
||||
// private static volatile AppDatabase INSTANCE;
|
||||
//
|
||||
// public static AppDatabase getInstance(Context context) {
|
||||
// if (INSTANCE == null) {
|
||||
// synchronized (AppDatabase.class) {
|
||||
// if (INSTANCE == null) {
|
||||
// INSTANCE = Room.databaseBuilder(context.getApplicationContext(), AppDatabase.class, "db1")
|
||||
// .build();
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// return INSTANCE;
|
||||
// }
|
||||
//
|
||||
// public abstract SMSDao localReceivedSMSDao();
|
||||
//}
|
||||
@@ -0,0 +1,28 @@
|
||||
package com.vernu.sms.database.local
|
||||
|
||||
/*
|
||||
import android.content.Context
|
||||
import androidx.room.*
|
||||
|
||||
@Database(entities = [Sms::class], version = 2)
|
||||
@TypeConverters(DateConverter::class)
|
||||
abstract class AppDatabase : RoomDatabase() {
|
||||
|
||||
abstract fun localReceivedSMSDao(): SmsDao
|
||||
|
||||
companion object {
|
||||
@Volatile
|
||||
private var INSTANCE: AppDatabase? = null
|
||||
|
||||
fun getInstance(context: Context): AppDatabase {
|
||||
return INSTANCE ?: synchronized(this) {
|
||||
INSTANCE ?: Room.databaseBuilder(
|
||||
context.applicationContext,
|
||||
AppDatabase::class.java,
|
||||
"db1"
|
||||
).build().also { INSTANCE = it }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
*/
|
||||
@@ -1,17 +0,0 @@
|
||||
//package com.vernu.sms.database.local;
|
||||
//
|
||||
//import androidx.room.TypeConverter;
|
||||
//
|
||||
//import java.util.Date;
|
||||
//
|
||||
//public class DateConverter {
|
||||
// @TypeConverter
|
||||
// public static Date toDate(Long dateLong) {
|
||||
// return dateLong == null ? null : new Date(dateLong);
|
||||
// }
|
||||
//
|
||||
// @TypeConverter
|
||||
// public static Long fromDate(Date date) {
|
||||
// return date == null ? null : date.getTime();
|
||||
// }
|
||||
//}
|
||||
@@ -0,0 +1,14 @@
|
||||
package com.vernu.sms.database.local
|
||||
|
||||
/*
|
||||
import androidx.room.TypeConverter
|
||||
import java.util.Date
|
||||
|
||||
object DateConverter {
|
||||
@TypeConverter
|
||||
fun toDate(value: Long?): Date? = value?.let { Date(it) }
|
||||
|
||||
@TypeConverter
|
||||
fun fromDate(date: Date?): Long? = date?.time
|
||||
}
|
||||
*/
|
||||
@@ -1,193 +0,0 @@
|
||||
//package com.vernu.sms.database.local;
|
||||
//
|
||||
//import androidx.annotation.NonNull;
|
||||
//import androidx.room.ColumnInfo;
|
||||
//import androidx.room.Entity;
|
||||
//import androidx.room.PrimaryKey;
|
||||
//import androidx.room.TypeConverters;
|
||||
//
|
||||
//import java.util.Date;
|
||||
//
|
||||
//@Entity(tableName = "sms")
|
||||
//@TypeConverters(DateConverter.class)
|
||||
//public class SMS {
|
||||
//
|
||||
// public SMS() {
|
||||
// type = null;
|
||||
// }
|
||||
//
|
||||
// @PrimaryKey(autoGenerate = true)
|
||||
// private int id;
|
||||
//
|
||||
// // This is the ID of the SMS in the server
|
||||
// @ColumnInfo(name = "_id")
|
||||
// private String _id;
|
||||
//
|
||||
// @ColumnInfo(name = "message")
|
||||
// private String message = "";
|
||||
//
|
||||
// @ColumnInfo(name = "encrypted_message")
|
||||
// private String encryptedMessage = "";
|
||||
//
|
||||
// @ColumnInfo(name = "is_encrypted", defaultValue = "0")
|
||||
// private boolean isEncrypted = false;
|
||||
//
|
||||
// @ColumnInfo(name = "sender")
|
||||
// private String sender;
|
||||
//
|
||||
// @ColumnInfo(name = "recipient")
|
||||
// private String recipient;
|
||||
//
|
||||
// @ColumnInfo(name = "requested_at")
|
||||
// private Date requestedAt;
|
||||
//
|
||||
// @ColumnInfo(name = "sent_at")
|
||||
// private Date sentAt;
|
||||
//
|
||||
// @ColumnInfo(name = "delivered_at")
|
||||
// private Date deliveredAt;
|
||||
//
|
||||
// @ColumnInfo(name = "received_at")
|
||||
// private Date receivedAt;
|
||||
//
|
||||
// @NonNull
|
||||
// @ColumnInfo(name = "type")
|
||||
// private String type;
|
||||
//
|
||||
// @ColumnInfo(name = "server_acknowledged_at")
|
||||
// private Date serverAcknowledgedAt;
|
||||
//
|
||||
// public boolean hasServerAcknowledged() {
|
||||
// return serverAcknowledgedAt != null;
|
||||
// }
|
||||
//
|
||||
// @ColumnInfo(name = "last_acknowledged_request_at")
|
||||
// private Date lastAcknowledgedRequestAt;
|
||||
//
|
||||
// @ColumnInfo(name = "retry_count", defaultValue = "0")
|
||||
// private int retryCount = 0;
|
||||
//
|
||||
// public int getId() {
|
||||
// return id;
|
||||
// }
|
||||
//
|
||||
// public void setId(int id) {
|
||||
// this.id = id;
|
||||
// }
|
||||
//
|
||||
// public String get_id() {
|
||||
// return _id;
|
||||
// }
|
||||
//
|
||||
// public void set_id(String _id) {
|
||||
// this._id = _id;
|
||||
// }
|
||||
//
|
||||
// public String getMessage() {
|
||||
// return message;
|
||||
// }
|
||||
//
|
||||
// public void setMessage(String message) {
|
||||
// this.message = message;
|
||||
// }
|
||||
//
|
||||
// public String getEncryptedMessage() {
|
||||
// return encryptedMessage;
|
||||
// }
|
||||
//
|
||||
// public void setEncryptedMessage(String encryptedMessage) {
|
||||
// this.encryptedMessage = encryptedMessage;
|
||||
// }
|
||||
//
|
||||
// public boolean getIsEncrypted() {
|
||||
// return isEncrypted;
|
||||
// }
|
||||
//
|
||||
// public void setIsEncrypted(boolean isEncrypted) {
|
||||
// this.isEncrypted = isEncrypted;
|
||||
// }
|
||||
//
|
||||
// public String getSender() {
|
||||
// return sender;
|
||||
// }
|
||||
//
|
||||
// public void setSender(String sender) {
|
||||
// this.sender = sender;
|
||||
// }
|
||||
//
|
||||
// public String getRecipient() {
|
||||
// return recipient;
|
||||
// }
|
||||
//
|
||||
// public void setRecipient(String recipient) {
|
||||
// this.recipient = recipient;
|
||||
// }
|
||||
//
|
||||
// public Date getServerAcknowledgedAt() {
|
||||
// return serverAcknowledgedAt;
|
||||
// }
|
||||
//
|
||||
// public void setServerAcknowledgedAt(Date serverAcknowledgedAt) {
|
||||
// this.serverAcknowledgedAt = serverAcknowledgedAt;
|
||||
// }
|
||||
//
|
||||
//
|
||||
//
|
||||
// public Date getRequestedAt() {
|
||||
// return requestedAt;
|
||||
// }
|
||||
//
|
||||
// public void setRequestedAt(Date requestedAt) {
|
||||
// this.requestedAt = requestedAt;
|
||||
// }
|
||||
//
|
||||
// public Date getSentAt() {
|
||||
// return sentAt;
|
||||
// }
|
||||
//
|
||||
// public void setSentAt(Date sentAt) {
|
||||
// this.sentAt = sentAt;
|
||||
// }
|
||||
//
|
||||
// public Date getDeliveredAt() {
|
||||
// return deliveredAt;
|
||||
// }
|
||||
//
|
||||
// public void setDeliveredAt(Date deliveredAt) {
|
||||
// this.deliveredAt = deliveredAt;
|
||||
// }
|
||||
//
|
||||
// public Date getReceivedAt() {
|
||||
// return receivedAt;
|
||||
// }
|
||||
//
|
||||
// public void setReceivedAt(Date receivedAt) {
|
||||
// this.receivedAt = receivedAt;
|
||||
// }
|
||||
//
|
||||
// @NonNull
|
||||
// public String getType() {
|
||||
// return type;
|
||||
// }
|
||||
//
|
||||
// public void setType(@NonNull String type) {
|
||||
// this.type = type;
|
||||
// }
|
||||
//
|
||||
//
|
||||
// public Date getLastAcknowledgedRequestAt() {
|
||||
// return lastAcknowledgedRequestAt;
|
||||
// }
|
||||
//
|
||||
// public void setLastAcknowledgedRequestAt(Date lastAcknowledgedRequestAt) {
|
||||
// this.lastAcknowledgedRequestAt = lastAcknowledgedRequestAt;
|
||||
// }
|
||||
//
|
||||
// public int getRetryCount() {
|
||||
// return retryCount;
|
||||
// }
|
||||
//
|
||||
// public void setRetryCount(int retryCount) {
|
||||
// this.retryCount = retryCount;
|
||||
// }
|
||||
//}
|
||||
@@ -1,27 +0,0 @@
|
||||
//package com.vernu.sms.database.local;
|
||||
//
|
||||
//import androidx.room.Dao;
|
||||
//import androidx.room.Delete;
|
||||
//import androidx.room.Insert;
|
||||
//import androidx.room.OnConflictStrategy;
|
||||
//import androidx.room.Query;
|
||||
//
|
||||
//import java.util.List;
|
||||
//
|
||||
//@Dao
|
||||
//public interface SMSDao {
|
||||
//
|
||||
// @Query("SELECT * FROM sms")
|
||||
// List<SMS> getAll();
|
||||
//
|
||||
// @Query("SELECT * FROM sms WHERE id IN (:smsIds)")
|
||||
// List<SMS> loadAllByIds(int[] smsIds);
|
||||
//
|
||||
// @Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
// void insertAll(SMS... sms);
|
||||
//
|
||||
//
|
||||
// @Delete
|
||||
// void delete(SMS sms);
|
||||
//
|
||||
//}
|
||||
@@ -0,0 +1,29 @@
|
||||
package com.vernu.sms.database.local
|
||||
|
||||
/*
|
||||
import androidx.annotation.NonNull
|
||||
import androidx.room.*
|
||||
import java.util.Date
|
||||
|
||||
@Entity(tableName = "sms")
|
||||
@TypeConverters(DateConverter::class)
|
||||
data class Sms(
|
||||
@PrimaryKey(autoGenerate = true) val id: Int = 0,
|
||||
@ColumnInfo(name = "_id") val serverId: String? = null,
|
||||
@ColumnInfo(name = "message") val message: String = "",
|
||||
@ColumnInfo(name = "encrypted_message") val encryptedMessage: String = "",
|
||||
@ColumnInfo(name = "is_encrypted", defaultValue = "0") val isEncrypted: Boolean = false,
|
||||
@ColumnInfo(name = "sender") val sender: String? = null,
|
||||
@ColumnInfo(name = "recipient") val recipient: String? = null,
|
||||
@ColumnInfo(name = "requested_at") val requestedAt: Date? = null,
|
||||
@ColumnInfo(name = "sent_at") val sentAt: Date? = null,
|
||||
@ColumnInfo(name = "delivered_at") val deliveredAt: Date? = null,
|
||||
@ColumnInfo(name = "received_at") val receivedAt: Date? = null,
|
||||
@ColumnInfo(name = "type") @field:NonNull val type: String = "",
|
||||
@ColumnInfo(name = "server_acknowledged_at") val serverAcknowledgedAt: Date? = null,
|
||||
@ColumnInfo(name = "last_acknowledged_request_at") val lastAcknowledgedRequestAt: Date? = null,
|
||||
@ColumnInfo(name = "retry_count", defaultValue = "0") val retryCount: Int = 0
|
||||
) {
|
||||
fun hasServerAcknowledged(): Boolean = serverAcknowledgedAt != null
|
||||
}
|
||||
*/
|
||||
@@ -0,0 +1,20 @@
|
||||
package com.vernu.sms.database.local
|
||||
|
||||
/*
|
||||
import androidx.room.*
|
||||
|
||||
@Dao
|
||||
interface SmsDao {
|
||||
@Query("SELECT * FROM sms")
|
||||
suspend fun getAll(): List<Sms>
|
||||
|
||||
@Query("SELECT * FROM sms WHERE id IN (:smsIds)")
|
||||
suspend fun loadAllByIds(smsIds: IntArray): List<Sms>
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
suspend fun insertAll(vararg sms: Sms)
|
||||
|
||||
@Delete
|
||||
suspend fun delete(sms: Sms)
|
||||
}
|
||||
*/
|
||||
@@ -0,0 +1,14 @@
|
||||
package com.vernu.sms.dtos
|
||||
|
||||
import com.google.gson.annotations.SerializedName
|
||||
|
||||
data class GatewayStatsResponse(
|
||||
@SerializedName("data") val data: GatewayStatsData? = null
|
||||
)
|
||||
|
||||
data class GatewayStatsData(
|
||||
@SerializedName("totalSentSMSCount") val totalSentSMSCount: Int? = null,
|
||||
@SerializedName("totalReceivedSMSCount") val totalReceivedSMSCount: Int? = null,
|
||||
@SerializedName("totalDeviceCount") val totalDeviceCount: Int? = null,
|
||||
@SerializedName("totalApiKeyCount") val totalApiKeyCount: Int? = null
|
||||
)
|
||||
@@ -0,0 +1,21 @@
|
||||
package com.vernu.sms.dtos
|
||||
|
||||
class HeartbeatInputDTO {
|
||||
var fcmToken: String? = null
|
||||
var batteryPercentage: Int? = null
|
||||
var isCharging: Boolean? = null
|
||||
var networkType: String? = null
|
||||
var appVersionName: String? = null
|
||||
var appVersionCode: Int? = null
|
||||
var deviceUptimeMillis: Long? = null
|
||||
var memoryFreeBytes: Long? = null
|
||||
var memoryTotalBytes: Long? = null
|
||||
var memoryMaxBytes: Long? = null
|
||||
var storageAvailableBytes: Long? = null
|
||||
var storageTotalBytes: Long? = null
|
||||
var timezone: String? = null
|
||||
var locale: String? = null
|
||||
var receiveSMSEnabled: Boolean? = null
|
||||
var smsSendDelaySeconds: Int? = null
|
||||
var simInfo: SimInfoCollectionDTO? = null
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package com.vernu.sms.dtos
|
||||
|
||||
class HeartbeatResponseDTO {
|
||||
@JvmField var success: Boolean = false
|
||||
@JvmField var fcmTokenUpdated: Boolean = false
|
||||
@JvmField var lastHeartbeat: Long = 0
|
||||
@JvmField var name: String? = null
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package com.vernu.sms.dtos
|
||||
|
||||
import com.google.gson.annotations.SerializedName
|
||||
|
||||
data class MessagesResponse(
|
||||
@SerializedName("data") val data: List<SmsMessage>? = null,
|
||||
@SerializedName("meta") val meta: PaginationMeta? = null
|
||||
)
|
||||
|
||||
data class SmsMessage(
|
||||
@SerializedName("_id") val id: String? = null,
|
||||
@SerializedName("message") val message: String? = null,
|
||||
@SerializedName("sender") val sender: String? = null,
|
||||
@SerializedName("recipient") val recipient: String? = null,
|
||||
@SerializedName("recipients") val recipients: List<String>? = null,
|
||||
@SerializedName("requestedAt") val requestedAt: String? = null,
|
||||
@SerializedName("receivedAt") val receivedAt: String? = null,
|
||||
@SerializedName("createdAt") val createdAt: String? = null,
|
||||
@SerializedName("status") val status: String? = null,
|
||||
@SerializedName("errorCode") val errorCode: String? = null,
|
||||
@SerializedName("errorMessage") val errorMessage: String? = null
|
||||
) {
|
||||
val isReceived: Boolean get() = sender != null
|
||||
val counterparty: String get() = if (isReceived) sender ?: "Unknown"
|
||||
else recipient ?: recipients?.firstOrNull() ?: "Unknown"
|
||||
}
|
||||
|
||||
data class PaginationMeta(
|
||||
@SerializedName("page") val page: Int? = null,
|
||||
@SerializedName("limit") val limit: Int? = null,
|
||||
@SerializedName("total") val total: Int? = null,
|
||||
@SerializedName("totalPages") val totalPages: Int? = null
|
||||
)
|
||||
|
||||
data class SendSmsRequest(
|
||||
@SerializedName("message") val message: String,
|
||||
@SerializedName("recipients") val recipients: List<String>
|
||||
)
|
||||
@@ -1,110 +0,0 @@
|
||||
package com.vernu.sms.dtos;
|
||||
|
||||
public class RegisterDeviceInputDTO {
|
||||
private String fcmToken;
|
||||
private Boolean enabled;
|
||||
private String brand;
|
||||
private String manufacturer;
|
||||
private String model;
|
||||
private String serial;
|
||||
private String buildId;
|
||||
private String os;
|
||||
private String osVersion;
|
||||
private String appVersionName;
|
||||
private int appVersionCode;
|
||||
|
||||
public RegisterDeviceInputDTO() {
|
||||
}
|
||||
|
||||
public RegisterDeviceInputDTO(String fcmToken) {
|
||||
this.fcmToken = fcmToken;
|
||||
}
|
||||
|
||||
public String getFcmToken() {
|
||||
return fcmToken;
|
||||
}
|
||||
|
||||
public void setFcmToken(String fcmToken) {
|
||||
this.fcmToken = fcmToken;
|
||||
}
|
||||
|
||||
public Boolean isEnabled() {
|
||||
return enabled;
|
||||
}
|
||||
|
||||
public void setEnabled(Boolean enabled) {
|
||||
this.enabled = enabled;
|
||||
}
|
||||
|
||||
public String getBrand() {
|
||||
return brand;
|
||||
}
|
||||
|
||||
public void setBrand(String brand) {
|
||||
this.brand = brand;
|
||||
}
|
||||
|
||||
public String getManufacturer() {
|
||||
return manufacturer;
|
||||
}
|
||||
|
||||
public void setManufacturer(String manufacturer) {
|
||||
this.manufacturer = manufacturer;
|
||||
}
|
||||
|
||||
public String getModel() {
|
||||
return model;
|
||||
}
|
||||
|
||||
public void setModel(String model) {
|
||||
this.model = model;
|
||||
}
|
||||
|
||||
public String getSerial() {
|
||||
return serial;
|
||||
}
|
||||
|
||||
public void setSerial(String serial) {
|
||||
this.serial = serial;
|
||||
}
|
||||
|
||||
public String getBuildId() {
|
||||
return buildId;
|
||||
}
|
||||
|
||||
public void setBuildId(String buildId) {
|
||||
this.buildId = buildId;
|
||||
}
|
||||
|
||||
public String getOs() {
|
||||
return os;
|
||||
}
|
||||
|
||||
public void setOs(String os) {
|
||||
this.os = os;
|
||||
}
|
||||
|
||||
public String getOsVersion() {
|
||||
return osVersion;
|
||||
}
|
||||
|
||||
public void setOsVersion(String osVersion) {
|
||||
this.osVersion = osVersion;
|
||||
}
|
||||
|
||||
public String getAppVersionName() {
|
||||
return appVersionName;
|
||||
}
|
||||
|
||||
public void setAppVersionName(String appVersionName) {
|
||||
this.appVersionName = appVersionName;
|
||||
}
|
||||
|
||||
public int getAppVersionCode() {
|
||||
return appVersionCode;
|
||||
}
|
||||
|
||||
public void setAppVersionCode(int appVersionCode) {
|
||||
this.appVersionCode = appVersionCode;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package com.vernu.sms.dtos
|
||||
|
||||
class RegisterDeviceInputDTO {
|
||||
var fcmToken: String? = null
|
||||
@get:JvmName("isEnabled") var enabled: Boolean? = null
|
||||
var brand: String? = null
|
||||
var manufacturer: String? = null
|
||||
var model: String? = null
|
||||
var name: String? = null
|
||||
var serial: String? = null
|
||||
var buildId: String? = null
|
||||
var os: String? = null
|
||||
var osVersion: String? = null
|
||||
var appVersionName: String? = null
|
||||
var appVersionCode: Int = 0
|
||||
var simInfo: SimInfoCollectionDTO? = null
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
package com.vernu.sms.dtos;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
public class RegisterDeviceResponseDTO {
|
||||
public boolean success;
|
||||
public Map<String, Object> data;
|
||||
public String error;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package com.vernu.sms.dtos
|
||||
|
||||
class RegisterDeviceResponseDTO {
|
||||
@JvmField var success: Boolean = false
|
||||
@JvmField var data: Map<String, Any?>? = null
|
||||
@JvmField var error: String? = null
|
||||
}
|
||||
@@ -1,110 +0,0 @@
|
||||
package com.vernu.sms.dtos;
|
||||
|
||||
import java.util.Date;
|
||||
|
||||
public class SMSDTO {
|
||||
private String sender;
|
||||
private String message = "";
|
||||
private long receivedAtInMillis;
|
||||
|
||||
private String smsId;
|
||||
private String smsBatchId;
|
||||
private String status;
|
||||
private long sentAtInMillis;
|
||||
private long deliveredAtInMillis;
|
||||
private long failedAtInMillis;
|
||||
private String errorCode;
|
||||
private String errorMessage;
|
||||
|
||||
public SMSDTO() {
|
||||
}
|
||||
|
||||
|
||||
public String getSender() {
|
||||
return sender;
|
||||
}
|
||||
|
||||
public void setSender(String sender) {
|
||||
this.sender = sender;
|
||||
}
|
||||
|
||||
public String getMessage() {
|
||||
return message;
|
||||
}
|
||||
|
||||
public void setMessage(String message) {
|
||||
this.message = message;
|
||||
}
|
||||
|
||||
public long getReceivedAtInMillis() {
|
||||
return receivedAtInMillis;
|
||||
}
|
||||
|
||||
public void setReceivedAtInMillis(long receivedAtInMillis) {
|
||||
this.receivedAtInMillis = receivedAtInMillis;
|
||||
}
|
||||
|
||||
public String getSmsId() {
|
||||
return smsId;
|
||||
}
|
||||
|
||||
public void setSmsId(String smsId) {
|
||||
this.smsId = smsId;
|
||||
}
|
||||
|
||||
public String getSmsBatchId() {
|
||||
return smsBatchId;
|
||||
}
|
||||
|
||||
public void setSmsBatchId(String smsBatchId) {
|
||||
this.smsBatchId = smsBatchId;
|
||||
}
|
||||
|
||||
public String getStatus() {
|
||||
return status;
|
||||
}
|
||||
|
||||
public void setStatus(String status) {
|
||||
this.status = status;
|
||||
}
|
||||
|
||||
public long getSentAtInMillis() {
|
||||
return sentAtInMillis;
|
||||
}
|
||||
|
||||
public void setSentAtInMillis(long sentAtInMillis) {
|
||||
this.sentAtInMillis = sentAtInMillis;
|
||||
}
|
||||
|
||||
public long getDeliveredAtInMillis() {
|
||||
return deliveredAtInMillis;
|
||||
}
|
||||
|
||||
public void setDeliveredAtInMillis(long deliveredAtInMillis) {
|
||||
this.deliveredAtInMillis = deliveredAtInMillis;
|
||||
}
|
||||
|
||||
public long getFailedAtInMillis() {
|
||||
return failedAtInMillis;
|
||||
}
|
||||
|
||||
public void setFailedAtInMillis(long failedAtInMillis) {
|
||||
this.failedAtInMillis = failedAtInMillis;
|
||||
}
|
||||
|
||||
public String getErrorCode() {
|
||||
return errorCode;
|
||||
}
|
||||
|
||||
public void setErrorCode(String errorCode) {
|
||||
this.errorCode = errorCode;
|
||||
}
|
||||
|
||||
public String getErrorMessage() {
|
||||
return errorMessage;
|
||||
}
|
||||
|
||||
public void setErrorMessage(String errorMessage) {
|
||||
this.errorMessage = errorMessage;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package com.vernu.sms.dtos
|
||||
|
||||
class SMSDTO {
|
||||
var sender: String? = null
|
||||
var message: String = ""
|
||||
var receivedAtInMillis: Long = 0
|
||||
var fingerprint: String? = null
|
||||
var smsId: String? = null
|
||||
var smsBatchId: String? = null
|
||||
var status: String? = null
|
||||
var sentAtInMillis: Long = 0
|
||||
var deliveredAtInMillis: Long = 0
|
||||
var failedAtInMillis: Long = 0
|
||||
var errorCode: String? = null
|
||||
var errorMessage: String? = null
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
package com.vernu.sms.dtos;
|
||||
|
||||
public class SMSForwardResponseDTO {
|
||||
|
||||
public SMSForwardResponseDTO() {
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
package com.vernu.sms.dtos
|
||||
|
||||
class SMSForwardResponseDTO
|
||||
@@ -0,0 +1,6 @@
|
||||
package com.vernu.sms.dtos
|
||||
|
||||
class SimInfoCollectionDTO {
|
||||
var lastUpdated: Long = 0
|
||||
var sims: MutableList<SimInfoDTO>? = null
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package com.vernu.sms.dtos
|
||||
|
||||
class SimInfoDTO {
|
||||
var subscriptionId: Int = 0
|
||||
var iccId: String? = null
|
||||
var cardId: Int? = null
|
||||
var carrierName: String? = null
|
||||
var displayName: String? = null
|
||||
var simSlotIndex: Int? = null
|
||||
var mcc: String? = null
|
||||
var mnc: String? = null
|
||||
var countryIso: String? = null
|
||||
var subscriptionType: String? = null
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package com.vernu.sms.dtos
|
||||
|
||||
import com.google.gson.annotations.SerializedName
|
||||
|
||||
data class SubscriptionResponse(
|
||||
@SerializedName("plan") val plan: SubscriptionPlan? = null,
|
||||
@SerializedName("currentPeriodStart") val currentPeriodStart: String? = null,
|
||||
@SerializedName("currentPeriodEnd") val currentPeriodEnd: String? = null,
|
||||
@SerializedName("isActive") val isActive: Boolean? = null,
|
||||
@SerializedName("usage") val usage: SubscriptionUsage? = null
|
||||
)
|
||||
|
||||
data class SubscriptionPlan(
|
||||
@SerializedName("name") val name: String? = null,
|
||||
@SerializedName("displayName") val displayName: String? = null
|
||||
)
|
||||
|
||||
data class SubscriptionUsage(
|
||||
@SerializedName("processedSmsToday") val processedSmsToday: Int? = null,
|
||||
@SerializedName("processedSmsLastMonth") val processedSmsLastMonth: Int? = null,
|
||||
@SerializedName("dailyLimit") val dailyLimit: Int? = null,
|
||||
@SerializedName("monthlyLimit") val monthlyLimit: Int? = null,
|
||||
@SerializedName("dailyRemaining") val dailyRemaining: Int? = null,
|
||||
@SerializedName("monthlyRemaining") val monthlyRemaining: Int? = null,
|
||||
@SerializedName("dailyUsagePercentage") val dailyUsagePercentage: Int? = null,
|
||||
@SerializedName("monthlyUsagePercentage") val monthlyUsagePercentage: Int? = null
|
||||
)
|
||||
@@ -0,0 +1,12 @@
|
||||
package com.vernu.sms.dtos
|
||||
|
||||
import com.google.gson.annotations.SerializedName
|
||||
|
||||
data class UserProfileWrapper(
|
||||
@SerializedName("data") val data: UserProfile? = null
|
||||
)
|
||||
|
||||
data class UserProfile(
|
||||
@SerializedName("name") val name: String? = null,
|
||||
@SerializedName("email") val email: String? = null
|
||||
)
|
||||
@@ -0,0 +1,17 @@
|
||||
package com.vernu.sms.helpers
|
||||
|
||||
import org.json.JSONObject
|
||||
import retrofit2.Response
|
||||
|
||||
/**
|
||||
* Extracts the human-readable `message` field the API returns in error bodies
|
||||
* (e.g. the device-limit 429), falling back to null when it can't be parsed.
|
||||
*/
|
||||
fun Response<*>.serverErrorMessage(): String? {
|
||||
val raw = errorBody()?.string()?.takeIf { it.isNotBlank() } ?: return null
|
||||
return try {
|
||||
JSONObject(raw).optString("message").takeIf { it.isNotBlank() }
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
package com.vernu.sms.helpers
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.IntentFilter
|
||||
import android.net.ConnectivityManager
|
||||
import android.os.BatteryManager
|
||||
import android.os.StatFs
|
||||
import android.os.SystemClock
|
||||
import android.util.Log
|
||||
import com.google.firebase.messaging.FirebaseMessaging
|
||||
import com.vernu.sms.ApiManager
|
||||
import com.vernu.sms.AppConstants
|
||||
import com.vernu.sms.BuildConfig
|
||||
import com.vernu.sms.TextBeeUtils
|
||||
import com.vernu.sms.dtos.HeartbeatInputDTO
|
||||
import com.vernu.sms.dtos.SimInfoCollectionDTO
|
||||
import java.io.IOException
|
||||
import java.util.Locale
|
||||
import java.util.TimeZone
|
||||
import java.util.concurrent.CountDownLatch
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
object HeartbeatHelper {
|
||||
private const val TAG = "HeartbeatHelper"
|
||||
|
||||
@JvmStatic
|
||||
fun sendHeartbeat(context: Context, deviceId: String, apiKey: String): Boolean {
|
||||
if (deviceId.isEmpty()) {
|
||||
Log.d(TAG, "Device not registered, skipping heartbeat")
|
||||
return false
|
||||
}
|
||||
if (apiKey.isEmpty()) {
|
||||
Log.e(TAG, "API key not available, skipping heartbeat")
|
||||
return false
|
||||
}
|
||||
|
||||
val heartbeatInput = HeartbeatInputDTO()
|
||||
|
||||
return try {
|
||||
// FCM token (blocking wait up to 5 seconds)
|
||||
try {
|
||||
val latch = CountDownLatch(1)
|
||||
var fcmTokenResult: String? = null
|
||||
FirebaseMessaging.getInstance().token.addOnCompleteListener { task ->
|
||||
if (task.isSuccessful) fcmTokenResult = task.result
|
||||
latch.countDown()
|
||||
}
|
||||
if (latch.await(5, TimeUnit.SECONDS) && fcmTokenResult != null) {
|
||||
heartbeatInput.fcmToken = fcmTokenResult
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to get FCM token: ${e.message}")
|
||||
}
|
||||
|
||||
// Battery info
|
||||
val batteryStatus = context.registerReceiver(null, IntentFilter(Intent.ACTION_BATTERY_CHANGED))
|
||||
if (batteryStatus != null) {
|
||||
val level = batteryStatus.getIntExtra(BatteryManager.EXTRA_LEVEL, -1)
|
||||
val scale = batteryStatus.getIntExtra(BatteryManager.EXTRA_SCALE, -1)
|
||||
heartbeatInput.batteryPercentage = ((level / scale.toFloat()) * 100).toInt()
|
||||
val status = batteryStatus.getIntExtra(BatteryManager.EXTRA_STATUS, -1)
|
||||
heartbeatInput.isCharging = status == BatteryManager.BATTERY_STATUS_CHARGING ||
|
||||
status == BatteryManager.BATTERY_STATUS_FULL
|
||||
}
|
||||
|
||||
// Network type
|
||||
@Suppress("DEPRECATION")
|
||||
val cm = context.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager
|
||||
@Suppress("DEPRECATION")
|
||||
val activeNetwork = cm?.activeNetworkInfo
|
||||
@Suppress("DEPRECATION")
|
||||
heartbeatInput.networkType = when {
|
||||
activeNetwork?.isConnected == true && activeNetwork.type == ConnectivityManager.TYPE_WIFI -> "wifi"
|
||||
activeNetwork?.isConnected == true && activeNetwork.type == ConnectivityManager.TYPE_MOBILE -> "cellular"
|
||||
else -> "none"
|
||||
}
|
||||
|
||||
// App version
|
||||
heartbeatInput.appVersionName = BuildConfig.VERSION_NAME
|
||||
heartbeatInput.appVersionCode = BuildConfig.VERSION_CODE
|
||||
|
||||
// Device uptime
|
||||
heartbeatInput.deviceUptimeMillis = SystemClock.uptimeMillis()
|
||||
|
||||
// Memory
|
||||
val runtime = Runtime.getRuntime()
|
||||
heartbeatInput.memoryFreeBytes = runtime.freeMemory()
|
||||
heartbeatInput.memoryTotalBytes = runtime.totalMemory()
|
||||
heartbeatInput.memoryMaxBytes = runtime.maxMemory()
|
||||
|
||||
// Storage
|
||||
val statFs = StatFs(context.filesDir.path)
|
||||
heartbeatInput.storageAvailableBytes = statFs.availableBytes
|
||||
heartbeatInput.storageTotalBytes = statFs.totalBytes
|
||||
|
||||
// Locale / timezone
|
||||
heartbeatInput.timezone = TimeZone.getDefault().id
|
||||
heartbeatInput.locale = Locale.getDefault().toString()
|
||||
|
||||
// Preferences
|
||||
heartbeatInput.receiveSMSEnabled = SharedPreferenceHelper.getSharedPreferenceBoolean(
|
||||
context, AppConstants.SHARED_PREFS_RECEIVE_SMS_ENABLED_KEY, false
|
||||
)
|
||||
heartbeatInput.smsSendDelaySeconds = SharedPreferenceHelper.getSharedPreferenceInt(
|
||||
context, AppConstants.SHARED_PREFS_SMS_SEND_DELAY_SECONDS_KEY,
|
||||
AppConstants.DEFAULT_SMS_SEND_DELAY_SECONDS
|
||||
)
|
||||
|
||||
// SIM info
|
||||
heartbeatInput.simInfo = SimInfoCollectionDTO().apply {
|
||||
lastUpdated = System.currentTimeMillis()
|
||||
sims = TextBeeUtils.collectSimInfo(context)
|
||||
}
|
||||
|
||||
// Send heartbeat (blocking)
|
||||
val response = ApiManager.getApiService().heartbeat(deviceId, apiKey, heartbeatInput).execute()
|
||||
if (response.isSuccessful && response.body() != null) {
|
||||
val body = response.body()!!
|
||||
if (body.fcmTokenUpdated) Log.d(TAG, "FCM token was updated during heartbeat")
|
||||
if (!body.name.isNullOrBlank()) {
|
||||
SharedPreferenceHelper.setSharedPreferenceString(
|
||||
context, AppConstants.SHARED_PREFS_DEVICE_NAME_KEY, body.name!!
|
||||
)
|
||||
Log.d(TAG, "Synced device name from heartbeat: ${body.name}")
|
||||
}
|
||||
SharedPreferenceHelper.setSharedPreferenceString(
|
||||
context, AppConstants.SHARED_PREFS_LAST_HEARTBEAT_MS_KEY,
|
||||
System.currentTimeMillis().toString()
|
||||
)
|
||||
Log.d(TAG, "Heartbeat sent successfully")
|
||||
true
|
||||
} else {
|
||||
Log.e(TAG, "Failed to send heartbeat. Response code: ${response.code()}")
|
||||
false
|
||||
}
|
||||
} catch (e: IOException) {
|
||||
Log.e(TAG, "Heartbeat API call failed: ${e.message}")
|
||||
false
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error collecting device information: ${e.message}")
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun isDeviceEligibleForHeartbeat(context: Context): Boolean {
|
||||
val deviceId = SharedPreferenceHelper.getSharedPreferenceString(
|
||||
context, AppConstants.SHARED_PREFS_DEVICE_ID_KEY, ""
|
||||
) ?: ""
|
||||
if (deviceId.isEmpty()) return false
|
||||
|
||||
val deviceEnabled = SharedPreferenceHelper.getSharedPreferenceBoolean(
|
||||
context, AppConstants.SHARED_PREFS_GATEWAY_ENABLED_KEY, false
|
||||
)
|
||||
if (!deviceEnabled) return false
|
||||
|
||||
return SharedPreferenceHelper.getSharedPreferenceBoolean(
|
||||
context, AppConstants.SHARED_PREFS_HEARTBEAT_ENABLED_KEY, true
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
package com.vernu.sms.helpers
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import androidx.work.*
|
||||
import com.vernu.sms.AppConstants
|
||||
import com.vernu.sms.workers.HeartbeatWorker
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
object HeartbeatManager {
|
||||
private const val TAG = "HeartbeatManager"
|
||||
private const val MIN_INTERVAL_MINUTES = 15
|
||||
private const val UNIQUE_WORK_NAME = "heartbeat_unique_work"
|
||||
|
||||
@JvmStatic
|
||||
fun scheduleHeartbeat(context: Context) {
|
||||
val appContext = context.applicationContext
|
||||
var intervalMinutes = SharedPreferenceHelper.getSharedPreferenceInt(
|
||||
appContext, AppConstants.SHARED_PREFS_HEARTBEAT_INTERVAL_MINUTES_KEY, 30
|
||||
)
|
||||
if (intervalMinutes < MIN_INTERVAL_MINUTES) {
|
||||
Log.w(TAG, "Interval $intervalMinutes minutes is less than minimum $MIN_INTERVAL_MINUTES minutes, using minimum")
|
||||
intervalMinutes = MIN_INTERVAL_MINUTES
|
||||
}
|
||||
Log.d(TAG, "Scheduling heartbeat with interval: $intervalMinutes minutes")
|
||||
|
||||
val constraints = Constraints.Builder()
|
||||
.setRequiredNetworkType(NetworkType.CONNECTED)
|
||||
.build()
|
||||
|
||||
val heartbeatWork = PeriodicWorkRequest.Builder(
|
||||
HeartbeatWorker::class.java,
|
||||
intervalMinutes.toLong(),
|
||||
TimeUnit.MINUTES
|
||||
)
|
||||
.setConstraints(constraints)
|
||||
.addTag(AppConstants.HEARTBEAT_WORK_TAG)
|
||||
.build()
|
||||
|
||||
WorkManager.getInstance(appContext)
|
||||
.enqueueUniquePeriodicWork(
|
||||
UNIQUE_WORK_NAME,
|
||||
ExistingPeriodicWorkPolicy.REPLACE,
|
||||
heartbeatWork
|
||||
)
|
||||
Log.d(TAG, "Heartbeat scheduled successfully with unique work name: $UNIQUE_WORK_NAME")
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun cancelHeartbeat(context: Context) {
|
||||
Log.d(TAG, "Cancelling heartbeat work")
|
||||
val appContext = context.applicationContext
|
||||
WorkManager.getInstance(appContext).cancelUniqueWork(UNIQUE_WORK_NAME)
|
||||
WorkManager.getInstance(appContext).cancelAllWorkByTag(AppConstants.HEARTBEAT_WORK_TAG)
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun triggerHeartbeat(context: Context) {
|
||||
Log.d(TAG, "Triggering immediate heartbeat")
|
||||
scheduleHeartbeat(context)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
package com.vernu.sms.helpers
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import com.google.gson.Gson
|
||||
import com.google.gson.reflect.TypeToken
|
||||
import com.vernu.sms.AppConstants
|
||||
import com.vernu.sms.models.SMSFilterRule
|
||||
|
||||
object SMSFilterHelper {
|
||||
private const val TAG = "SMSFilterHelper"
|
||||
|
||||
enum class FilterMode { ALLOW_LIST, BLOCK_LIST }
|
||||
|
||||
class FilterConfig {
|
||||
@get:JvmName("isEnabled") var enabled: Boolean = false
|
||||
var mode: FilterMode = FilterMode.BLOCK_LIST
|
||||
var rules: MutableList<SMSFilterRule> = mutableListOf()
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun loadFilterConfig(context: Context): FilterConfig {
|
||||
val json = SharedPreferenceHelper.getSharedPreferenceString(
|
||||
context, AppConstants.SHARED_PREFS_SMS_FILTER_CONFIG_KEY, null
|
||||
)
|
||||
if (json.isNullOrEmpty()) return FilterConfig()
|
||||
return try {
|
||||
val type = object : TypeToken<FilterConfig>() {}.type
|
||||
Gson().fromJson<FilterConfig>(json, type) ?: FilterConfig()
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error loading filter config: ${e.message}")
|
||||
FilterConfig()
|
||||
}
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun saveFilterConfig(context: Context, config: FilterConfig) {
|
||||
try {
|
||||
SharedPreferenceHelper.setSharedPreferenceString(
|
||||
context,
|
||||
AppConstants.SHARED_PREFS_SMS_FILTER_CONFIG_KEY,
|
||||
Gson().toJson(config)
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error saving filter config: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun shouldProcessSMS(sender: String?, message: String?, context: Context): Boolean {
|
||||
val config = loadFilterConfig(context)
|
||||
if (!config.enabled) return true
|
||||
if (config.rules.isEmpty()) return true
|
||||
val matchesAnyRule = config.rules.any { it.matches(sender, message) }
|
||||
return if (config.mode == FilterMode.ALLOW_LIST) matchesAnyRule else !matchesAnyRule
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun shouldProcessSMS(sender: String?, context: Context): Boolean =
|
||||
shouldProcessSMS(sender, null, context)
|
||||
}
|
||||
@@ -1,236 +0,0 @@
|
||||
package com.vernu.sms.helpers;
|
||||
|
||||
import android.Manifest;
|
||||
import android.app.PendingIntent;
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.telephony.SmsManager;
|
||||
import android.os.Build;
|
||||
import android.telephony.SubscriptionManager;
|
||||
import android.util.Log;
|
||||
|
||||
import com.vernu.sms.ApiManager;
|
||||
import com.vernu.sms.AppConstants;
|
||||
import com.vernu.sms.TextBeeUtils;
|
||||
import com.vernu.sms.dtos.SMSDTO;
|
||||
import com.vernu.sms.dtos.SMSForwardResponseDTO;
|
||||
import com.vernu.sms.receivers.SMSStatusReceiver;
|
||||
import com.vernu.sms.services.GatewayApiService;
|
||||
|
||||
import java.util.ArrayList;
|
||||
|
||||
import retrofit2.Call;
|
||||
import retrofit2.Callback;
|
||||
import retrofit2.Response;
|
||||
|
||||
public class SMSHelper {
|
||||
private static final String TAG = "SMSHelper";
|
||||
|
||||
/**
|
||||
* Sends an SMS message and returns whether the operation was successful
|
||||
*
|
||||
* @param phoneNo The recipient's phone number
|
||||
* @param message The SMS message to send
|
||||
* @param smsId The unique ID for this SMS
|
||||
* @param smsBatchId The batch ID for this SMS
|
||||
* @param context The application context
|
||||
* @return boolean True if sending was initiated, false if permissions aren't granted
|
||||
*/
|
||||
public static boolean sendSMS(String phoneNo, String message, String smsId, String smsBatchId, Context context) {
|
||||
// Check if we have permission to send SMS
|
||||
if (!TextBeeUtils.isPermissionGranted(context, Manifest.permission.SEND_SMS)) {
|
||||
Log.e(TAG, "SMS permission not granted. Unable to send SMS.");
|
||||
|
||||
// Report failure to API
|
||||
reportPermissionError(context, smsId, smsBatchId);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
SmsManager smsManager = SmsManager.getDefault();
|
||||
|
||||
// Create pending intents for status tracking
|
||||
PendingIntent sentIntent = createSentPendingIntent(context, smsId, smsBatchId);
|
||||
PendingIntent deliveredIntent = createDeliveredPendingIntent(context, smsId, smsBatchId);
|
||||
|
||||
// For SMS with more than 160 chars
|
||||
ArrayList<String> parts = smsManager.divideMessage(message);
|
||||
if (parts.size() > 1) {
|
||||
ArrayList<PendingIntent> sentIntents = new ArrayList<>();
|
||||
ArrayList<PendingIntent> deliveredIntents = new ArrayList<>();
|
||||
|
||||
for (int i = 0; i < parts.size(); i++) {
|
||||
sentIntents.add(sentIntent);
|
||||
deliveredIntents.add(deliveredIntent);
|
||||
}
|
||||
|
||||
smsManager.sendMultipartTextMessage(phoneNo, null, parts, sentIntents, deliveredIntents);
|
||||
} else {
|
||||
smsManager.sendTextMessage(phoneNo, null, message, sentIntent, deliveredIntent);
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (Exception e) {
|
||||
Log.e(TAG, "Exception when sending SMS: " + e.getMessage());
|
||||
|
||||
// Report exception to API
|
||||
reportSendingError(context, smsId, smsBatchId, e.getMessage());
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends an SMS message from a specific SIM slot and returns whether the operation was successful
|
||||
*
|
||||
* @param phoneNo The recipient's phone number
|
||||
* @param message The SMS message to send
|
||||
* @param simSubscriptionId The specific SIM subscription ID to use
|
||||
* @param smsId The unique ID for this SMS
|
||||
* @param smsBatchId The batch ID for this SMS
|
||||
* @param context The application context
|
||||
* @return boolean True if sending was initiated, false if permissions aren't granted
|
||||
*/
|
||||
public static boolean sendSMSFromSpecificSim(String phoneNo, String message, int simSubscriptionId,
|
||||
String smsId, String smsBatchId, Context context) {
|
||||
// Check for required permissions
|
||||
if (!TextBeeUtils.isPermissionGranted(context, Manifest.permission.SEND_SMS) ||
|
||||
!TextBeeUtils.isPermissionGranted(context, Manifest.permission.READ_PHONE_STATE)) {
|
||||
Log.e(TAG, "SMS or Phone State permission not granted. Unable to send SMS from specific SIM.");
|
||||
|
||||
// Report failure to API
|
||||
reportPermissionError(context, smsId, smsBatchId);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
// Get the SmsManager for the specific SIM
|
||||
SmsManager smsManager;
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP_MR1) {
|
||||
smsManager = SmsManager.getSmsManagerForSubscriptionId(simSubscriptionId);
|
||||
} else {
|
||||
// Fallback to default SmsManager for older Android versions
|
||||
smsManager = SmsManager.getDefault();
|
||||
Log.w(TAG, "Using default SIM as specific SIM selection not supported on this Android version");
|
||||
}
|
||||
|
||||
// Create pending intents for status tracking
|
||||
PendingIntent sentIntent = createSentPendingIntent(context, smsId, smsBatchId);
|
||||
PendingIntent deliveredIntent = createDeliveredPendingIntent(context, smsId, smsBatchId);
|
||||
|
||||
// For SMS with more than 160 chars
|
||||
ArrayList<String> parts = smsManager.divideMessage(message);
|
||||
if (parts.size() > 1) {
|
||||
ArrayList<PendingIntent> sentIntents = new ArrayList<>();
|
||||
ArrayList<PendingIntent> deliveredIntents = new ArrayList<>();
|
||||
|
||||
for (int i = 0; i < parts.size(); i++) {
|
||||
sentIntents.add(sentIntent);
|
||||
deliveredIntents.add(deliveredIntent);
|
||||
}
|
||||
|
||||
smsManager.sendMultipartTextMessage(phoneNo, null, parts, sentIntents, deliveredIntents);
|
||||
} else {
|
||||
smsManager.sendTextMessage(phoneNo, null, message, sentIntent, deliveredIntent);
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (Exception e) {
|
||||
Log.e(TAG, "Exception when sending SMS from specific SIM: " + e.getMessage());
|
||||
|
||||
// Report exception to API
|
||||
reportSendingError(context, smsId, smsBatchId, e.getMessage());
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static void reportPermissionError(Context context, String smsId, String smsBatchId) {
|
||||
SMSDTO smsDTO = new SMSDTO();
|
||||
smsDTO.setSmsId(smsId);
|
||||
smsDTO.setSmsBatchId(smsBatchId);
|
||||
smsDTO.setStatus("FAILED");
|
||||
smsDTO.setFailedAtInMillis(System.currentTimeMillis());
|
||||
smsDTO.setErrorCode("PERMISSION_DENIED");
|
||||
smsDTO.setErrorMessage("SMS permission not granted");
|
||||
|
||||
updateSMSStatus(context, smsDTO);
|
||||
}
|
||||
|
||||
private static void reportSendingError(Context context, String smsId, String smsBatchId, String errorMessage) {
|
||||
SMSDTO smsDTO = new SMSDTO();
|
||||
smsDTO.setSmsId(smsId);
|
||||
smsDTO.setSmsBatchId(smsBatchId);
|
||||
smsDTO.setStatus("FAILED");
|
||||
smsDTO.setFailedAtInMillis(System.currentTimeMillis());
|
||||
smsDTO.setErrorCode("SENDING_EXCEPTION");
|
||||
smsDTO.setErrorMessage(errorMessage);
|
||||
|
||||
updateSMSStatus(context, smsDTO);
|
||||
}
|
||||
|
||||
private static void updateSMSStatus(Context context, SMSDTO smsDTO) {
|
||||
String deviceId = SharedPreferenceHelper.getSharedPreferenceString(context, AppConstants.SHARED_PREFS_DEVICE_ID_KEY, "");
|
||||
String apiKey = SharedPreferenceHelper.getSharedPreferenceString(context, AppConstants.SHARED_PREFS_API_KEY_KEY, "");
|
||||
|
||||
if (deviceId.isEmpty() || apiKey.isEmpty()) {
|
||||
Log.e(TAG, "Device ID or API key not found");
|
||||
return;
|
||||
}
|
||||
|
||||
GatewayApiService apiService = ApiManager.getApiService();
|
||||
Call<SMSForwardResponseDTO> call = apiService.updateSMSStatus(deviceId, apiKey, smsDTO);
|
||||
|
||||
call.enqueue(new Callback<SMSForwardResponseDTO>() {
|
||||
@Override
|
||||
public void onResponse(Call<SMSForwardResponseDTO> call, Response<SMSForwardResponseDTO> response) {
|
||||
if (response.isSuccessful()) {
|
||||
Log.d(TAG, "SMS status updated successfully - ID: " + smsDTO.getSmsId() + ", Status: " + smsDTO.getStatus());
|
||||
} else {
|
||||
Log.e(TAG, "Failed to update SMS status. Response code: " + response.code());
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onFailure(Call<SMSForwardResponseDTO> call, Throwable t) {
|
||||
Log.e(TAG, "API call failed: " + t.getMessage());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private static PendingIntent createSentPendingIntent(Context context, String smsId, String smsBatchId) {
|
||||
// Create explicit intent (specify the component)
|
||||
Intent intent = new Intent(context, SMSStatusReceiver.class);
|
||||
intent.setAction(SMSStatusReceiver.SMS_SENT);
|
||||
intent.putExtra("sms_id", smsId);
|
||||
intent.putExtra("sms_batch_id", smsBatchId);
|
||||
|
||||
int flags = PendingIntent.FLAG_UPDATE_CURRENT;
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||
flags |= PendingIntent.FLAG_MUTABLE;
|
||||
}
|
||||
|
||||
// Use a unique request code to avoid PendingIntent collisions
|
||||
int requestCode = (smsId + "_sent").hashCode();
|
||||
return PendingIntent.getBroadcast(context, requestCode, intent, flags);
|
||||
}
|
||||
|
||||
private static PendingIntent createDeliveredPendingIntent(Context context, String smsId, String smsBatchId) {
|
||||
// Create explicit intent (specify the component)
|
||||
Intent intent = new Intent(context, SMSStatusReceiver.class);
|
||||
intent.setAction(SMSStatusReceiver.SMS_DELIVERED);
|
||||
intent.putExtra("sms_id", smsId);
|
||||
intent.putExtra("sms_batch_id", smsBatchId);
|
||||
|
||||
int flags = PendingIntent.FLAG_UPDATE_CURRENT;
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||
flags |= PendingIntent.FLAG_MUTABLE;
|
||||
}
|
||||
|
||||
// Use a unique request code to avoid PendingIntent collisions
|
||||
int requestCode = (smsId + "_delivered").hashCode();
|
||||
return PendingIntent.getBroadcast(context, requestCode, intent, flags);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
package com.vernu.sms.helpers
|
||||
|
||||
import android.Manifest
|
||||
import android.app.PendingIntent
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Build
|
||||
import android.telephony.SmsManager
|
||||
import android.util.Log
|
||||
import com.vernu.sms.AppConstants
|
||||
import com.vernu.sms.TextBeeUtils
|
||||
import com.vernu.sms.dtos.SMSDTO
|
||||
import com.vernu.sms.receivers.SMSStatusReceiver
|
||||
import com.vernu.sms.workers.SMSStatusUpdateWorker
|
||||
|
||||
object SMSHelper {
|
||||
private const val TAG = "SMSHelper"
|
||||
|
||||
@JvmStatic
|
||||
fun sendSMS(
|
||||
phoneNo: String,
|
||||
message: String,
|
||||
smsId: String,
|
||||
smsBatchId: String,
|
||||
context: Context
|
||||
): Boolean {
|
||||
if (!TextBeeUtils.isPermissionGranted(context, Manifest.permission.SEND_SMS)) {
|
||||
Log.e(TAG, "SMS permission not granted. Unable to send SMS.")
|
||||
reportPermissionError(context, smsId, smsBatchId)
|
||||
return false
|
||||
}
|
||||
return try {
|
||||
val smsManager = SmsManager.getDefault()
|
||||
val sentIntent = createSentPendingIntent(context, smsId, smsBatchId)
|
||||
val deliveredIntent = createDeliveredPendingIntent(context, smsId, smsBatchId)
|
||||
val parts = smsManager.divideMessage(message)
|
||||
if (parts.size > 1) {
|
||||
val sentIntents = ArrayList<PendingIntent>(parts.size).also { list ->
|
||||
repeat(parts.size) { list.add(sentIntent) }
|
||||
}
|
||||
val deliveredIntents = ArrayList<PendingIntent>(parts.size).also { list ->
|
||||
repeat(parts.size) { list.add(deliveredIntent) }
|
||||
}
|
||||
smsManager.sendMultipartTextMessage(phoneNo, null, parts, sentIntents, deliveredIntents)
|
||||
} else {
|
||||
smsManager.sendTextMessage(phoneNo, null, message, sentIntent, deliveredIntent)
|
||||
}
|
||||
true
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Exception when sending SMS: ${e.message}")
|
||||
reportSendingError(context, smsId, smsBatchId, e.message)
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun sendSMSFromSpecificSim(
|
||||
phoneNo: String,
|
||||
message: String,
|
||||
simSubscriptionId: Int,
|
||||
smsId: String,
|
||||
smsBatchId: String,
|
||||
context: Context
|
||||
): Boolean {
|
||||
if (!TextBeeUtils.isPermissionGranted(context, Manifest.permission.SEND_SMS) ||
|
||||
!TextBeeUtils.isPermissionGranted(context, Manifest.permission.READ_PHONE_STATE)
|
||||
) {
|
||||
Log.e(TAG, "SMS or Phone State permission not granted. Unable to send SMS from specific SIM.")
|
||||
reportPermissionError(context, smsId, smsBatchId)
|
||||
return false
|
||||
}
|
||||
return try {
|
||||
@Suppress("DEPRECATION")
|
||||
val smsManager = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP_MR1) {
|
||||
SmsManager.getSmsManagerForSubscriptionId(simSubscriptionId)
|
||||
} else {
|
||||
Log.w(TAG, "Using default SIM as specific SIM selection not supported on this Android version")
|
||||
SmsManager.getDefault()
|
||||
}
|
||||
val sentIntent = createSentPendingIntent(context, smsId, smsBatchId)
|
||||
val deliveredIntent = createDeliveredPendingIntent(context, smsId, smsBatchId)
|
||||
val parts = smsManager.divideMessage(message)
|
||||
if (parts.size > 1) {
|
||||
val sentIntents = ArrayList<PendingIntent>(parts.size).also { list ->
|
||||
repeat(parts.size) { list.add(sentIntent) }
|
||||
}
|
||||
val deliveredIntents = ArrayList<PendingIntent>(parts.size).also { list ->
|
||||
repeat(parts.size) { list.add(deliveredIntent) }
|
||||
}
|
||||
smsManager.sendMultipartTextMessage(phoneNo, null, parts, sentIntents, deliveredIntents)
|
||||
} else {
|
||||
smsManager.sendTextMessage(phoneNo, null, message, sentIntent, deliveredIntent)
|
||||
}
|
||||
true
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Exception when sending SMS from specific SIM: ${e.message}")
|
||||
reportSendingError(context, smsId, smsBatchId, e.message)
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
private fun reportPermissionError(context: Context, smsId: String, smsBatchId: String) {
|
||||
val smsDTO = SMSDTO().apply {
|
||||
this.smsId = smsId
|
||||
this.smsBatchId = smsBatchId
|
||||
status = "FAILED"
|
||||
failedAtInMillis = System.currentTimeMillis()
|
||||
errorCode = "PERMISSION_DENIED"
|
||||
errorMessage = "SMS permission not granted"
|
||||
}
|
||||
updateSMSStatus(context, smsDTO)
|
||||
}
|
||||
|
||||
private fun reportSendingError(context: Context, smsId: String, smsBatchId: String, error: String?) {
|
||||
val smsDTO = SMSDTO().apply {
|
||||
this.smsId = smsId
|
||||
this.smsBatchId = smsBatchId
|
||||
status = "FAILED"
|
||||
failedAtInMillis = System.currentTimeMillis()
|
||||
errorCode = "SENDING_EXCEPTION"
|
||||
errorMessage = error
|
||||
}
|
||||
updateSMSStatus(context, smsDTO)
|
||||
}
|
||||
|
||||
private fun updateSMSStatus(context: Context, smsDTO: SMSDTO) {
|
||||
val deviceId = SharedPreferenceHelper.getSharedPreferenceString(
|
||||
context, AppConstants.SHARED_PREFS_DEVICE_ID_KEY, ""
|
||||
) ?: ""
|
||||
val apiKey = SharedPreferenceHelper.getSharedPreferenceString(
|
||||
context, AppConstants.SHARED_PREFS_API_KEY_KEY, ""
|
||||
) ?: ""
|
||||
if (deviceId.isEmpty() || apiKey.isEmpty()) {
|
||||
Log.e(TAG, "Device ID or API key not found")
|
||||
return
|
||||
}
|
||||
SMSStatusUpdateWorker.enqueueWork(context, deviceId, apiKey, smsDTO)
|
||||
}
|
||||
|
||||
private fun createSentPendingIntent(context: Context, smsId: String, smsBatchId: String): PendingIntent {
|
||||
val intent = Intent(context, SMSStatusReceiver::class.java).apply {
|
||||
action = SMSStatusReceiver.SMS_SENT
|
||||
putExtra("sms_id", smsId)
|
||||
putExtra("sms_batch_id", smsBatchId)
|
||||
}
|
||||
var flags = PendingIntent.FLAG_UPDATE_CURRENT
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) flags = flags or PendingIntent.FLAG_MUTABLE
|
||||
return PendingIntent.getBroadcast(context, (smsId + "_sent").hashCode(), intent, flags)
|
||||
}
|
||||
|
||||
private fun createDeliveredPendingIntent(context: Context, smsId: String, smsBatchId: String): PendingIntent {
|
||||
val intent = Intent(context, SMSStatusReceiver::class.java).apply {
|
||||
action = SMSStatusReceiver.SMS_DELIVERED
|
||||
putExtra("sms_id", smsId)
|
||||
putExtra("sms_batch_id", smsBatchId)
|
||||
}
|
||||
var flags = PendingIntent.FLAG_UPDATE_CURRENT
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) flags = flags or PendingIntent.FLAG_MUTABLE
|
||||
return PendingIntent.getBroadcast(context, (smsId + "_delivered").hashCode(), intent, flags)
|
||||
}
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
package com.vernu.sms.helpers;
|
||||
|
||||
import android.content.Context;
|
||||
import android.content.SharedPreferences;
|
||||
|
||||
public class SharedPreferenceHelper {
|
||||
private final static String PREF_FILE = "PREF";
|
||||
|
||||
|
||||
public static void setSharedPreferenceString(Context context, String key, String value) {
|
||||
SharedPreferences settings = context.getSharedPreferences(PREF_FILE, 0);
|
||||
SharedPreferences.Editor editor = settings.edit();
|
||||
editor.putString(key, value);
|
||||
editor.apply();
|
||||
}
|
||||
|
||||
public static void setSharedPreferenceInt(Context context, String key, int value) {
|
||||
SharedPreferences settings = context.getSharedPreferences(PREF_FILE, 0);
|
||||
SharedPreferences.Editor editor = settings.edit();
|
||||
editor.putInt(key, value);
|
||||
editor.apply();
|
||||
}
|
||||
|
||||
public static void setSharedPreferenceBoolean(Context context, String key, boolean value) {
|
||||
SharedPreferences settings = context.getSharedPreferences(PREF_FILE, 0);
|
||||
SharedPreferences.Editor editor = settings.edit();
|
||||
editor.putBoolean(key, value);
|
||||
editor.apply();
|
||||
}
|
||||
|
||||
public static String getSharedPreferenceString(Context context, String key, String defValue) {
|
||||
SharedPreferences settings = context.getSharedPreferences(PREF_FILE, 0);
|
||||
return settings.getString(key, defValue);
|
||||
}
|
||||
|
||||
|
||||
public static int getSharedPreferenceInt(Context context, String key, int defValue) {
|
||||
SharedPreferences settings = context.getSharedPreferences(PREF_FILE, 0);
|
||||
return settings.getInt(key, defValue);
|
||||
}
|
||||
|
||||
|
||||
public static boolean getSharedPreferenceBoolean(Context context, String key, boolean defValue) {
|
||||
SharedPreferences settings = context.getSharedPreferences(PREF_FILE, 0);
|
||||
return settings.getBoolean(key, defValue);
|
||||
}
|
||||
|
||||
public static void clearSharedPreference(Context context, String key) {
|
||||
SharedPreferences settings = context.getSharedPreferences(PREF_FILE, 0);
|
||||
SharedPreferences.Editor editor = settings.edit();
|
||||
editor.remove(key);
|
||||
editor.apply();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package com.vernu.sms.helpers
|
||||
|
||||
import android.content.Context
|
||||
|
||||
object SharedPreferenceHelper {
|
||||
private const val PREF_FILE = "PREF"
|
||||
|
||||
@JvmStatic
|
||||
fun setSharedPreferenceString(context: Context, key: String, value: String) {
|
||||
context.getSharedPreferences(PREF_FILE, 0).edit().putString(key, value).apply()
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun setSharedPreferenceInt(context: Context, key: String, value: Int) {
|
||||
context.getSharedPreferences(PREF_FILE, 0).edit().putInt(key, value).apply()
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun setSharedPreferenceBoolean(context: Context, key: String, value: Boolean) {
|
||||
context.getSharedPreferences(PREF_FILE, 0).edit().putBoolean(key, value).apply()
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun getSharedPreferenceString(context: Context, key: String, defValue: String?): String? {
|
||||
return context.getSharedPreferences(PREF_FILE, 0).getString(key, defValue)
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun getSharedPreferenceInt(context: Context, key: String, defValue: Int): Int {
|
||||
return context.getSharedPreferences(PREF_FILE, 0).getInt(key, defValue)
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun getSharedPreferenceBoolean(context: Context, key: String, defValue: Boolean): Boolean {
|
||||
return context.getSharedPreferences(PREF_FILE, 0).getBoolean(key, defValue)
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun clearSharedPreference(context: Context, key: String) {
|
||||
context.getSharedPreferences(PREF_FILE, 0).edit().remove(key).apply()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package com.vernu.sms.models
|
||||
|
||||
class SMSFilterRule @JvmOverloads constructor(
|
||||
var pattern: String? = null,
|
||||
var matchType: MatchType? = null,
|
||||
var filterTarget: FilterTarget = FilterTarget.SENDER,
|
||||
@get:JvmName("isCaseSensitive") var caseSensitive: Boolean = false
|
||||
) {
|
||||
enum class MatchType { EXACT, STARTS_WITH, ENDS_WITH, CONTAINS }
|
||||
enum class FilterTarget { SENDER, MESSAGE, BOTH }
|
||||
|
||||
private fun matchesString(text: String?): Boolean {
|
||||
val p = pattern ?: return false
|
||||
val t = text ?: return false
|
||||
val pat = if (caseSensitive) p else p.lowercase()
|
||||
val txt = if (caseSensitive) t else t.lowercase()
|
||||
return when (matchType) {
|
||||
MatchType.EXACT -> txt == pat
|
||||
MatchType.STARTS_WITH -> txt.startsWith(pat)
|
||||
MatchType.ENDS_WITH -> txt.endsWith(pat)
|
||||
MatchType.CONTAINS -> txt.contains(pat)
|
||||
null -> false
|
||||
}
|
||||
}
|
||||
|
||||
fun matches(sender: String?, message: String?): Boolean {
|
||||
if (pattern == null) return false
|
||||
return when (filterTarget) {
|
||||
FilterTarget.SENDER -> matchesString(sender)
|
||||
FilterTarget.MESSAGE -> matchesString(message)
|
||||
FilterTarget.BOTH -> matchesString(sender) || matchesString(message)
|
||||
}
|
||||
}
|
||||
|
||||
fun matches(sender: String?): Boolean = matches(sender, null)
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
package com.vernu.sms.models;
|
||||
|
||||
public class SMSPayload {
|
||||
|
||||
private String[] recipients;
|
||||
private String message;
|
||||
private String smsId;
|
||||
private String smsBatchId;
|
||||
|
||||
// Legacy fields that are no longer used
|
||||
private String[] receivers;
|
||||
private String smsBody;
|
||||
|
||||
public SMSPayload() {
|
||||
}
|
||||
|
||||
public String[] getRecipients() {
|
||||
return recipients;
|
||||
}
|
||||
|
||||
public void setRecipients(String[] recipients) {
|
||||
this.recipients = recipients;
|
||||
}
|
||||
|
||||
public String getMessage() {
|
||||
return message;
|
||||
}
|
||||
|
||||
public void setMessage(String message) {
|
||||
this.message = message;
|
||||
}
|
||||
|
||||
public String getSmsId() {
|
||||
return smsId;
|
||||
}
|
||||
|
||||
public void setSmsId(String smsId) {
|
||||
this.smsId = smsId;
|
||||
}
|
||||
|
||||
public String getSmsBatchId() {
|
||||
return smsBatchId;
|
||||
}
|
||||
|
||||
public void setSmsBatchId(String smsBatchId) {
|
||||
this.smsBatchId = smsBatchId;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package com.vernu.sms.models
|
||||
|
||||
class SMSPayload {
|
||||
var recipients: Array<String>? = null
|
||||
var message: String? = null
|
||||
var smsId: String? = null
|
||||
var smsBatchId: String? = null
|
||||
var simSubscriptionId: Int? = null
|
||||
|
||||
// Legacy fields — no longer actively used but kept for backward compatibility
|
||||
var receivers: Array<String>? = null
|
||||
var smsBody: String? = null
|
||||
}
|
||||
@@ -1,100 +0,0 @@
|
||||
package com.vernu.sms.receivers;
|
||||
|
||||
import android.Manifest;
|
||||
import android.content.BroadcastReceiver;
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.os.Build;
|
||||
import android.util.Log;
|
||||
|
||||
import com.google.firebase.messaging.FirebaseMessaging;
|
||||
import com.vernu.sms.ApiManager;
|
||||
import com.vernu.sms.AppConstants;
|
||||
import com.vernu.sms.BuildConfig;
|
||||
import com.vernu.sms.TextBeeUtils;
|
||||
import com.vernu.sms.dtos.RegisterDeviceInputDTO;
|
||||
import com.vernu.sms.dtos.RegisterDeviceResponseDTO;
|
||||
import com.vernu.sms.helpers.SharedPreferenceHelper;
|
||||
import com.vernu.sms.services.StickyNotificationService;
|
||||
|
||||
import retrofit2.Call;
|
||||
import retrofit2.Callback;
|
||||
import retrofit2.Response;
|
||||
|
||||
public class BootCompletedReceiver extends BroadcastReceiver {
|
||||
private static final String TAG = "BootCompletedReceiver";
|
||||
|
||||
@Override
|
||||
public void onReceive(Context context, Intent intent) {
|
||||
if (Intent.ACTION_BOOT_COMPLETED.equals(intent.getAction())) {
|
||||
boolean stickyNotificationEnabled = SharedPreferenceHelper.getSharedPreferenceBoolean(
|
||||
context,
|
||||
AppConstants.SHARED_PREFS_STICKY_NOTIFICATION_ENABLED_KEY,
|
||||
false
|
||||
);
|
||||
|
||||
if(stickyNotificationEnabled && TextBeeUtils.isPermissionGranted(context, Manifest.permission.RECEIVE_SMS)){
|
||||
Log.i(TAG, "Device booted, starting sticky notification service");
|
||||
TextBeeUtils.startStickyNotificationService(context);
|
||||
}
|
||||
|
||||
// Report device info to server if device is registered
|
||||
String deviceId = SharedPreferenceHelper.getSharedPreferenceString(
|
||||
context,
|
||||
AppConstants.SHARED_PREFS_DEVICE_ID_KEY,
|
||||
""
|
||||
);
|
||||
|
||||
String apiKey = SharedPreferenceHelper.getSharedPreferenceString(
|
||||
context,
|
||||
AppConstants.SHARED_PREFS_API_KEY_KEY,
|
||||
""
|
||||
);
|
||||
|
||||
// Only proceed if both device ID and API key are available
|
||||
if (!deviceId.isEmpty() && !apiKey.isEmpty()) {
|
||||
updateDeviceInfo(context, deviceId, apiKey);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates device information on the server after boot
|
||||
*/
|
||||
private void updateDeviceInfo(Context context, String deviceId, String apiKey) {
|
||||
FirebaseMessaging.getInstance().getToken()
|
||||
.addOnCompleteListener(task -> {
|
||||
if (!task.isSuccessful()) {
|
||||
Log.e(TAG, "Failed to obtain FCM token after boot");
|
||||
return;
|
||||
}
|
||||
|
||||
String token = task.getResult();
|
||||
|
||||
RegisterDeviceInputDTO updateInput = new RegisterDeviceInputDTO();
|
||||
updateInput.setFcmToken(token);
|
||||
updateInput.setAppVersionCode(BuildConfig.VERSION_CODE);
|
||||
updateInput.setAppVersionName(BuildConfig.VERSION_NAME);
|
||||
|
||||
Log.d(TAG, "Updating device info after boot - deviceId: " + deviceId);
|
||||
|
||||
ApiManager.getApiService()
|
||||
.updateDevice(deviceId, apiKey, updateInput)
|
||||
.enqueue(new Callback<RegisterDeviceResponseDTO>() {
|
||||
@Override
|
||||
public void onResponse(Call<RegisterDeviceResponseDTO> call, Response<RegisterDeviceResponseDTO> response) {
|
||||
if (response.isSuccessful()) {
|
||||
Log.d(TAG, "Device info updated successfully after boot");
|
||||
} else {
|
||||
Log.e(TAG, "Failed to update device info after boot. Response code: " + response.code());
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onFailure(Call<RegisterDeviceResponseDTO> call, Throwable t) {
|
||||
Log.e(TAG, "Error updating device info after boot: " + t.getMessage());
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
package com.vernu.sms.receivers
|
||||
|
||||
import android.Manifest
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.util.Log
|
||||
import com.google.firebase.messaging.FirebaseMessaging
|
||||
import com.vernu.sms.ApiManager
|
||||
import com.vernu.sms.AppConstants
|
||||
import com.vernu.sms.BuildConfig
|
||||
import com.vernu.sms.TextBeeUtils
|
||||
import com.vernu.sms.dtos.RegisterDeviceInputDTO
|
||||
import com.vernu.sms.dtos.RegisterDeviceResponseDTO
|
||||
import com.vernu.sms.helpers.HeartbeatManager
|
||||
import com.vernu.sms.helpers.SharedPreferenceHelper
|
||||
import retrofit2.Call
|
||||
import retrofit2.Callback
|
||||
import retrofit2.Response
|
||||
|
||||
class BootCompletedReceiver : BroadcastReceiver() {
|
||||
companion object {
|
||||
private const val TAG = "BootCompletedReceiver"
|
||||
}
|
||||
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
if (intent.action != Intent.ACTION_BOOT_COMPLETED) return
|
||||
|
||||
val stickyNotificationEnabled = SharedPreferenceHelper.getSharedPreferenceBoolean(
|
||||
context, AppConstants.SHARED_PREFS_STICKY_NOTIFICATION_ENABLED_KEY, false
|
||||
)
|
||||
if (stickyNotificationEnabled && TextBeeUtils.isPermissionGranted(context, Manifest.permission.RECEIVE_SMS)) {
|
||||
Log.i(TAG, "Device booted, starting sticky notification service")
|
||||
TextBeeUtils.startStickyNotificationService(context)
|
||||
}
|
||||
|
||||
val deviceId = SharedPreferenceHelper.getSharedPreferenceString(
|
||||
context, AppConstants.SHARED_PREFS_DEVICE_ID_KEY, ""
|
||||
) ?: ""
|
||||
val apiKey = SharedPreferenceHelper.getSharedPreferenceString(
|
||||
context, AppConstants.SHARED_PREFS_API_KEY_KEY, ""
|
||||
) ?: ""
|
||||
|
||||
if (deviceId.isNotEmpty() && apiKey.isNotEmpty()) {
|
||||
updateDeviceInfo(context, deviceId, apiKey)
|
||||
|
||||
val deviceEnabled = SharedPreferenceHelper.getSharedPreferenceBoolean(
|
||||
context, AppConstants.SHARED_PREFS_GATEWAY_ENABLED_KEY, false
|
||||
)
|
||||
if (deviceEnabled) {
|
||||
Log.i(TAG, "Device booted, scheduling heartbeat")
|
||||
HeartbeatManager.scheduleHeartbeat(context)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateDeviceInfo(context: Context, deviceId: String, apiKey: String) {
|
||||
FirebaseMessaging.getInstance().token.addOnCompleteListener { task ->
|
||||
if (!task.isSuccessful) {
|
||||
Log.e(TAG, "Failed to obtain FCM token after boot")
|
||||
return@addOnCompleteListener
|
||||
}
|
||||
|
||||
val input = RegisterDeviceInputDTO().apply {
|
||||
fcmToken = task.result
|
||||
appVersionCode = BuildConfig.VERSION_CODE
|
||||
appVersionName = BuildConfig.VERSION_NAME
|
||||
}
|
||||
|
||||
Log.d(TAG, "Updating device info after boot - deviceId: $deviceId")
|
||||
|
||||
ApiManager.getApiService()
|
||||
.updateDevice(deviceId, apiKey, input)
|
||||
.enqueue(object : Callback<RegisterDeviceResponseDTO> {
|
||||
override fun onResponse(
|
||||
call: Call<RegisterDeviceResponseDTO>,
|
||||
response: Response<RegisterDeviceResponseDTO>
|
||||
) {
|
||||
if (response.isSuccessful) {
|
||||
Log.d(TAG, "Device info updated successfully after boot")
|
||||
val data = response.body()?.data ?: return
|
||||
val intervalObj = data["heartbeatIntervalMinutes"] as? Number ?: return
|
||||
SharedPreferenceHelper.setSharedPreferenceInt(
|
||||
context,
|
||||
AppConstants.SHARED_PREFS_HEARTBEAT_INTERVAL_MINUTES_KEY,
|
||||
intervalObj.toInt()
|
||||
)
|
||||
Log.d(TAG, "Synced heartbeat interval from server: ${intervalObj.toInt()} minutes")
|
||||
} else {
|
||||
Log.e(TAG, "Failed to update device info after boot. Response code: ${response.code()}")
|
||||
}
|
||||
}
|
||||
|
||||
override fun onFailure(call: Call<RegisterDeviceResponseDTO>, t: Throwable) {
|
||||
Log.e(TAG, "Error updating device info after boot: ${t.message}")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,72 +0,0 @@
|
||||
package com.vernu.sms.receivers;
|
||||
|
||||
import android.content.BroadcastReceiver;
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.provider.Telephony;
|
||||
import android.telephony.SmsMessage;
|
||||
import android.util.Log;
|
||||
import com.vernu.sms.AppConstants;
|
||||
import com.vernu.sms.dtos.SMSDTO;
|
||||
import com.vernu.sms.helpers.SharedPreferenceHelper;
|
||||
import com.vernu.sms.workers.SMSReceivedWorker;
|
||||
|
||||
import java.util.Objects;
|
||||
|
||||
|
||||
public class SMSBroadcastReceiver extends BroadcastReceiver {
|
||||
private static final String TAG = "SMSBroadcastReceiver";
|
||||
|
||||
@Override
|
||||
public void onReceive(Context context, Intent intent) {
|
||||
Log.d(TAG, "onReceive: " + intent.getAction());
|
||||
|
||||
if (!Objects.equals(intent.getAction(), Telephony.Sms.Intents.SMS_RECEIVED_ACTION)) {
|
||||
Log.d(TAG, "Not Valid intent");
|
||||
return;
|
||||
}
|
||||
|
||||
SmsMessage[] messages = Telephony.Sms.Intents.getMessagesFromIntent(intent);
|
||||
if (messages == null) {
|
||||
Log.d(TAG, "No messages found");
|
||||
return;
|
||||
}
|
||||
|
||||
String deviceId = SharedPreferenceHelper.getSharedPreferenceString(context, AppConstants.SHARED_PREFS_DEVICE_ID_KEY, "");
|
||||
String apiKey = SharedPreferenceHelper.getSharedPreferenceString(context, AppConstants.SHARED_PREFS_API_KEY_KEY, "");
|
||||
boolean receiveSMSEnabled = SharedPreferenceHelper.getSharedPreferenceBoolean(context, AppConstants.SHARED_PREFS_RECEIVE_SMS_ENABLED_KEY, false);
|
||||
|
||||
if (deviceId.isEmpty() || apiKey.isEmpty() || !receiveSMSEnabled) {
|
||||
Log.d(TAG, "Device ID or API Key is empty or Receive SMS Feature is disabled");
|
||||
return;
|
||||
}
|
||||
|
||||
// SMS receivedSMS = new SMS();
|
||||
// receivedSMS.setType("RECEIVED");
|
||||
// for (SmsMessage message : messages) {
|
||||
// receivedSMS.setMessage(receivedSMS.getMessage() + message.getMessageBody());
|
||||
// receivedSMS.setSender(message.getOriginatingAddress());
|
||||
// receivedSMS.setReceivedAt(new Date(message.getTimestampMillis()));
|
||||
// }
|
||||
|
||||
SMSDTO receivedSMSDTO = new SMSDTO();
|
||||
|
||||
for (SmsMessage message : messages) {
|
||||
receivedSMSDTO.setMessage(receivedSMSDTO.getMessage() + message.getMessageBody());
|
||||
receivedSMSDTO.setSender(message.getOriginatingAddress());
|
||||
receivedSMSDTO.setReceivedAtInMillis(message.getTimestampMillis());
|
||||
}
|
||||
// receivedSMSDTO.setSender(receivedSMS.getSender());
|
||||
// receivedSMSDTO.setMessage(receivedSMS.getMessage());
|
||||
// receivedSMSDTO.setReceivedAt(receivedSMS.getReceivedAt());
|
||||
|
||||
SMSReceivedWorker.enqueueWork(context, deviceId, apiKey, receivedSMSDTO);
|
||||
}
|
||||
|
||||
// private void updateLocalReceivedSMS(SMS localReceivedSMS, Context context) {
|
||||
// Executors.newSingleThreadExecutor().execute(() -> {
|
||||
// AppDatabase appDatabase = AppDatabase.getInstance(context);
|
||||
// appDatabase.localReceivedSMSDao().insertAll(localReceivedSMS);
|
||||
// });
|
||||
// }
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
package com.vernu.sms.receivers
|
||||
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.provider.Telephony
|
||||
import android.util.Log
|
||||
import com.vernu.sms.AppConstants
|
||||
import com.vernu.sms.dtos.SMSDTO
|
||||
import com.vernu.sms.helpers.SMSFilterHelper
|
||||
import com.vernu.sms.helpers.SharedPreferenceHelper
|
||||
import com.vernu.sms.workers.SMSReceivedWorker
|
||||
import java.security.MessageDigest
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
|
||||
class SMSBroadcastReceiver : BroadcastReceiver() {
|
||||
companion object {
|
||||
private const val TAG = "SMSBroadcastReceiver"
|
||||
private val processedFingerprints = ConcurrentHashMap<String, Long>()
|
||||
private const val CACHE_TTL_MS = 5000L
|
||||
}
|
||||
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
Log.d(TAG, "onReceive: ${intent.action}")
|
||||
|
||||
if (intent.action != Telephony.Sms.Intents.SMS_RECEIVED_ACTION) {
|
||||
Log.d(TAG, "Not Valid intent")
|
||||
return
|
||||
}
|
||||
|
||||
val messages = Telephony.Sms.Intents.getMessagesFromIntent(intent) ?: run {
|
||||
Log.d(TAG, "No messages found")
|
||||
return
|
||||
}
|
||||
|
||||
val deviceId = SharedPreferenceHelper.getSharedPreferenceString(
|
||||
context, AppConstants.SHARED_PREFS_DEVICE_ID_KEY, ""
|
||||
) ?: ""
|
||||
val apiKey = SharedPreferenceHelper.getSharedPreferenceString(
|
||||
context, AppConstants.SHARED_PREFS_API_KEY_KEY, ""
|
||||
) ?: ""
|
||||
val receiveSMSEnabled = SharedPreferenceHelper.getSharedPreferenceBoolean(
|
||||
context, AppConstants.SHARED_PREFS_RECEIVE_SMS_ENABLED_KEY, false
|
||||
)
|
||||
|
||||
if (deviceId.isEmpty() || apiKey.isEmpty() || !receiveSMSEnabled) {
|
||||
Log.d(TAG, "Device ID or API Key is empty or Receive SMS Feature is disabled")
|
||||
return
|
||||
}
|
||||
|
||||
val dto = SMSDTO()
|
||||
for (message in messages) {
|
||||
dto.message += message.messageBody ?: ""
|
||||
dto.sender = message.originatingAddress
|
||||
dto.receivedAtInMillis = message.timestampMillis
|
||||
}
|
||||
|
||||
val sender = dto.sender
|
||||
if (sender != null && !SMSFilterHelper.shouldProcessSMS(sender, dto.message, context)) {
|
||||
Log.d(TAG, "SMS from $sender filtered out by filter rules")
|
||||
return
|
||||
}
|
||||
|
||||
val fingerprint = generateFingerprint(dto.sender, dto.message, dto.receivedAtInMillis)
|
||||
dto.fingerprint = fingerprint
|
||||
|
||||
val currentTime = System.currentTimeMillis()
|
||||
val lastProcessedTime = processedFingerprints[fingerprint]
|
||||
if (lastProcessedTime != null && (currentTime - lastProcessedTime) < CACHE_TTL_MS) {
|
||||
Log.d(TAG, "Duplicate SMS detected in cache, skipping: $fingerprint")
|
||||
return
|
||||
}
|
||||
|
||||
processedFingerprints[fingerprint] = currentTime
|
||||
cleanupCache(currentTime)
|
||||
|
||||
SMSReceivedWorker.enqueueWork(context, deviceId, apiKey, dto)
|
||||
}
|
||||
|
||||
private fun generateFingerprint(sender: String?, message: String, timestamp: Long): String {
|
||||
return try {
|
||||
val data = "${sender ?: ""}|$message|$timestamp"
|
||||
val hashBytes = MessageDigest.getInstance("MD5").digest(data.toByteArray(Charsets.UTF_8))
|
||||
hashBytes.joinToString("") { "%02x".format(it) }
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error generating fingerprint: ${e.message}")
|
||||
"${sender ?: ""}_${message}_$timestamp"
|
||||
}
|
||||
}
|
||||
|
||||
private fun cleanupCache(currentTime: Long) {
|
||||
if (processedFingerprints.size > 100) {
|
||||
val keysToRemove = processedFingerprints.entries
|
||||
.filter { (currentTime - it.value) > CACHE_TTL_MS }
|
||||
.map { it.key }
|
||||
keysToRemove.forEach { processedFingerprints.remove(it) }
|
||||
Log.d(TAG, "Cleaned up ${keysToRemove.size} expired cache entries")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,158 +0,0 @@
|
||||
package com.vernu.sms.receivers;
|
||||
|
||||
import android.app.Activity;
|
||||
import android.content.BroadcastReceiver;
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.telephony.SmsManager;
|
||||
import android.util.Log;
|
||||
|
||||
import com.vernu.sms.AppConstants;
|
||||
import com.vernu.sms.dtos.SMSDTO;
|
||||
import com.vernu.sms.helpers.SharedPreferenceHelper;
|
||||
import com.vernu.sms.workers.SMSStatusUpdateWorker;
|
||||
|
||||
|
||||
public class SMSStatusReceiver extends BroadcastReceiver {
|
||||
private static final String TAG = "SMSStatusReceiver";
|
||||
|
||||
public static final String SMS_SENT = "SMS_SENT";
|
||||
public static final String SMS_DELIVERED = "SMS_DELIVERED";
|
||||
|
||||
@Override
|
||||
public void onReceive(Context context, Intent intent) {
|
||||
String smsId = intent.getStringExtra("sms_id");
|
||||
String smsBatchId = intent.getStringExtra("sms_batch_id");
|
||||
String action = intent.getAction();
|
||||
|
||||
SMSDTO smsDTO = new SMSDTO();
|
||||
smsDTO.setSmsId(smsId);
|
||||
smsDTO.setSmsBatchId(smsBatchId);
|
||||
|
||||
if (SMS_SENT.equals(action)) {
|
||||
handleSentStatus(context, getResultCode(), smsDTO);
|
||||
} else if (SMS_DELIVERED.equals(action)) {
|
||||
handleDeliveredStatus(context, getResultCode(), smsDTO);
|
||||
}
|
||||
}
|
||||
|
||||
private void handleSentStatus(Context context, int resultCode, SMSDTO smsDTO) {
|
||||
long timestamp = System.currentTimeMillis();
|
||||
String errorMessage = "";
|
||||
|
||||
switch (resultCode) {
|
||||
case Activity.RESULT_OK:
|
||||
smsDTO.setStatus("SENT");
|
||||
smsDTO.setSentAtInMillis(timestamp);
|
||||
Log.d(TAG, "SMS sent successfully - ID: " + smsDTO.getSmsId());
|
||||
break;
|
||||
case SmsManager.RESULT_ERROR_GENERIC_FAILURE:
|
||||
errorMessage = "Generic failure";
|
||||
smsDTO.setStatus("FAILED");
|
||||
smsDTO.setFailedAtInMillis(timestamp);
|
||||
smsDTO.setErrorCode(String.valueOf(resultCode));
|
||||
smsDTO.setErrorMessage(errorMessage);
|
||||
Log.e(TAG, "SMS failed to send - ID: " + smsDTO.getSmsId() + ", Error code: " + resultCode + ", Error: " + errorMessage);
|
||||
break;
|
||||
case SmsManager.RESULT_ERROR_RADIO_OFF:
|
||||
errorMessage = "Radio off";
|
||||
smsDTO.setStatus("FAILED");
|
||||
smsDTO.setFailedAtInMillis(timestamp);
|
||||
smsDTO.setErrorCode(String.valueOf(resultCode));
|
||||
smsDTO.setErrorMessage(errorMessage);
|
||||
Log.e(TAG, "SMS failed to send - ID: " + smsDTO.getSmsId() + ", Error code: " + resultCode + ", Error: " + errorMessage);
|
||||
break;
|
||||
case SmsManager.RESULT_ERROR_NULL_PDU:
|
||||
errorMessage = "Null PDU";
|
||||
smsDTO.setStatus("FAILED");
|
||||
smsDTO.setFailedAtInMillis(timestamp);
|
||||
smsDTO.setErrorCode(String.valueOf(resultCode));
|
||||
smsDTO.setErrorMessage(errorMessage);
|
||||
Log.e(TAG, "SMS failed to send - ID: " + smsDTO.getSmsId() + ", Error code: " + resultCode + ", Error: " + errorMessage);
|
||||
break;
|
||||
case SmsManager.RESULT_ERROR_NO_SERVICE:
|
||||
errorMessage = "No service";
|
||||
smsDTO.setStatus("FAILED");
|
||||
smsDTO.setFailedAtInMillis(timestamp);
|
||||
smsDTO.setErrorCode(String.valueOf(resultCode));
|
||||
smsDTO.setErrorMessage(errorMessage);
|
||||
Log.e(TAG, "SMS failed to send - ID: " + smsDTO.getSmsId() + ", Error code: " + resultCode + ", Error: " + errorMessage);
|
||||
break;
|
||||
case SmsManager.RESULT_ERROR_LIMIT_EXCEEDED:
|
||||
errorMessage = "Sending limit exceeded";
|
||||
smsDTO.setStatus("FAILED");
|
||||
smsDTO.setFailedAtInMillis(timestamp);
|
||||
smsDTO.setErrorCode(String.valueOf(resultCode));
|
||||
smsDTO.setErrorMessage(errorMessage);
|
||||
Log.e(TAG, "SMS failed to send - ID: " + smsDTO.getSmsId() + ", Error code: " + resultCode + ", Error: " + errorMessage);
|
||||
break;
|
||||
case SmsManager.RESULT_ERROR_SHORT_CODE_NOT_ALLOWED:
|
||||
errorMessage = "Short code not allowed";
|
||||
smsDTO.setStatus("FAILED");
|
||||
smsDTO.setFailedAtInMillis(timestamp);
|
||||
smsDTO.setErrorCode(String.valueOf(resultCode));
|
||||
smsDTO.setErrorMessage(errorMessage);
|
||||
Log.e(TAG, "SMS failed to send - ID: " + smsDTO.getSmsId() + ", Error code: " + resultCode + ", Error: " + errorMessage);
|
||||
break;
|
||||
case SmsManager.RESULT_ERROR_SHORT_CODE_NEVER_ALLOWED:
|
||||
errorMessage = "Short code never allowed";
|
||||
smsDTO.setStatus("FAILED");
|
||||
smsDTO.setFailedAtInMillis(timestamp);
|
||||
smsDTO.setErrorCode(String.valueOf(resultCode));
|
||||
smsDTO.setErrorMessage(errorMessage);
|
||||
Log.e(TAG, "SMS failed to send - ID: " + smsDTO.getSmsId() + ", Error code: " + resultCode + ", Error: " + errorMessage);
|
||||
break;
|
||||
default:
|
||||
errorMessage = "Unknown error";
|
||||
smsDTO.setStatus("FAILED");
|
||||
smsDTO.setFailedAtInMillis(timestamp);
|
||||
smsDTO.setErrorCode(String.valueOf(resultCode));
|
||||
smsDTO.setErrorMessage(errorMessage);
|
||||
Log.e(TAG, "SMS failed to send - ID: " + smsDTO.getSmsId() + ", Unknown error code: " + resultCode);
|
||||
break;
|
||||
}
|
||||
|
||||
updateSMSStatus(context, smsDTO);
|
||||
}
|
||||
|
||||
private void handleDeliveredStatus(Context context, int resultCode, SMSDTO smsDTO) {
|
||||
long timestamp = System.currentTimeMillis();
|
||||
String errorMessage = "";
|
||||
|
||||
switch (resultCode) {
|
||||
case Activity.RESULT_OK:
|
||||
smsDTO.setStatus("DELIVERED");
|
||||
smsDTO.setDeliveredAtInMillis(timestamp);
|
||||
Log.d(TAG, "SMS delivered successfully - ID: " + smsDTO.getSmsId());
|
||||
break;
|
||||
case Activity.RESULT_CANCELED:
|
||||
errorMessage = "Delivery canceled";
|
||||
smsDTO.setStatus("DELIVERY_FAILED");
|
||||
smsDTO.setErrorCode(String.valueOf(resultCode));
|
||||
smsDTO.setErrorMessage(errorMessage);
|
||||
Log.e(TAG, "SMS delivery failed - ID: " + smsDTO.getSmsId() + ", Error code: " + resultCode + ", Error: " + errorMessage);
|
||||
break;
|
||||
default:
|
||||
errorMessage = "Unknown delivery error";
|
||||
smsDTO.setStatus("DELIVERY_FAILED");
|
||||
smsDTO.setErrorCode(String.valueOf(resultCode));
|
||||
smsDTO.setErrorMessage(errorMessage);
|
||||
Log.e(TAG, "SMS delivery failed - ID: " + smsDTO.getSmsId() + ", Unknown error code: " + resultCode);
|
||||
break;
|
||||
}
|
||||
|
||||
updateSMSStatus(context, smsDTO);
|
||||
}
|
||||
|
||||
private void updateSMSStatus(Context context, SMSDTO smsDTO) {
|
||||
String deviceId = SharedPreferenceHelper.getSharedPreferenceString(context, AppConstants.SHARED_PREFS_DEVICE_ID_KEY, "");
|
||||
String apiKey = SharedPreferenceHelper.getSharedPreferenceString(context, AppConstants.SHARED_PREFS_API_KEY_KEY, "");
|
||||
|
||||
if (deviceId.isEmpty() || apiKey.isEmpty()) {
|
||||
Log.e(TAG, "Device ID or API key not found");
|
||||
return;
|
||||
}
|
||||
|
||||
SMSStatusUpdateWorker.enqueueWork(context, deviceId, apiKey, smsDTO);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
package com.vernu.sms.receivers
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.telephony.SmsManager
|
||||
import android.util.Log
|
||||
import com.vernu.sms.AppConstants
|
||||
import com.vernu.sms.dtos.SMSDTO
|
||||
import com.vernu.sms.helpers.SharedPreferenceHelper
|
||||
import com.vernu.sms.workers.SMSStatusUpdateWorker
|
||||
import java.lang.reflect.Modifier
|
||||
|
||||
class SMSStatusReceiver : BroadcastReceiver() {
|
||||
companion object {
|
||||
private const val TAG = "SMSStatusReceiver"
|
||||
const val SMS_SENT = "SMS_SENT"
|
||||
const val SMS_DELIVERED = "SMS_DELIVERED"
|
||||
|
||||
private fun getResultCodeName(resultCode: Int): String? {
|
||||
for (clazz in arrayOf<Class<*>>(SmsManager::class.java, Activity::class.java)) {
|
||||
try {
|
||||
for (field in clazz.declaredFields) {
|
||||
if (field.type != Int::class.javaPrimitiveType) continue
|
||||
if (!Modifier.isStatic(field.modifiers) || !Modifier.isFinal(field.modifiers)) continue
|
||||
if (!field.name.startsWith("RESULT_")) continue
|
||||
field.isAccessible = true
|
||||
if (field.getInt(null) == resultCode) return "${clazz.simpleName}.${field.name}"
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Reflection failed for ${clazz.simpleName}: ${e.message}")
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
val smsId = intent.getStringExtra("sms_id")
|
||||
val smsBatchId = intent.getStringExtra("sms_batch_id")
|
||||
|
||||
val smsDTO = SMSDTO().apply {
|
||||
this.smsId = smsId
|
||||
this.smsBatchId = smsBatchId
|
||||
}
|
||||
|
||||
when (intent.action) {
|
||||
SMS_SENT -> handleSentStatus(context, intent, resultCode, smsDTO)
|
||||
SMS_DELIVERED -> handleDeliveredStatus(context, resultCode, smsDTO)
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleSentStatus(context: Context, intent: Intent, resultCode: Int, smsDTO: SMSDTO) {
|
||||
val timestamp = System.currentTimeMillis()
|
||||
when (resultCode) {
|
||||
Activity.RESULT_OK -> {
|
||||
smsDTO.status = "SENT"
|
||||
smsDTO.sentAtInMillis = timestamp
|
||||
Log.d(TAG, "SMS sent successfully - ID: ${smsDTO.smsId}")
|
||||
}
|
||||
SmsManager.RESULT_ERROR_GENERIC_FAILURE -> {
|
||||
val radioCode = intent.getIntExtra("errorCode", -1)
|
||||
var msg = "SMS failed on device. Common causes: no SMS credit on SIM, weak signal, or carrier blocked. Check SIM balance and signal, then try again."
|
||||
if (radioCode != -1) msg += " (code $radioCode)"
|
||||
setFailed(smsDTO, timestamp, resultCode, msg)
|
||||
Log.e(TAG, "SMS failed to send - ID: ${smsDTO.smsId}, Error code: $resultCode, Error: $msg")
|
||||
}
|
||||
SmsManager.RESULT_ERROR_RADIO_OFF -> setFailed(smsDTO, timestamp, resultCode,
|
||||
"Mobile radio is off (e.g. airplane mode). Turn off airplane mode and ensure cellular is on.")
|
||||
SmsManager.RESULT_ERROR_NULL_PDU -> setFailed(smsDTO, timestamp, resultCode,
|
||||
"Message could not be sent; invalid format or carrier issue. Try a shorter message or different recipient.")
|
||||
SmsManager.RESULT_ERROR_NO_SERVICE -> setFailed(smsDTO, timestamp, resultCode,
|
||||
"No cellular service. Check signal and try again when you have coverage.")
|
||||
SmsManager.RESULT_ERROR_LIMIT_EXCEEDED -> setFailed(smsDTO, timestamp, resultCode,
|
||||
"Device/carrier send limit reached (too many SMS in a short time). Wait a few minutes or lower the send rate.")
|
||||
SmsManager.RESULT_ERROR_SHORT_CODE_NOT_ALLOWED -> setFailed(smsDTO, timestamp, resultCode,
|
||||
"Short code not allowed on this carrier. Use a full phone number.")
|
||||
SmsManager.RESULT_ERROR_SHORT_CODE_NEVER_ALLOWED -> setFailed(smsDTO, timestamp, resultCode,
|
||||
"Short codes are not supported on this carrier. Use a full phone number.")
|
||||
SmsManager.RESULT_NETWORK_ERROR -> setFailed(smsDTO, timestamp, resultCode,
|
||||
"Network error while sending. Check signal and try again.")
|
||||
else -> {
|
||||
val msg = getResultCodeName(resultCode) ?: "Unknown error (code $resultCode)"
|
||||
setFailed(smsDTO, timestamp, resultCode, msg)
|
||||
Log.e(TAG, "SMS failed to send - ID: ${smsDTO.smsId}, Error: $msg")
|
||||
}
|
||||
}
|
||||
updateSMSStatus(context, smsDTO)
|
||||
}
|
||||
|
||||
private fun handleDeliveredStatus(context: Context, resultCode: Int, smsDTO: SMSDTO) {
|
||||
val timestamp = System.currentTimeMillis()
|
||||
when (resultCode) {
|
||||
Activity.RESULT_OK -> {
|
||||
smsDTO.status = "DELIVERED"
|
||||
smsDTO.deliveredAtInMillis = timestamp
|
||||
Log.d(TAG, "SMS delivered successfully - ID: ${smsDTO.smsId}")
|
||||
}
|
||||
Activity.RESULT_CANCELED -> {
|
||||
val msg = "Delivery report was canceled (e.g. carrier does not support delivery receipts). Message may still have been delivered."
|
||||
smsDTO.status = "DELIVERY_FAILED"
|
||||
smsDTO.errorCode = resultCode.toString()
|
||||
smsDTO.errorMessage = msg
|
||||
Log.e(TAG, "SMS delivery failed - ID: ${smsDTO.smsId}, Error: $msg")
|
||||
}
|
||||
else -> {
|
||||
val msg = getResultCodeName(resultCode) ?: "Unknown delivery error (code $resultCode)"
|
||||
smsDTO.status = "DELIVERY_FAILED"
|
||||
smsDTO.errorCode = resultCode.toString()
|
||||
smsDTO.errorMessage = msg
|
||||
Log.e(TAG, "SMS delivery failed - ID: ${smsDTO.smsId}, Error: $msg")
|
||||
}
|
||||
}
|
||||
updateSMSStatus(context, smsDTO)
|
||||
}
|
||||
|
||||
private fun setFailed(smsDTO: SMSDTO, timestamp: Long, resultCode: Int, msg: String) {
|
||||
smsDTO.status = "FAILED"
|
||||
smsDTO.failedAtInMillis = timestamp
|
||||
smsDTO.errorCode = resultCode.toString()
|
||||
smsDTO.errorMessage = msg
|
||||
}
|
||||
|
||||
private fun updateSMSStatus(context: Context, smsDTO: SMSDTO) {
|
||||
val deviceId = SharedPreferenceHelper.getSharedPreferenceString(
|
||||
context, AppConstants.SHARED_PREFS_DEVICE_ID_KEY, ""
|
||||
) ?: ""
|
||||
val apiKey = SharedPreferenceHelper.getSharedPreferenceString(
|
||||
context, AppConstants.SHARED_PREFS_API_KEY_KEY, ""
|
||||
) ?: ""
|
||||
if (deviceId.isEmpty() || apiKey.isEmpty()) {
|
||||
Log.e(TAG, "Device ID or API key not found")
|
||||
return
|
||||
}
|
||||
SMSStatusUpdateWorker.enqueueWork(context, deviceId, apiKey, smsDTO)
|
||||
}
|
||||
}
|
||||
@@ -1,198 +0,0 @@
|
||||
package com.vernu.sms.services;
|
||||
|
||||
import android.app.NotificationChannel;
|
||||
import android.app.NotificationManager;
|
||||
import android.app.PendingIntent;
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.media.RingtoneManager;
|
||||
import android.net.Uri;
|
||||
import android.os.Build;
|
||||
import android.util.Log;
|
||||
import androidx.core.app.NotificationCompat;
|
||||
import com.google.firebase.messaging.FirebaseMessagingService;
|
||||
import com.google.firebase.messaging.RemoteMessage;
|
||||
import com.google.gson.Gson;
|
||||
import com.vernu.sms.AppConstants;
|
||||
import com.vernu.sms.R;
|
||||
import com.vernu.sms.activities.MainActivity;
|
||||
import com.vernu.sms.helpers.SMSHelper;
|
||||
import com.vernu.sms.helpers.SharedPreferenceHelper;
|
||||
import com.vernu.sms.models.SMSPayload;
|
||||
import com.vernu.sms.dtos.RegisterDeviceInputDTO;
|
||||
import com.vernu.sms.dtos.RegisterDeviceResponseDTO;
|
||||
import com.vernu.sms.ApiManager;
|
||||
import retrofit2.Call;
|
||||
import retrofit2.Callback;
|
||||
import retrofit2.Response;
|
||||
|
||||
public class FCMService extends FirebaseMessagingService {
|
||||
|
||||
private static final String TAG = "FirebaseMessagingService";
|
||||
private static final String DEFAULT_NOTIFICATION_CHANNEL_ID = "N1";
|
||||
|
||||
@Override
|
||||
public void onMessageReceived(RemoteMessage remoteMessage) {
|
||||
Log.d(TAG, remoteMessage.getData().toString());
|
||||
|
||||
try {
|
||||
// Parse SMS payload data
|
||||
Gson gson = new Gson();
|
||||
SMSPayload smsPayload = gson.fromJson(remoteMessage.getData().get("smsData"), SMSPayload.class);
|
||||
|
||||
// Check if message contains a data payload
|
||||
if (remoteMessage.getData().size() > 0) {
|
||||
sendSMS(smsPayload);
|
||||
}
|
||||
|
||||
// Handle any notification message
|
||||
if (remoteMessage.getNotification() != null) {
|
||||
// sendNotification("notif msg", "msg body");
|
||||
}
|
||||
} catch (Exception e) {
|
||||
Log.e(TAG, "Error processing FCM message: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send SMS to recipients using the provided payload
|
||||
*/
|
||||
private void sendSMS(SMSPayload smsPayload) {
|
||||
if (smsPayload == null) {
|
||||
Log.e(TAG, "SMS payload is null");
|
||||
return;
|
||||
}
|
||||
|
||||
// Get preferred SIM
|
||||
int preferredSim = SharedPreferenceHelper.getSharedPreferenceInt(
|
||||
this, AppConstants.SHARED_PREFS_PREFERRED_SIM_KEY, -1);
|
||||
|
||||
// Check if SMS payload contains valid recipients
|
||||
String[] recipients = smsPayload.getRecipients();
|
||||
if (recipients == null || recipients.length == 0) {
|
||||
Log.e(TAG, "No recipients found in SMS payload");
|
||||
return;
|
||||
}
|
||||
|
||||
// Send SMS to each recipient
|
||||
boolean atLeastOneSent = false;
|
||||
int sentCount = 0;
|
||||
int failedCount = 0;
|
||||
|
||||
for (String recipient : recipients) {
|
||||
boolean smsSent;
|
||||
|
||||
// Try to send using default or specific SIM based on preference
|
||||
if (preferredSim == -1) {
|
||||
// Use default SIM
|
||||
smsSent = SMSHelper.sendSMS(
|
||||
recipient,
|
||||
smsPayload.getMessage(),
|
||||
smsPayload.getSmsId(),
|
||||
smsPayload.getSmsBatchId(),
|
||||
this
|
||||
);
|
||||
} else {
|
||||
// Use specific SIM
|
||||
try {
|
||||
smsSent = SMSHelper.sendSMSFromSpecificSim(
|
||||
recipient,
|
||||
smsPayload.getMessage(),
|
||||
preferredSim,
|
||||
smsPayload.getSmsId(),
|
||||
smsPayload.getSmsBatchId(),
|
||||
this
|
||||
);
|
||||
} catch (Exception e) {
|
||||
Log.e(TAG, "Error sending SMS from specific SIM: " + e.getMessage());
|
||||
smsSent = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Track sent and failed counts
|
||||
if (smsSent) {
|
||||
sentCount++;
|
||||
atLeastOneSent = true;
|
||||
} else {
|
||||
failedCount++;
|
||||
}
|
||||
}
|
||||
|
||||
// Log summary
|
||||
Log.d(TAG, "SMS sending complete - Batch: " + smsPayload.getSmsBatchId() +
|
||||
", Sent: " + sentCount + ", Failed: " + failedCount);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onNewToken(String token) {
|
||||
sendRegistrationToServer(token);
|
||||
}
|
||||
|
||||
private void sendRegistrationToServer(String token) {
|
||||
// Check if device ID and API key are saved in shared preferences
|
||||
String deviceId = SharedPreferenceHelper.getSharedPreferenceString(this, AppConstants.SHARED_PREFS_DEVICE_ID_KEY, "");
|
||||
String apiKey = SharedPreferenceHelper.getSharedPreferenceString(this, AppConstants.SHARED_PREFS_API_KEY_KEY, "");
|
||||
|
||||
// Only proceed if both device ID and API key are available
|
||||
if (deviceId.isEmpty() || apiKey.isEmpty()) {
|
||||
Log.d(TAG, "Device ID or API key not available, skipping FCM token update");
|
||||
return;
|
||||
}
|
||||
|
||||
// Create update payload with new FCM token
|
||||
RegisterDeviceInputDTO updateInput = new RegisterDeviceInputDTO();
|
||||
updateInput.setFcmToken(token);
|
||||
|
||||
// Call API to update the device with new token
|
||||
Log.d(TAG, "Updating FCM token for device: " + deviceId);
|
||||
ApiManager.getApiService()
|
||||
.updateDevice(deviceId, apiKey, updateInput)
|
||||
.enqueue(new Callback<RegisterDeviceResponseDTO>() {
|
||||
@Override
|
||||
public void onResponse(Call<RegisterDeviceResponseDTO> call, Response<RegisterDeviceResponseDTO> response) {
|
||||
if (response.isSuccessful()) {
|
||||
Log.d(TAG, "FCM token updated successfully");
|
||||
} else {
|
||||
Log.e(TAG, "Failed to update FCM token. Response code: " + response.code());
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onFailure(Call<RegisterDeviceResponseDTO> call, Throwable t) {
|
||||
Log.e(TAG, "Error updating FCM token: " + t.getMessage());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/* build and show notification */
|
||||
private void sendNotification(String title, String messageBody) {
|
||||
Intent intent = new Intent(this, MainActivity.class);
|
||||
intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP);
|
||||
PendingIntent pendingIntent = PendingIntent.getActivity(this, 0 /* Request code */, intent,
|
||||
PendingIntent.FLAG_ONE_SHOT);
|
||||
|
||||
String channelId = DEFAULT_NOTIFICATION_CHANNEL_ID;
|
||||
Uri defaultSoundUri = RingtoneManager.getDefaultUri(RingtoneManager.TYPE_NOTIFICATION);
|
||||
NotificationCompat.Builder notificationBuilder =
|
||||
new NotificationCompat.Builder(this, DEFAULT_NOTIFICATION_CHANNEL_ID)
|
||||
.setSmallIcon(R.drawable.ic_launcher_foreground)
|
||||
.setContentTitle(title)
|
||||
.setContentText(messageBody)
|
||||
.setAutoCancel(true)
|
||||
.setSound(defaultSoundUri)
|
||||
.setContentIntent(pendingIntent);
|
||||
|
||||
NotificationManager notificationManager =
|
||||
(NotificationManager) getSystemService(Context.NOTIFICATION_SERVICE);
|
||||
|
||||
// Since android Oreo notification channel is needed.
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
NotificationChannel channel = new NotificationChannel(channelId,
|
||||
"Channel human readable title",
|
||||
NotificationManager.IMPORTANCE_DEFAULT);
|
||||
notificationManager.createNotificationChannel(channel);
|
||||
}
|
||||
|
||||
notificationManager.notify(0 /* ID of notification */, notificationBuilder.build());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
package com.vernu.sms.services
|
||||
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.app.PendingIntent
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.media.RingtoneManager
|
||||
import android.os.Build
|
||||
import android.util.Log
|
||||
import androidx.core.app.NotificationCompat
|
||||
import com.google.firebase.messaging.FirebaseMessagingService
|
||||
import com.google.firebase.messaging.RemoteMessage
|
||||
import com.google.gson.Gson
|
||||
import com.vernu.sms.ApiManager
|
||||
import com.vernu.sms.AppConstants
|
||||
import com.vernu.sms.R
|
||||
import com.vernu.sms.activities.MainActivity
|
||||
import com.vernu.sms.dtos.RegisterDeviceInputDTO
|
||||
import com.vernu.sms.dtos.RegisterDeviceResponseDTO
|
||||
import com.vernu.sms.helpers.HeartbeatHelper
|
||||
import com.vernu.sms.helpers.HeartbeatManager
|
||||
import com.vernu.sms.helpers.SharedPreferenceHelper
|
||||
import com.vernu.sms.models.SMSPayload
|
||||
import com.vernu.sms.workers.SmsSendWorker
|
||||
import retrofit2.Call
|
||||
import retrofit2.Callback
|
||||
import retrofit2.Response
|
||||
|
||||
class FCMService : FirebaseMessagingService() {
|
||||
companion object {
|
||||
private const val TAG = "FirebaseMessagingService"
|
||||
private const val DEFAULT_NOTIFICATION_CHANNEL_ID = "N1"
|
||||
}
|
||||
|
||||
override fun onMessageReceived(remoteMessage: RemoteMessage) {
|
||||
Log.d(TAG, remoteMessage.data.toString())
|
||||
|
||||
try {
|
||||
val messageType = remoteMessage.data["type"]
|
||||
if (messageType == "heartbeat_check") {
|
||||
handleHeartbeatCheck()
|
||||
return
|
||||
}
|
||||
|
||||
val smsPayload = Gson().fromJson(remoteMessage.data["smsData"], SMSPayload::class.java)
|
||||
|
||||
if (remoteMessage.data.isNotEmpty()) {
|
||||
sendSMS(smsPayload)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error processing FCM message: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleHeartbeatCheck() {
|
||||
Log.d(TAG, "Received heartbeat check request from backend")
|
||||
|
||||
if (!HeartbeatHelper.isDeviceEligibleForHeartbeat(this)) {
|
||||
Log.d(TAG, "Device not eligible for heartbeat, skipping heartbeat check")
|
||||
return
|
||||
}
|
||||
|
||||
val deviceId = SharedPreferenceHelper.getSharedPreferenceString(
|
||||
this, AppConstants.SHARED_PREFS_DEVICE_ID_KEY, ""
|
||||
) ?: ""
|
||||
val apiKey = SharedPreferenceHelper.getSharedPreferenceString(
|
||||
this, AppConstants.SHARED_PREFS_API_KEY_KEY, ""
|
||||
) ?: ""
|
||||
|
||||
val success = HeartbeatHelper.sendHeartbeat(this, deviceId, apiKey)
|
||||
if (success) {
|
||||
Log.d(TAG, "Heartbeat sent successfully in response to backend check")
|
||||
} else {
|
||||
Log.e(TAG, "Failed to send heartbeat in response to backend check")
|
||||
}
|
||||
HeartbeatManager.scheduleHeartbeat(this)
|
||||
}
|
||||
|
||||
private fun sendSMS(smsPayload: SMSPayload?) {
|
||||
if (smsPayload == null) {
|
||||
Log.e(TAG, "SMS payload is null")
|
||||
return
|
||||
}
|
||||
|
||||
val recipients = smsPayload.recipients
|
||||
if (recipients == null || recipients.isEmpty()) {
|
||||
Log.e(TAG, "No recipients found in SMS payload")
|
||||
return
|
||||
}
|
||||
|
||||
for (recipient in recipients) {
|
||||
SmsSendWorker.enqueue(
|
||||
this, recipient, smsPayload.message ?: "",
|
||||
smsPayload.smsId, smsPayload.smsBatchId, smsPayload.simSubscriptionId
|
||||
)
|
||||
}
|
||||
|
||||
Log.d(TAG, "Enqueued ${recipients.size} SMS for sending - Batch: ${smsPayload.smsBatchId}")
|
||||
}
|
||||
|
||||
override fun onNewToken(token: String) {
|
||||
sendRegistrationToServer(token)
|
||||
}
|
||||
|
||||
private fun sendRegistrationToServer(token: String) {
|
||||
val deviceId = SharedPreferenceHelper.getSharedPreferenceString(
|
||||
this, AppConstants.SHARED_PREFS_DEVICE_ID_KEY, ""
|
||||
) ?: ""
|
||||
val apiKey = SharedPreferenceHelper.getSharedPreferenceString(
|
||||
this, AppConstants.SHARED_PREFS_API_KEY_KEY, ""
|
||||
) ?: ""
|
||||
|
||||
if (deviceId.isEmpty() || apiKey.isEmpty()) {
|
||||
Log.d(TAG, "Device ID or API key not available, skipping FCM token update")
|
||||
return
|
||||
}
|
||||
|
||||
val updateInput = RegisterDeviceInputDTO().apply { fcmToken = token }
|
||||
Log.d(TAG, "Updating FCM token for device: $deviceId")
|
||||
|
||||
ApiManager.getApiService()
|
||||
.updateDevice(deviceId, apiKey, updateInput)
|
||||
.enqueue(object : Callback<RegisterDeviceResponseDTO> {
|
||||
override fun onResponse(
|
||||
call: Call<RegisterDeviceResponseDTO>,
|
||||
response: Response<RegisterDeviceResponseDTO>
|
||||
) {
|
||||
if (response.isSuccessful) {
|
||||
Log.d(TAG, "FCM token updated successfully")
|
||||
} else {
|
||||
Log.e(TAG, "Failed to update FCM token. Response code: ${response.code()}")
|
||||
}
|
||||
}
|
||||
|
||||
override fun onFailure(call: Call<RegisterDeviceResponseDTO>, t: Throwable) {
|
||||
Log.e(TAG, "Error updating FCM token: ${t.message}")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private fun sendNotification(title: String, messageBody: String) {
|
||||
val intent = Intent(this, MainActivity::class.java).apply {
|
||||
addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP)
|
||||
}
|
||||
val pendingIntent = PendingIntent.getActivity(
|
||||
this, 0, intent, PendingIntent.FLAG_ONE_SHOT or PendingIntent.FLAG_IMMUTABLE
|
||||
)
|
||||
|
||||
val defaultSoundUri = RingtoneManager.getDefaultUri(RingtoneManager.TYPE_NOTIFICATION)
|
||||
val notificationBuilder = NotificationCompat.Builder(this, DEFAULT_NOTIFICATION_CHANNEL_ID)
|
||||
.setSmallIcon(R.drawable.ic_launcher_foreground)
|
||||
.setContentTitle(title)
|
||||
.setContentText(messageBody)
|
||||
.setAutoCancel(true)
|
||||
.setSound(defaultSoundUri)
|
||||
.setContentIntent(pendingIntent)
|
||||
|
||||
val notificationManager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
notificationManager.createNotificationChannel(
|
||||
NotificationChannel(
|
||||
DEFAULT_NOTIFICATION_CHANNEL_ID,
|
||||
"Channel human readable title",
|
||||
NotificationManager.IMPORTANCE_DEFAULT
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
notificationManager.notify(0, notificationBuilder.build())
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,8 @@ import com.vernu.sms.dtos.SMSDTO;
|
||||
import com.vernu.sms.dtos.SMSForwardResponseDTO;
|
||||
import com.vernu.sms.dtos.RegisterDeviceInputDTO;
|
||||
import com.vernu.sms.dtos.RegisterDeviceResponseDTO;
|
||||
import com.vernu.sms.dtos.HeartbeatInputDTO;
|
||||
import com.vernu.sms.dtos.HeartbeatResponseDTO;
|
||||
|
||||
import retrofit2.Call;
|
||||
import retrofit2.http.Body;
|
||||
@@ -24,4 +26,7 @@ public interface GatewayApiService {
|
||||
|
||||
@PATCH("gateway/devices/{deviceId}/sms-status")
|
||||
Call<SMSForwardResponseDTO> updateSMSStatus(@Path("deviceId") String deviceId, @Header("x-api-key") String apiKey, @Body() SMSDTO body);
|
||||
|
||||
@POST("gateway/devices/{deviceId}/heartbeat")
|
||||
Call<HeartbeatResponseDTO> heartbeat(@Path("deviceId") String deviceId, @Header("x-api-key") String apiKey, @Body() HeartbeatInputDTO body);
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package com.vernu.sms.services
|
||||
|
||||
import com.vernu.sms.dtos.GatewayStatsResponse
|
||||
import com.vernu.sms.dtos.MessagesResponse
|
||||
import com.vernu.sms.dtos.RegisterDeviceInputDTO
|
||||
import com.vernu.sms.dtos.RegisterDeviceResponseDTO
|
||||
import com.vernu.sms.dtos.SendSmsRequest
|
||||
import com.vernu.sms.dtos.SubscriptionResponse
|
||||
import com.vernu.sms.dtos.UserProfileWrapper
|
||||
import retrofit2.Response
|
||||
import retrofit2.http.*
|
||||
|
||||
interface GatewayApiServiceKt {
|
||||
|
||||
@GET("auth/who-am-i")
|
||||
suspend fun whoAmI(
|
||||
@Header("x-api-key") apiKey: String
|
||||
): Response<UserProfileWrapper>
|
||||
|
||||
@GET("gateway/stats")
|
||||
suspend fun getStats(
|
||||
@Header("x-api-key") apiKey: String
|
||||
): Response<GatewayStatsResponse>
|
||||
|
||||
@POST("gateway/devices")
|
||||
suspend fun registerDevice(
|
||||
@Header("x-api-key") apiKey: String,
|
||||
@Body body: RegisterDeviceInputDTO
|
||||
): Response<RegisterDeviceResponseDTO>
|
||||
|
||||
@GET("billing/current-subscription")
|
||||
suspend fun getCurrentSubscription(
|
||||
@Header("x-api-key") apiKey: String
|
||||
): Response<SubscriptionResponse>
|
||||
|
||||
@PATCH("gateway/devices/{deviceId}")
|
||||
suspend fun updateDevice(
|
||||
@Path("deviceId") deviceId: String,
|
||||
@Header("x-api-key") apiKey: String,
|
||||
@Body body: RegisterDeviceInputDTO
|
||||
): Response<RegisterDeviceResponseDTO>
|
||||
|
||||
@GET("gateway/devices/{deviceId}/messages")
|
||||
suspend fun getMessages(
|
||||
@Path("deviceId") deviceId: String,
|
||||
@Header("x-api-key") apiKey: String,
|
||||
@Query("page") page: Int,
|
||||
@Query("limit") limit: Int,
|
||||
@Query("type") type: String
|
||||
): Response<MessagesResponse>
|
||||
|
||||
@POST("gateway/devices/{deviceId}/send-sms")
|
||||
suspend fun sendSms(
|
||||
@Path("deviceId") deviceId: String,
|
||||
@Header("x-api-key") apiKey: String,
|
||||
@Body body: SendSmsRequest
|
||||
): Response<Any>
|
||||
}
|
||||
@@ -1,96 +0,0 @@
|
||||
package com.vernu.sms.services;
|
||||
|
||||
import android.app.*;
|
||||
import android.content.BroadcastReceiver;
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.content.IntentFilter;
|
||||
import android.os.IBinder;
|
||||
import android.provider.Telephony;
|
||||
import android.util.Log;
|
||||
import android.widget.Toast;
|
||||
|
||||
import androidx.core.app.NotificationCompat;
|
||||
|
||||
import com.vernu.sms.R;
|
||||
import com.vernu.sms.activities.MainActivity;
|
||||
import com.vernu.sms.receivers.SMSBroadcastReceiver;
|
||||
import com.vernu.sms.AppConstants;
|
||||
import com.vernu.sms.helpers.SharedPreferenceHelper;
|
||||
|
||||
public class StickyNotificationService extends Service {
|
||||
|
||||
private static final String TAG = "StickyNotificationService";
|
||||
|
||||
@Override
|
||||
public IBinder onBind(Intent intent) {
|
||||
Log.i(TAG, "Service onBind " + intent.getAction());
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onCreate() {
|
||||
super.onCreate();
|
||||
Log.i(TAG, "Service Started");
|
||||
|
||||
// Only show notification if enabled in preferences
|
||||
boolean stickyNotificationEnabled = SharedPreferenceHelper.getSharedPreferenceBoolean(
|
||||
getApplicationContext(),
|
||||
AppConstants.SHARED_PREFS_STICKY_NOTIFICATION_ENABLED_KEY,
|
||||
false
|
||||
);
|
||||
|
||||
if (stickyNotificationEnabled) {
|
||||
Notification notification = createNotification();
|
||||
startForeground(1, notification);
|
||||
Log.i(TAG, "Started foreground service with sticky notification");
|
||||
} else {
|
||||
Log.i(TAG, "Sticky notification disabled by user preference");
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public int onStartCommand(Intent intent, int flags, int startId) {
|
||||
Log.i(TAG, "Received start id " + startId + ": " + intent);
|
||||
return START_STICKY;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onDestroy() {
|
||||
super.onDestroy();
|
||||
|
||||
Log.i(TAG, "StickyNotificationService destroyed");
|
||||
}
|
||||
|
||||
private Notification createNotification() {
|
||||
String notificationChannelId = "stickyNotificationChannel";
|
||||
|
||||
NotificationManager notificationManager = (NotificationManager) getSystemService(Context.NOTIFICATION_SERVICE);
|
||||
NotificationChannel channel = null;
|
||||
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.O) {
|
||||
channel = new NotificationChannel(notificationChannelId, notificationChannelId, NotificationManager.IMPORTANCE_HIGH);
|
||||
channel.enableVibration(false);
|
||||
channel.setShowBadge(false);
|
||||
notificationManager.createNotificationChannel(channel);
|
||||
|
||||
Intent notificationIntent = new Intent(this, MainActivity.class);
|
||||
PendingIntent pendingIntent = PendingIntent.getActivity(this, 0, notificationIntent, PendingIntent.FLAG_IMMUTABLE | PendingIntent.FLAG_UPDATE_CURRENT);
|
||||
|
||||
Notification.Builder builder = new Notification.Builder(this, notificationChannelId);
|
||||
return builder.setContentTitle("TextBee Active")
|
||||
.setContentText("SMS gateway service is active")
|
||||
.setContentIntent(pendingIntent)
|
||||
.setOngoing(true)
|
||||
.setSmallIcon(R.mipmap.ic_launcher)
|
||||
.build();
|
||||
} else {
|
||||
NotificationCompat.Builder builder = new NotificationCompat.Builder(this, notificationChannelId);
|
||||
return builder.setContentTitle("TextBee Active")
|
||||
.setContentText("SMS gateway service is active")
|
||||
.setOngoing(true)
|
||||
.setSmallIcon(R.mipmap.ic_launcher)
|
||||
.build();
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
package com.vernu.sms.services
|
||||
|
||||
import android.app.*
|
||||
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
|
||||
import com.vernu.sms.AppConstants
|
||||
import com.vernu.sms.R
|
||||
import com.vernu.sms.activities.MainActivity
|
||||
import com.vernu.sms.helpers.SharedPreferenceHelper
|
||||
|
||||
class StickyNotificationService : Service() {
|
||||
companion object {
|
||||
private const val TAG = "StickyNotificationService"
|
||||
private const val NOTIFICATION_CHANNEL_ID = "stickyNotificationChannel"
|
||||
private const val NOTIFICATION_ID = 1
|
||||
}
|
||||
|
||||
override fun onBind(intent: Intent): IBinder? {
|
||||
Log.i(TAG, "Service onBind ${intent.action}")
|
||||
return null
|
||||
}
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
Log.i(TAG, "Service Started")
|
||||
|
||||
val stickyNotificationEnabled = SharedPreferenceHelper.getSharedPreferenceBoolean(
|
||||
applicationContext, AppConstants.SHARED_PREFS_STICKY_NOTIFICATION_ENABLED_KEY, false
|
||||
)
|
||||
|
||||
if (stickyNotificationEnabled) {
|
||||
val notification = createNotification()
|
||||
try {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
startForeground(NOTIFICATION_ID, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_REMOTE_MESSAGING)
|
||||
} else {
|
||||
startForeground(NOTIFICATION_ID, notification)
|
||||
}
|
||||
Log.i(TAG, "Started foreground service with sticky notification")
|
||||
} catch (e: Exception) {
|
||||
// ForegroundServiceStartNotAllowedException on API 31+ when app is in background
|
||||
Log.w(TAG, "Cannot start foreground service (likely background restriction): ${e.message}")
|
||||
stopSelf()
|
||||
}
|
||||
} else {
|
||||
Log.i(TAG, "Sticky notification disabled by user preference")
|
||||
}
|
||||
}
|
||||
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
Log.i(TAG, "Received start id $startId: $intent")
|
||||
return START_STICKY
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
super.onDestroy()
|
||||
Log.i(TAG, "StickyNotificationService destroyed")
|
||||
}
|
||||
|
||||
private fun createNotification(): Notification {
|
||||
val notificationManager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||
|
||||
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
val channel = NotificationChannel(
|
||||
NOTIFICATION_CHANNEL_ID, NOTIFICATION_CHANNEL_ID, NotificationManager.IMPORTANCE_HIGH
|
||||
).apply {
|
||||
enableVibration(false)
|
||||
setShowBadge(false)
|
||||
}
|
||||
notificationManager.createNotificationChannel(channel)
|
||||
|
||||
val pendingIntent = PendingIntent.getActivity(
|
||||
this, 0,
|
||||
Intent(this, MainActivity::class.java),
|
||||
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
|
||||
)
|
||||
|
||||
Notification.Builder(this, NOTIFICATION_CHANNEL_ID)
|
||||
.setContentTitle("TextBee Active")
|
||||
.setContentText("SMS gateway service is active")
|
||||
.setContentIntent(pendingIntent)
|
||||
.setOngoing(true)
|
||||
.setSmallIcon(R.mipmap.ic_launcher)
|
||||
.build()
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
NotificationCompat.Builder(this, NOTIFICATION_CHANNEL_ID)
|
||||
.setContentTitle("TextBee Active")
|
||||
.setContentText("SMS gateway service is active")
|
||||
.setOngoing(true)
|
||||
.setSmallIcon(R.mipmap.ic_launcher)
|
||||
.build()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,624 @@
|
||||
package com.vernu.sms.ui.dashboard
|
||||
|
||||
import android.Manifest
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.provider.Settings
|
||||
import android.widget.Toast
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.ContentCopy
|
||||
import androidx.compose.material.icons.filled.MenuBook
|
||||
import androidx.compose.material.icons.filled.OpenInBrowser
|
||||
import androidx.compose.material.icons.filled.Refresh
|
||||
import androidx.compose.material.icons.filled.Warning
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.scale
|
||||
import androidx.compose.ui.platform.LocalClipboardManager
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalLifecycleOwner
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.LifecycleEventObserver
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.vernu.sms.R
|
||||
import com.vernu.sms.dtos.SimInfoDTO
|
||||
import com.vernu.sms.dtos.SubscriptionResponse
|
||||
import com.vernu.sms.dtos.UserProfile
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Locale
|
||||
import java.util.TimeZone
|
||||
|
||||
private val REQUIRED_PERMISSIONS = listOf(
|
||||
Manifest.permission.SEND_SMS,
|
||||
Manifest.permission.RECEIVE_SMS,
|
||||
Manifest.permission.READ_PHONE_STATE
|
||||
)
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun DashboardScreen(
|
||||
viewModel: DashboardViewModel = viewModel()
|
||||
) {
|
||||
val state by viewModel.state.collectAsState()
|
||||
val context = LocalContext.current
|
||||
|
||||
LaunchedEffect(state.userMessage) {
|
||||
state.userMessage?.let { message ->
|
||||
Toast.makeText(context, message, Toast.LENGTH_LONG).show()
|
||||
viewModel.consumeUserMessage()
|
||||
}
|
||||
}
|
||||
|
||||
fun checkMissingPermissions() = REQUIRED_PERMISSIONS.filter {
|
||||
ContextCompat.checkSelfPermission(context, it) != PackageManager.PERMISSION_GRANTED
|
||||
}
|
||||
|
||||
var missingPermissions by remember { mutableStateOf(checkMissingPermissions()) }
|
||||
var permissionsDenied by remember { mutableStateOf(false) }
|
||||
|
||||
val permissionLauncher = rememberLauncherForActivityResult(
|
||||
ActivityResultContracts.RequestMultiplePermissions()
|
||||
) {
|
||||
val stillMissing = checkMissingPermissions()
|
||||
missingPermissions = stillMissing
|
||||
if (stillMissing.isNotEmpty()) permissionsDenied = true
|
||||
}
|
||||
|
||||
val lifecycleOwner = LocalLifecycleOwner.current
|
||||
DisposableEffect(lifecycleOwner) {
|
||||
val observer = LifecycleEventObserver { _, event ->
|
||||
if (event == Lifecycle.Event.ON_RESUME) {
|
||||
missingPermissions = checkMissingPermissions()
|
||||
if (missingPermissions.isEmpty()) permissionsDenied = false
|
||||
}
|
||||
}
|
||||
lifecycleOwner.lifecycle.addObserver(observer)
|
||||
onDispose { lifecycleOwner.lifecycle.removeObserver(observer) }
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Image(
|
||||
painter = painterResource(id = R.drawable.ic_app_logo),
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(28.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Column {
|
||||
Text(
|
||||
text = "textbee.dev",
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
val greeting = state.userProfile?.name?.takeIf { it.isNotBlank() }
|
||||
?: state.userProfile?.email?.takeIf { it.isNotBlank() }
|
||||
if (greeting != null) {
|
||||
Text(
|
||||
text = "Hello, $greeting",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
actions = {
|
||||
IconButton(onClick = { viewModel.refresh() }) {
|
||||
Icon(Icons.Default.Refresh, contentDescription = "Refresh")
|
||||
}
|
||||
},
|
||||
colors = TopAppBarDefaults.topAppBarColors(
|
||||
containerColor = MaterialTheme.colorScheme.surface
|
||||
)
|
||||
)
|
||||
}
|
||||
) { padding ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(padding)
|
||||
.padding(horizontal = 16.dp)
|
||||
.verticalScroll(rememberScrollState()),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
if (missingPermissions.isNotEmpty()) {
|
||||
PermissionWarningCard(
|
||||
missingPermissions = missingPermissions,
|
||||
showOpenSettings = permissionsDenied,
|
||||
onGrant = { permissionLauncher.launch(missingPermissions.toTypedArray()) },
|
||||
onOpenSettings = {
|
||||
context.startActivity(
|
||||
Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply {
|
||||
data = Uri.fromParts("package", context.packageName, null)
|
||||
}
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
DeviceStatusCard(
|
||||
state = state,
|
||||
onToggle = { viewModel.toggleGateway(it) },
|
||||
onReceiveSmsToggle = { viewModel.setReceiveSms(it) }
|
||||
)
|
||||
SubscriptionCard(
|
||||
subscription = state.subscription,
|
||||
isLoading = state.isSubscriptionLoading,
|
||||
unavailable = state.subscriptionUnavailable
|
||||
)
|
||||
QuickActionsSection()
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DeviceStatusCard(
|
||||
state: DashboardState,
|
||||
onToggle: (Boolean) -> Unit,
|
||||
onReceiveSmsToggle: (Boolean) -> Unit
|
||||
) {
|
||||
val clipboard = LocalClipboardManager.current
|
||||
val statusColor = if (state.isGatewayEnabled) MaterialTheme.colorScheme.primary
|
||||
else MaterialTheme.colorScheme.onSurfaceVariant
|
||||
val statusText = if (state.isGatewayEnabled) "Enabled" else "Disabled"
|
||||
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface),
|
||||
elevation = CardDefaults.cardElevation(defaultElevation = 2.dp)
|
||||
) {
|
||||
Column(modifier = Modifier.padding(20.dp)) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
val hardwareModel = "${Build.BRAND.replaceFirstChar { it.uppercase() }} ${Build.MODEL}"
|
||||
val customName = state.deviceName.trim()
|
||||
val displayName = customName.ifEmpty { hardwareModel }
|
||||
val showModel = customName.isNotEmpty() &&
|
||||
customName.lowercase() != hardwareModel.lowercase()
|
||||
|
||||
Column(modifier = Modifier.weight(1f).padding(end = 12.dp)) {
|
||||
Text(
|
||||
text = displayName,
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
fontWeight = FontWeight.SemiBold
|
||||
)
|
||||
if (showModel) {
|
||||
Text(
|
||||
text = hardwareModel,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
if (state.deviceId.isNotEmpty()) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
text = state.deviceId,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f)
|
||||
)
|
||||
IconButton(
|
||||
onClick = { clipboard.setText(AnnotatedString(state.deviceId)) },
|
||||
modifier = Modifier.size(20.dp)
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.ContentCopy,
|
||||
contentDescription = "Copy Device ID",
|
||||
modifier = Modifier.size(12.dp),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Switch(
|
||||
checked = state.isGatewayEnabled,
|
||||
onCheckedChange = onToggle,
|
||||
enabled = !state.isTogglingGateway
|
||||
)
|
||||
Text(
|
||||
text = "Gateway",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
if (!state.isGatewayEnabled) {
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
Surface(
|
||||
color = statusColor.copy(alpha = 0.15f),
|
||||
shape = MaterialTheme.shapes.small
|
||||
) {
|
||||
Text(
|
||||
text = statusText,
|
||||
modifier = Modifier.padding(horizontal = 10.dp, vertical = 4.dp),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = statusColor,
|
||||
fontWeight = FontWeight.SemiBold
|
||||
)
|
||||
}
|
||||
}
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Column(modifier = Modifier.weight(1f).padding(end = 8.dp)) {
|
||||
Text(
|
||||
text = "Receive SMS",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
Text(
|
||||
text = "Received messages appear in your dashboard and API",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f)
|
||||
)
|
||||
}
|
||||
Switch(
|
||||
checked = state.isReceiveSmsEnabled,
|
||||
onCheckedChange = onReceiveSmsToggle,
|
||||
modifier = Modifier.scale(0.75f)
|
||||
)
|
||||
}
|
||||
if (state.availableSims.isNotEmpty()) {
|
||||
SimCardsSection(sims = state.availableSims)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PermissionWarningCard(
|
||||
missingPermissions: List<String>,
|
||||
showOpenSettings: Boolean,
|
||||
onGrant: () -> Unit,
|
||||
onOpenSettings: () -> Unit
|
||||
) {
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.errorContainer),
|
||||
elevation = CardDefaults.cardElevation(defaultElevation = 2.dp)
|
||||
) {
|
||||
Column(modifier = Modifier.padding(16.dp)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Icon(
|
||||
Icons.Default.Warning,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.error,
|
||||
modifier = Modifier.size(18.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text(
|
||||
text = "Permissions Required",
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = MaterialTheme.colorScheme.onErrorContainer
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.height(6.dp))
|
||||
Text(
|
||||
text = "The gateway won't work without: ${missingPermissions.joinToString { friendlyPermissionName(it) }}",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onErrorContainer
|
||||
)
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
Button(
|
||||
onClick = if (showOpenSettings) onOpenSettings else onGrant,
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = MaterialTheme.colorScheme.error,
|
||||
contentColor = MaterialTheme.colorScheme.onError
|
||||
)
|
||||
) {
|
||||
Text(if (showOpenSettings) "Open App Settings" else "Grant Permissions")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun friendlyPermissionName(permission: String) = when (permission) {
|
||||
Manifest.permission.SEND_SMS -> "Send SMS"
|
||||
Manifest.permission.RECEIVE_SMS -> "Receive SMS"
|
||||
Manifest.permission.READ_PHONE_STATE -> "Phone State"
|
||||
else -> permission.substringAfterLast(".")
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SimCardsSection(sims: List<SimInfoDTO>) {
|
||||
val context = LocalContext.current
|
||||
val clipboard = LocalClipboardManager.current
|
||||
|
||||
Divider(modifier = Modifier.padding(top = 10.dp, bottom = 2.dp))
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
text = "SIM Cards",
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
Text(
|
||||
text = "simSubscriptionId",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f)
|
||||
)
|
||||
}
|
||||
sims.forEach { sim ->
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
text = "SIM ${(sim.simSlotIndex ?: 0) + 1} · ${sim.carrierName ?: sim.displayName ?: "Unknown"}",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
text = sim.subscriptionId.toString(),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
IconButton(
|
||||
onClick = {
|
||||
clipboard.setText(AnnotatedString(sim.subscriptionId.toString()))
|
||||
Toast.makeText(context, "Subscription ID copied", Toast.LENGTH_SHORT).show()
|
||||
},
|
||||
modifier = Modifier.size(28.dp)
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.ContentCopy,
|
||||
contentDescription = "Copy subscription ID",
|
||||
modifier = Modifier.size(13.dp),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SubscriptionCard(
|
||||
subscription: SubscriptionResponse?,
|
||||
isLoading: Boolean,
|
||||
unavailable: Boolean
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface),
|
||||
elevation = CardDefaults.cardElevation(defaultElevation = 2.dp)
|
||||
) {
|
||||
when {
|
||||
isLoading -> {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(24.dp),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
CircularProgressIndicator(modifier = Modifier.size(24.dp))
|
||||
}
|
||||
}
|
||||
unavailable || subscription == null -> {
|
||||
// silently hide rather than show an error card
|
||||
}
|
||||
else -> {
|
||||
Column(modifier = Modifier.padding(20.dp)) {
|
||||
val planName = subscription.plan?.name?.uppercase() ?: "FREE"
|
||||
val isFree = subscription.plan?.name?.lowercase() == "free" ||
|
||||
subscription.plan?.name == null
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Column {
|
||||
Text(
|
||||
text = "Subscription",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.SemiBold
|
||||
)
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Surface(
|
||||
color = if (isFree) MaterialTheme.colorScheme.surfaceVariant
|
||||
else MaterialTheme.colorScheme.primary.copy(alpha = 0.15f),
|
||||
shape = MaterialTheme.shapes.small
|
||||
) {
|
||||
Text(
|
||||
text = planName,
|
||||
modifier = Modifier.padding(horizontal = 8.dp, vertical = 3.dp),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = if (isFree) MaterialTheme.colorScheme.onSurfaceVariant
|
||||
else MaterialTheme.colorScheme.primary
|
||||
)
|
||||
}
|
||||
}
|
||||
if (isFree) {
|
||||
OutlinedButton(
|
||||
onClick = {
|
||||
context.startActivity(
|
||||
Intent(Intent.ACTION_VIEW, Uri.parse("https://textbee.dev/pricing"))
|
||||
)
|
||||
}
|
||||
) {
|
||||
Text("Upgrade")
|
||||
}
|
||||
} else {
|
||||
TextButton(
|
||||
onClick = {
|
||||
context.startActivity(
|
||||
Intent(Intent.ACTION_VIEW, Uri.parse("https://app.textbee.dev/dashboard/account"))
|
||||
)
|
||||
}
|
||||
) {
|
||||
Text("Manage")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (isFree) {
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Text(
|
||||
text = "Unlock higher limits, more devices & priority support",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
} else {
|
||||
subscription.usage?.let { usage ->
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
UsageRow(
|
||||
label = "Today",
|
||||
used = usage.processedSmsToday,
|
||||
limit = usage.dailyLimit,
|
||||
pct = usage.dailyUsagePercentage
|
||||
)
|
||||
Spacer(modifier = Modifier.height(10.dp))
|
||||
UsageRow(
|
||||
label = "This month",
|
||||
used = usage.processedSmsLastMonth,
|
||||
limit = usage.monthlyLimit,
|
||||
pct = usage.monthlyUsagePercentage
|
||||
)
|
||||
}
|
||||
|
||||
subscription.currentPeriodEnd?.let { dateStr ->
|
||||
formatDate(dateStr)?.let { formatted ->
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
Text(
|
||||
text = "Renews $formatted",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun UsageRow(label: String, used: Int?, limit: Int?, pct: Int?) {
|
||||
val isUnlimited = limit == -1
|
||||
val progress = if (isUnlimited || limit == null || limit == 0) 0f
|
||||
else (pct ?: 0).coerceIn(0, 100) / 100f
|
||||
|
||||
Column {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
Text(text = label, style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
Text(
|
||||
text = if (isUnlimited) "${used ?: 0} / Unlimited"
|
||||
else "${used ?: 0} / ${limit ?: "—"}",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
fontWeight = FontWeight.Medium
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
if (!isUnlimited && limit != null && limit > 0) {
|
||||
LinearProgressIndicator(
|
||||
progress = progress,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(6.dp),
|
||||
color = if (progress > 0.8f) MaterialTheme.colorScheme.error
|
||||
else MaterialTheme.colorScheme.primary,
|
||||
trackColor = MaterialTheme.colorScheme.surfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun QuickActionsSection() {
|
||||
val context = LocalContext.current
|
||||
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Text(
|
||||
text = "Quick Actions",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.SemiBold
|
||||
)
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
OutlinedButton(
|
||||
onClick = {
|
||||
context.startActivity(
|
||||
Intent(Intent.ACTION_VIEW, Uri.parse("https://app.textbee.dev/dashboard"))
|
||||
)
|
||||
},
|
||||
modifier = Modifier.weight(1f)
|
||||
) {
|
||||
Icon(Icons.Default.OpenInBrowser, contentDescription = null, modifier = Modifier.size(16.dp))
|
||||
Spacer(modifier = Modifier.width(6.dp))
|
||||
Text("Dashboard")
|
||||
}
|
||||
OutlinedButton(
|
||||
onClick = {
|
||||
context.startActivity(
|
||||
Intent(Intent.ACTION_VIEW, Uri.parse("https://textbee.dev/docs"))
|
||||
)
|
||||
},
|
||||
modifier = Modifier.weight(1f)
|
||||
) {
|
||||
Icon(Icons.Default.MenuBook, contentDescription = null, modifier = Modifier.size(16.dp))
|
||||
Spacer(modifier = Modifier.width(6.dp))
|
||||
Text("Explore Docs")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun formatDate(isoDate: String): String? {
|
||||
return try {
|
||||
val sdf = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss", Locale.getDefault())
|
||||
sdf.timeZone = TimeZone.getTimeZone("UTC")
|
||||
val date = sdf.parse(isoDate.take(19)) ?: return null
|
||||
SimpleDateFormat("MMM d, yyyy", Locale.getDefault()).format(date)
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
package com.vernu.sms.ui.dashboard
|
||||
|
||||
import android.app.Application
|
||||
import androidx.lifecycle.AndroidViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.vernu.sms.ApiManagerKt
|
||||
import com.vernu.sms.AppConstants
|
||||
import com.vernu.sms.TextBeeUtils
|
||||
import com.vernu.sms.dtos.RegisterDeviceInputDTO
|
||||
import com.vernu.sms.dtos.SimInfoDTO
|
||||
import com.vernu.sms.dtos.SubscriptionResponse
|
||||
import com.vernu.sms.dtos.UserProfile
|
||||
import com.vernu.sms.helpers.HeartbeatManager
|
||||
import com.vernu.sms.helpers.SharedPreferenceHelper
|
||||
import com.vernu.sms.helpers.serverErrorMessage
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
data class DashboardState(
|
||||
val deviceName: String = "",
|
||||
val deviceId: String = "",
|
||||
val isGatewayEnabled: Boolean = false,
|
||||
val lastHeartbeatMs: Long? = null,
|
||||
val isTogglingGateway: Boolean = false,
|
||||
val subscription: SubscriptionResponse? = null,
|
||||
val isSubscriptionLoading: Boolean = true,
|
||||
val subscriptionUnavailable: Boolean = false,
|
||||
val userProfile: UserProfile? = null,
|
||||
val availableSims: List<SimInfoDTO> = emptyList(),
|
||||
val isReceiveSmsEnabled: Boolean = false,
|
||||
val userMessage: String? = null
|
||||
)
|
||||
|
||||
class DashboardViewModel(app: Application) : AndroidViewModel(app) {
|
||||
|
||||
private val context get() = getApplication<Application>().applicationContext
|
||||
|
||||
private val _state = MutableStateFlow(DashboardState())
|
||||
val state: StateFlow<DashboardState> = _state.asStateFlow()
|
||||
|
||||
init {
|
||||
loadLocalState()
|
||||
fetchSubscription()
|
||||
fetchUserProfile()
|
||||
}
|
||||
|
||||
fun refresh() {
|
||||
loadLocalState()
|
||||
fetchSubscription()
|
||||
fetchUserProfile()
|
||||
}
|
||||
|
||||
private fun loadLocalState() {
|
||||
val deviceName = SharedPreferenceHelper.getSharedPreferenceString(
|
||||
context, AppConstants.SHARED_PREFS_DEVICE_NAME_KEY, ""
|
||||
) ?: ""
|
||||
val deviceId = SharedPreferenceHelper.getSharedPreferenceString(
|
||||
context, AppConstants.SHARED_PREFS_DEVICE_ID_KEY, ""
|
||||
) ?: ""
|
||||
val isEnabled = SharedPreferenceHelper.getSharedPreferenceBoolean(
|
||||
context, AppConstants.SHARED_PREFS_GATEWAY_ENABLED_KEY, false
|
||||
)
|
||||
val lastHeartbeatStr = SharedPreferenceHelper.getSharedPreferenceString(
|
||||
context, AppConstants.SHARED_PREFS_LAST_HEARTBEAT_MS_KEY, ""
|
||||
)
|
||||
val lastHeartbeatMs = lastHeartbeatStr?.toLongOrNull()
|
||||
|
||||
val sims = try {
|
||||
TextBeeUtils.collectSimInfo(context)
|
||||
} catch (e: Exception) {
|
||||
emptyList<SimInfoDTO>()
|
||||
}
|
||||
val isReceiveSms = SharedPreferenceHelper.getSharedPreferenceBoolean(
|
||||
context, AppConstants.SHARED_PREFS_RECEIVE_SMS_ENABLED_KEY, false
|
||||
)
|
||||
|
||||
_state.update {
|
||||
it.copy(
|
||||
deviceName = deviceName,
|
||||
deviceId = deviceId,
|
||||
isGatewayEnabled = isEnabled,
|
||||
lastHeartbeatMs = lastHeartbeatMs,
|
||||
availableSims = sims,
|
||||
isReceiveSmsEnabled = isReceiveSms
|
||||
)
|
||||
}
|
||||
|
||||
// Restart sticky notification service on every launch if it should be running,
|
||||
// matching legacy MainActivity behaviour (service is killed by OS on modern Android).
|
||||
if (isEnabled) {
|
||||
TextBeeUtils.startStickyNotificationService(context)
|
||||
if (deviceId.isNotEmpty()) HeartbeatManager.scheduleHeartbeat(context)
|
||||
}
|
||||
}
|
||||
|
||||
private fun fetchUserProfile() {
|
||||
val apiKey = SharedPreferenceHelper.getSharedPreferenceString(
|
||||
context, AppConstants.SHARED_PREFS_API_KEY_KEY, ""
|
||||
) ?: ""
|
||||
if (apiKey.isEmpty()) return
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
val response = ApiManagerKt.getApiService().whoAmI(apiKey)
|
||||
if (response.isSuccessful) {
|
||||
_state.update { it.copy(userProfile = response.body()?.data) }
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
TextBeeUtils.logException(e, "User profile fetch failed")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun fetchSubscription() {
|
||||
val apiKey = SharedPreferenceHelper.getSharedPreferenceString(
|
||||
context, AppConstants.SHARED_PREFS_API_KEY_KEY, ""
|
||||
) ?: ""
|
||||
if (apiKey.isEmpty()) {
|
||||
_state.update { it.copy(isSubscriptionLoading = false, subscriptionUnavailable = true) }
|
||||
return
|
||||
}
|
||||
viewModelScope.launch {
|
||||
_state.update { it.copy(isSubscriptionLoading = true, subscriptionUnavailable = false) }
|
||||
try {
|
||||
val response = ApiManagerKt.getApiService().getCurrentSubscription(apiKey)
|
||||
if (response.isSuccessful) {
|
||||
_state.update { it.copy(isSubscriptionLoading = false, subscription = response.body()) }
|
||||
} else {
|
||||
_state.update { it.copy(isSubscriptionLoading = false, subscriptionUnavailable = true) }
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
_state.update { it.copy(isSubscriptionLoading = false, subscriptionUnavailable = true) }
|
||||
TextBeeUtils.logException(e, "Subscription fetch failed")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun consumeUserMessage() {
|
||||
_state.update { it.copy(userMessage = null) }
|
||||
}
|
||||
|
||||
fun setReceiveSms(enabled: Boolean) {
|
||||
SharedPreferenceHelper.setSharedPreferenceBoolean(
|
||||
context, AppConstants.SHARED_PREFS_RECEIVE_SMS_ENABLED_KEY, enabled
|
||||
)
|
||||
_state.update { it.copy(isReceiveSmsEnabled = enabled) }
|
||||
}
|
||||
|
||||
fun toggleGateway(enabled: Boolean) {
|
||||
val deviceId = _state.value.deviceId
|
||||
val apiKey = SharedPreferenceHelper.getSharedPreferenceString(
|
||||
context, AppConstants.SHARED_PREFS_API_KEY_KEY, ""
|
||||
) ?: ""
|
||||
if (deviceId.isEmpty() || apiKey.isEmpty()) return
|
||||
|
||||
viewModelScope.launch {
|
||||
_state.update { it.copy(isTogglingGateway = true) }
|
||||
try {
|
||||
val input = RegisterDeviceInputDTO().apply { this.enabled = enabled }
|
||||
val response = ApiManagerKt.getApiService().updateDevice(deviceId, apiKey, input)
|
||||
if (response.isSuccessful) {
|
||||
SharedPreferenceHelper.setSharedPreferenceBoolean(
|
||||
context, AppConstants.SHARED_PREFS_GATEWAY_ENABLED_KEY, enabled
|
||||
)
|
||||
_state.update { it.copy(isGatewayEnabled = enabled) }
|
||||
try {
|
||||
if (enabled) {
|
||||
if (SharedPreferenceHelper.getSharedPreferenceBoolean(
|
||||
context, AppConstants.SHARED_PREFS_STICKY_NOTIFICATION_ENABLED_KEY, false
|
||||
)
|
||||
) {
|
||||
TextBeeUtils.startStickyNotificationService(context)
|
||||
}
|
||||
HeartbeatManager.scheduleHeartbeat(context)
|
||||
} else {
|
||||
TextBeeUtils.stopStickyNotificationService(context)
|
||||
HeartbeatManager.cancelHeartbeat(context)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
TextBeeUtils.logException(e, "Gateway service toggle failed")
|
||||
}
|
||||
_state.update {
|
||||
it.copy(
|
||||
userMessage = if (enabled) "Gateway enabled" else "Gateway disabled"
|
||||
)
|
||||
}
|
||||
} else {
|
||||
// Surface the server's reason (e.g. device-limit 429) so the
|
||||
// toggle doesn't silently snap back with no explanation.
|
||||
val message = response.serverErrorMessage()
|
||||
?: if (response.code() == 429) {
|
||||
"You've reached your plan's device limit. Disable or remove another device, or upgrade your plan."
|
||||
} else {
|
||||
"Couldn't update the gateway. Please try again."
|
||||
}
|
||||
_state.update { it.copy(userMessage = message) }
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
TextBeeUtils.logException(e, "Gateway toggle failed")
|
||||
_state.update { it.copy(userMessage = "Couldn't update the gateway. Please check your connection.") }
|
||||
} finally {
|
||||
_state.update { it.copy(isTogglingGateway = false) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
package com.vernu.sms.ui.main
|
||||
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Dashboard
|
||||
import androidx.compose.material.icons.filled.Message
|
||||
import androidx.compose.material.icons.filled.Settings
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.navigation.NavGraph.Companion.findStartDestination
|
||||
import androidx.navigation.NavHostController
|
||||
import androidx.navigation.compose.NavHost
|
||||
import androidx.navigation.compose.composable
|
||||
import androidx.navigation.compose.currentBackStackEntryAsState
|
||||
import androidx.navigation.compose.rememberNavController
|
||||
import com.vernu.sms.AppConstants
|
||||
import com.vernu.sms.activities.MainActivity
|
||||
import com.vernu.sms.helpers.HeartbeatManager
|
||||
import com.vernu.sms.helpers.SharedPreferenceHelper
|
||||
import com.vernu.sms.ui.dashboard.DashboardScreen
|
||||
import com.vernu.sms.ui.messages.ComposeScreen
|
||||
import com.vernu.sms.ui.messages.MessagesScreen
|
||||
import com.vernu.sms.ui.onboarding.OnboardingActivity
|
||||
import com.vernu.sms.ui.settings.SMSFilterScreen
|
||||
import com.vernu.sms.ui.settings.SettingsScreen
|
||||
import com.vernu.sms.ui.theme.TextBeeTheme
|
||||
|
||||
enum class MainDestination(val label: String, val icon: ImageVector) {
|
||||
DASHBOARD("Dashboard", Icons.Default.Dashboard),
|
||||
MESSAGES("Messages", Icons.Default.Message),
|
||||
SETTINGS("Settings", Icons.Default.Settings)
|
||||
}
|
||||
|
||||
class NewMainActivity : ComponentActivity() {
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
setContent {
|
||||
TextBeeTheme {
|
||||
val navController = rememberNavController()
|
||||
MainScaffold(
|
||||
navController = navController,
|
||||
onSwitchToLegacy = {
|
||||
SharedPreferenceHelper.setSharedPreferenceBoolean(
|
||||
this, AppConstants.SHARED_PREFS_USE_NEW_UI_KEY, false
|
||||
)
|
||||
startActivity(
|
||||
Intent(this, MainActivity::class.java).apply {
|
||||
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
|
||||
}
|
||||
)
|
||||
},
|
||||
onDisconnect = {
|
||||
listOf(
|
||||
AppConstants.SHARED_PREFS_DEVICE_ID_KEY,
|
||||
AppConstants.SHARED_PREFS_API_KEY_KEY,
|
||||
AppConstants.SHARED_PREFS_GATEWAY_ENABLED_KEY,
|
||||
AppConstants.SHARED_PREFS_DEVICE_NAME_KEY,
|
||||
AppConstants.SHARED_PREFS_LAST_HEARTBEAT_MS_KEY
|
||||
).forEach { key ->
|
||||
SharedPreferenceHelper.clearSharedPreference(this, key)
|
||||
}
|
||||
HeartbeatManager.cancelHeartbeat(this)
|
||||
startActivity(
|
||||
Intent(this, OnboardingActivity::class.java).apply {
|
||||
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
|
||||
}
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MainScaffold(
|
||||
navController: NavHostController,
|
||||
onSwitchToLegacy: () -> Unit,
|
||||
onDisconnect: () -> Unit
|
||||
) {
|
||||
val backStackEntry by navController.currentBackStackEntryAsState()
|
||||
val currentRoute = backStackEntry?.destination?.route
|
||||
val showBottomBar = currentRoute != "compose" && currentRoute != "filters"
|
||||
|
||||
Scaffold(
|
||||
bottomBar = {
|
||||
if (showBottomBar) {
|
||||
NavigationBar(
|
||||
containerColor = MaterialTheme.colorScheme.surface,
|
||||
tonalElevation = 4.dp
|
||||
) {
|
||||
MainDestination.values().forEach { dest ->
|
||||
val selected = currentRoute == dest.name
|
||||
NavigationBarItem(
|
||||
selected = selected,
|
||||
onClick = {
|
||||
navController.navigate(dest.name) {
|
||||
popUpTo(navController.graph.findStartDestination().id) {
|
||||
saveState = true
|
||||
}
|
||||
launchSingleTop = true
|
||||
restoreState = true
|
||||
}
|
||||
},
|
||||
icon = {
|
||||
Icon(
|
||||
dest.icon,
|
||||
contentDescription = dest.label,
|
||||
modifier = Modifier.size(if (selected) 26.dp else 22.dp)
|
||||
)
|
||||
},
|
||||
label = {
|
||||
Text(
|
||||
dest.label,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
fontWeight = if (selected) FontWeight.Bold else FontWeight.Normal
|
||||
)
|
||||
},
|
||||
colors = NavigationBarItemDefaults.colors(
|
||||
selectedIconColor = MaterialTheme.colorScheme.primary,
|
||||
selectedTextColor = MaterialTheme.colorScheme.primary,
|
||||
indicatorColor = MaterialTheme.colorScheme.surfaceVariant,
|
||||
unselectedIconColor = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
unselectedTextColor = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
) { innerPadding ->
|
||||
NavHost(
|
||||
navController = navController,
|
||||
startDestination = MainDestination.DASHBOARD.name,
|
||||
modifier = Modifier.padding(innerPadding)
|
||||
) {
|
||||
composable(MainDestination.DASHBOARD.name) {
|
||||
DashboardScreen()
|
||||
}
|
||||
composable(MainDestination.MESSAGES.name) {
|
||||
MessagesScreen(
|
||||
onNavigateToCompose = { navController.navigate("compose") }
|
||||
)
|
||||
}
|
||||
composable(MainDestination.SETTINGS.name) {
|
||||
SettingsScreen(
|
||||
onSwitchToLegacy = onSwitchToLegacy,
|
||||
onNavigateToFilters = { navController.navigate("filters") },
|
||||
onDisconnect = onDisconnect
|
||||
)
|
||||
}
|
||||
composable("compose") {
|
||||
ComposeScreen(
|
||||
onNavigateBack = { navController.popBackStack() }
|
||||
)
|
||||
}
|
||||
composable("filters") {
|
||||
SMSFilterScreen(onNavigateBack = { navController.popBackStack() })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
package com.vernu.sms.ui.messages
|
||||
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material.icons.filled.Send
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
fun ComposeScreen(
|
||||
viewModel: ComposeViewModel = viewModel(),
|
||||
onNavigateBack: () -> Unit
|
||||
) {
|
||||
val state by viewModel.state.collectAsState()
|
||||
var recipientInput by remember { mutableStateOf("") }
|
||||
val snackbarHostState = remember { SnackbarHostState() }
|
||||
|
||||
LaunchedEffect(state.sendSuccess) {
|
||||
if (state.sendSuccess) {
|
||||
snackbarHostState.showSnackbar("Message sent successfully!")
|
||||
viewModel.clearSuccess()
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(state.sendError) {
|
||||
state.sendError?.let {
|
||||
snackbarHostState.showSnackbar(it)
|
||||
viewModel.clearError()
|
||||
}
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = {
|
||||
Text("New Message", fontWeight = FontWeight.SemiBold)
|
||||
},
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onNavigateBack) {
|
||||
Icon(Icons.Default.ArrowBack, contentDescription = "Back")
|
||||
}
|
||||
},
|
||||
colors = TopAppBarDefaults.topAppBarColors(
|
||||
containerColor = MaterialTheme.colorScheme.surface
|
||||
)
|
||||
)
|
||||
},
|
||||
snackbarHost = { SnackbarHost(snackbarHostState) }
|
||||
) { padding ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(padding)
|
||||
.padding(16.dp)
|
||||
.verticalScroll(rememberScrollState()),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
Text(
|
||||
text = "Recipients",
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
fontWeight = FontWeight.SemiBold
|
||||
)
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = recipientInput,
|
||||
onValueChange = { recipientInput = it },
|
||||
modifier = Modifier.weight(1f),
|
||||
label = { Text("Phone number") },
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Phone),
|
||||
singleLine = true
|
||||
)
|
||||
Button(
|
||||
onClick = {
|
||||
val trimmed = recipientInput.trim()
|
||||
if (trimmed.isNotEmpty() && !state.recipients.contains(trimmed)) {
|
||||
viewModel.addRecipient(trimmed)
|
||||
recipientInput = ""
|
||||
}
|
||||
},
|
||||
enabled = recipientInput.trim().isNotEmpty()
|
||||
) {
|
||||
Text("Add")
|
||||
}
|
||||
}
|
||||
|
||||
if (state.recipients.isNotEmpty()) {
|
||||
FlowRow(
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp)
|
||||
) {
|
||||
state.recipients.forEach { number ->
|
||||
InputChip(
|
||||
selected = false,
|
||||
onClick = {},
|
||||
label = { Text(number) },
|
||||
trailingIcon = {
|
||||
IconButton(
|
||||
onClick = { viewModel.removeRecipient(number) },
|
||||
modifier = Modifier.size(18.dp)
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.Close,
|
||||
contentDescription = "Remove",
|
||||
modifier = Modifier.size(14.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Divider()
|
||||
|
||||
Text(
|
||||
text = "Message",
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
fontWeight = FontWeight.SemiBold
|
||||
)
|
||||
|
||||
OutlinedTextField(
|
||||
value = state.message,
|
||||
onValueChange = { viewModel.setMessage(it) },
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(min = 120.dp),
|
||||
label = { Text("Type your message") },
|
||||
maxLines = 8,
|
||||
supportingText = {
|
||||
Text("${state.message.length} characters")
|
||||
}
|
||||
)
|
||||
|
||||
Button(
|
||||
onClick = { viewModel.sendSms() },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
enabled = !state.isSending &&
|
||||
state.recipients.isNotEmpty() &&
|
||||
state.message.isNotEmpty()
|
||||
) {
|
||||
if (state.isSending) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(18.dp),
|
||||
color = MaterialTheme.colorScheme.onPrimary,
|
||||
strokeWidth = 2.dp
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text("Sending...")
|
||||
} else {
|
||||
Icon(
|
||||
Icons.Default.Send,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(18.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text("Send Message")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
package com.vernu.sms.ui.messages
|
||||
|
||||
import android.app.Application
|
||||
import androidx.lifecycle.AndroidViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.vernu.sms.ApiManagerKt
|
||||
import com.vernu.sms.AppConstants
|
||||
import com.vernu.sms.dtos.SendSmsRequest
|
||||
import com.vernu.sms.helpers.SharedPreferenceHelper
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import org.json.JSONObject
|
||||
import retrofit2.Response
|
||||
|
||||
data class ComposeState(
|
||||
val recipients: List<String> = emptyList(),
|
||||
val message: String = "",
|
||||
val isSending: Boolean = false,
|
||||
val sendError: String? = null,
|
||||
val sendSuccess: Boolean = false
|
||||
)
|
||||
|
||||
class ComposeViewModel(app: Application) : AndroidViewModel(app) {
|
||||
|
||||
private val context get() = getApplication<Application>().applicationContext
|
||||
|
||||
private val _state = MutableStateFlow(ComposeState())
|
||||
val state: StateFlow<ComposeState> = _state.asStateFlow()
|
||||
|
||||
fun addRecipient(number: String) {
|
||||
_state.update { it.copy(recipients = it.recipients + number) }
|
||||
}
|
||||
|
||||
fun removeRecipient(number: String) {
|
||||
_state.update { it.copy(recipients = it.recipients - number) }
|
||||
}
|
||||
|
||||
fun setMessage(msg: String) {
|
||||
_state.update { it.copy(message = msg) }
|
||||
}
|
||||
|
||||
fun clearError() {
|
||||
_state.update { it.copy(sendError = null) }
|
||||
}
|
||||
|
||||
fun clearSuccess() {
|
||||
_state.update { it.copy(sendSuccess = false, message = "") }
|
||||
}
|
||||
|
||||
private fun extractErrorMessage(response: Response<*>): String {
|
||||
return try {
|
||||
val body = response.errorBody()?.string()
|
||||
if (!body.isNullOrBlank()) {
|
||||
val json = JSONObject(body)
|
||||
json.optString("message").takeIf { it.isNotBlank() }
|
||||
?: json.optString("error").takeIf { it.isNotBlank() }
|
||||
?: "Failed to send (${response.code()})"
|
||||
} else {
|
||||
"Failed to send (${response.code()})"
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
"Failed to send (${response.code()})"
|
||||
}
|
||||
}
|
||||
|
||||
fun sendSms() {
|
||||
val apiKey = SharedPreferenceHelper.getSharedPreferenceString(
|
||||
context, AppConstants.SHARED_PREFS_API_KEY_KEY, ""
|
||||
) ?: ""
|
||||
val deviceId = SharedPreferenceHelper.getSharedPreferenceString(
|
||||
context, AppConstants.SHARED_PREFS_DEVICE_ID_KEY, ""
|
||||
) ?: ""
|
||||
if (apiKey.isEmpty() || deviceId.isEmpty()) return
|
||||
|
||||
val s = _state.value
|
||||
if (s.recipients.isEmpty() || s.message.isEmpty()) return
|
||||
|
||||
viewModelScope.launch {
|
||||
_state.update { it.copy(isSending = true, sendError = null) }
|
||||
try {
|
||||
val response = ApiManagerKt.getApiService().sendSms(
|
||||
deviceId, apiKey, SendSmsRequest(s.message, s.recipients)
|
||||
)
|
||||
if (response.isSuccessful) {
|
||||
_state.update { it.copy(isSending = false, sendSuccess = true) }
|
||||
} else {
|
||||
_state.update {
|
||||
it.copy(isSending = false, sendError = extractErrorMessage(response))
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
_state.update {
|
||||
it.copy(isSending = false, sendError = "Network error. Please try again.")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,360 @@
|
||||
package com.vernu.sms.ui.messages
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.ArrowDownward
|
||||
import androidx.compose.material.icons.filled.ArrowUpward
|
||||
import androidx.compose.material.icons.filled.Create
|
||||
import androidx.compose.material.icons.filled.Forum
|
||||
import androidx.compose.material.icons.filled.Refresh
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.vernu.sms.dtos.SmsMessage
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Locale
|
||||
import java.util.TimeZone
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun MessagesScreen(
|
||||
viewModel: MessagesViewModel = viewModel(),
|
||||
onNavigateToCompose: () -> Unit = {}
|
||||
) {
|
||||
val state by viewModel.state.collectAsState()
|
||||
var selectedMessage by remember { mutableStateOf<SmsMessage?>(null) }
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Icon(
|
||||
Icons.Default.Forum,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text("Messages", fontWeight = FontWeight.SemiBold)
|
||||
}
|
||||
},
|
||||
actions = {
|
||||
IconButton(onClick = { viewModel.refresh() }) {
|
||||
Icon(Icons.Default.Refresh, contentDescription = "Refresh")
|
||||
}
|
||||
},
|
||||
colors = TopAppBarDefaults.topAppBarColors(
|
||||
containerColor = MaterialTheme.colorScheme.surface
|
||||
)
|
||||
)
|
||||
},
|
||||
floatingActionButton = {
|
||||
FloatingActionButton(
|
||||
onClick = onNavigateToCompose,
|
||||
containerColor = MaterialTheme.colorScheme.primary
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.Create,
|
||||
contentDescription = "Compose",
|
||||
tint = MaterialTheme.colorScheme.onPrimary
|
||||
)
|
||||
}
|
||||
}
|
||||
) { padding ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(padding)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
listOf("all" to "All", "sent" to "Sent", "received" to "Received").forEach { (value, label) ->
|
||||
FilterChip(
|
||||
selected = state.filter == value,
|
||||
onClick = { viewModel.setFilter(value) },
|
||||
label = { Text(label) }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
when {
|
||||
state.isLoading -> {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
CircularProgressIndicator()
|
||||
}
|
||||
}
|
||||
state.error != null -> {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Text(
|
||||
text = state.error ?: "Error",
|
||||
color = MaterialTheme.colorScheme.error
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Button(onClick = { viewModel.refresh() }) {
|
||||
Text("Retry")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
state.messages.isEmpty() -> {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Icon(
|
||||
Icons.Default.Forum,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(48.dp),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Text(
|
||||
text = "No messages yet",
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
else -> {
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
items(state.messages) { message ->
|
||||
MessageItem(
|
||||
message = message,
|
||||
onClick = { selectedMessage = message }
|
||||
)
|
||||
}
|
||||
|
||||
if (state.currentPage < state.totalPages) {
|
||||
item {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(8.dp),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
if (state.isLoadingMore) {
|
||||
CircularProgressIndicator(modifier = Modifier.size(24.dp))
|
||||
} else {
|
||||
OutlinedButton(onClick = { viewModel.loadMore() }) {
|
||||
Text("Load more")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
item { Spacer(modifier = Modifier.height(80.dp)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
selectedMessage?.let { msg ->
|
||||
MessageDetailDialog(message = msg, onDismiss = { selectedMessage = null })
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MessageItem(message: SmsMessage, onClick: () -> Unit) {
|
||||
val accentColor = if (message.isReceived) Color(0xFF4CAF50)
|
||||
else MaterialTheme.colorScheme.primary
|
||||
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable(onClick = onClick),
|
||||
elevation = CardDefaults.cardElevation(defaultElevation = 1.dp),
|
||||
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(IntrinsicSize.Min)
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.width(4.dp)
|
||||
.fillMaxHeight()
|
||||
.background(accentColor)
|
||||
)
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.padding(12.dp)
|
||||
.fillMaxWidth()
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Icon(
|
||||
imageVector = if (message.isReceived) Icons.Default.ArrowDownward
|
||||
else Icons.Default.ArrowUpward,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(14.dp),
|
||||
tint = accentColor
|
||||
)
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
Text(
|
||||
text = message.counterparty,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
fontWeight = FontWeight.SemiBold
|
||||
)
|
||||
}
|
||||
Text(
|
||||
text = formatRelativeTime(message.createdAt),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Text(
|
||||
text = message.message ?: "",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
if (!message.isReceived && message.status != null) {
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
StatusBadge(status = message.status)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun StatusBadge(status: String) {
|
||||
val (color, label) = when (status.lowercase()) {
|
||||
"delivered" -> MaterialTheme.colorScheme.tertiary to "Delivered"
|
||||
"sent" -> MaterialTheme.colorScheme.primary to "Sent"
|
||||
"failed" -> MaterialTheme.colorScheme.error to "Failed"
|
||||
else -> MaterialTheme.colorScheme.onSurfaceVariant to "Pending"
|
||||
}
|
||||
Surface(
|
||||
color = color.copy(alpha = 0.15f),
|
||||
shape = MaterialTheme.shapes.extraSmall
|
||||
) {
|
||||
Text(
|
||||
text = label,
|
||||
modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = color,
|
||||
fontWeight = FontWeight.SemiBold
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MessageDetailDialog(message: SmsMessage, onDismiss: () -> Unit) {
|
||||
val accentColor = if (message.isReceived) Color(0xFF4CAF50)
|
||||
else MaterialTheme.colorScheme.primary
|
||||
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Icon(
|
||||
imageVector = if (message.isReceived) Icons.Default.ArrowDownward
|
||||
else Icons.Default.ArrowUpward,
|
||||
contentDescription = null,
|
||||
tint = accentColor,
|
||||
modifier = Modifier.size(18.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text(
|
||||
text = if (message.isReceived) "Received from" else "Sent to",
|
||||
style = MaterialTheme.typography.titleMedium
|
||||
)
|
||||
}
|
||||
},
|
||||
text = {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Text(
|
||||
text = message.counterparty,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = accentColor
|
||||
)
|
||||
if (!message.isReceived && message.status != null) {
|
||||
StatusBadge(status = message.status)
|
||||
}
|
||||
Divider()
|
||||
Text(
|
||||
text = message.message ?: "",
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
val timestamp = if (message.isReceived) message.receivedAt else message.requestedAt
|
||||
timestamp?.let {
|
||||
Text(
|
||||
text = formatFullDate(it),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = onDismiss) { Text("Close") }
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private fun formatRelativeTime(isoDate: String?): String {
|
||||
if (isoDate == null) return ""
|
||||
return try {
|
||||
val sdf = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss", Locale.getDefault())
|
||||
sdf.timeZone = TimeZone.getTimeZone("UTC")
|
||||
val date = sdf.parse(isoDate.take(19)) ?: return ""
|
||||
val diffMs = System.currentTimeMillis() - date.time
|
||||
when {
|
||||
diffMs < 60_000 -> "Just now"
|
||||
diffMs < 3_600_000 -> "${diffMs / 60_000}m ago"
|
||||
diffMs < 86_400_000 -> "${diffMs / 3_600_000}h ago"
|
||||
diffMs < 604_800_000 -> "${diffMs / 86_400_000}d ago"
|
||||
else -> SimpleDateFormat("MMM d", Locale.getDefault()).format(date)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
""
|
||||
}
|
||||
}
|
||||
|
||||
private fun formatFullDate(isoDate: String): String {
|
||||
return try {
|
||||
val sdf = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss", Locale.getDefault())
|
||||
sdf.timeZone = TimeZone.getTimeZone("UTC")
|
||||
val date = sdf.parse(isoDate.take(19)) ?: return ""
|
||||
SimpleDateFormat("MMM d, yyyy 'at' h:mm a", Locale.getDefault()).format(date)
|
||||
} catch (e: Exception) {
|
||||
""
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
package com.vernu.sms.ui.messages
|
||||
|
||||
import android.app.Application
|
||||
import androidx.lifecycle.AndroidViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.vernu.sms.ApiManagerKt
|
||||
import com.vernu.sms.AppConstants
|
||||
import com.vernu.sms.dtos.SmsMessage
|
||||
import com.vernu.sms.helpers.SharedPreferenceHelper
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
data class MessagesState(
|
||||
val messages: List<SmsMessage> = emptyList(),
|
||||
val isLoading: Boolean = true,
|
||||
val isLoadingMore: Boolean = false,
|
||||
val error: String? = null,
|
||||
val filter: String = "all",
|
||||
val currentPage: Int = 1,
|
||||
val totalPages: Int = 1,
|
||||
val total: Int = 0
|
||||
)
|
||||
|
||||
class MessagesViewModel(app: Application) : AndroidViewModel(app) {
|
||||
|
||||
private val context get() = getApplication<Application>().applicationContext
|
||||
|
||||
private val _state = MutableStateFlow(MessagesState())
|
||||
val state: StateFlow<MessagesState> = _state.asStateFlow()
|
||||
|
||||
init {
|
||||
fetchMessages(reset = true)
|
||||
}
|
||||
|
||||
fun setFilter(filter: String) {
|
||||
_state.update { it.copy(filter = filter, currentPage = 1) }
|
||||
fetchMessages(reset = true)
|
||||
}
|
||||
|
||||
fun refresh() = fetchMessages(reset = true)
|
||||
|
||||
fun loadMore() {
|
||||
val s = _state.value
|
||||
if (s.isLoadingMore || s.currentPage >= s.totalPages) return
|
||||
_state.update { it.copy(currentPage = it.currentPage + 1) }
|
||||
fetchMessages(reset = false)
|
||||
}
|
||||
|
||||
private fun fetchMessages(reset: Boolean) {
|
||||
val apiKey = SharedPreferenceHelper.getSharedPreferenceString(
|
||||
context, AppConstants.SHARED_PREFS_API_KEY_KEY, ""
|
||||
) ?: ""
|
||||
val deviceId = SharedPreferenceHelper.getSharedPreferenceString(
|
||||
context, AppConstants.SHARED_PREFS_DEVICE_ID_KEY, ""
|
||||
) ?: ""
|
||||
if (apiKey.isEmpty() || deviceId.isEmpty()) {
|
||||
_state.update { it.copy(isLoading = false, error = "Device not connected") }
|
||||
return
|
||||
}
|
||||
|
||||
val page = if (reset) 1 else _state.value.currentPage
|
||||
val filter = _state.value.filter
|
||||
|
||||
viewModelScope.launch {
|
||||
if (reset) {
|
||||
_state.update { it.copy(isLoading = true, error = null) }
|
||||
} else {
|
||||
_state.update { it.copy(isLoadingMore = true) }
|
||||
}
|
||||
try {
|
||||
val response = ApiManagerKt.getApiService()
|
||||
.getMessages(deviceId, apiKey, page, 20, filter)
|
||||
if (response.isSuccessful) {
|
||||
val body = response.body()
|
||||
val newMessages = body?.data ?: emptyList()
|
||||
val meta = body?.meta
|
||||
_state.update {
|
||||
it.copy(
|
||||
messages = if (reset) newMessages else it.messages + newMessages,
|
||||
isLoading = false,
|
||||
isLoadingMore = false,
|
||||
error = null,
|
||||
currentPage = page,
|
||||
totalPages = meta?.totalPages ?: 1,
|
||||
total = meta?.total ?: 0
|
||||
)
|
||||
}
|
||||
} else {
|
||||
_state.update {
|
||||
it.copy(isLoading = false, isLoadingMore = false, error = "Failed to load messages")
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
_state.update {
|
||||
it.copy(isLoading = false, isLoadingMore = false, error = "Network error")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
package com.vernu.sms.ui.onboarding
|
||||
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.viewModels
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.navigation.NavHostController
|
||||
import androidx.navigation.compose.NavHost
|
||||
import androidx.navigation.compose.composable
|
||||
import androidx.navigation.compose.rememberNavController
|
||||
import com.journeyapps.barcodescanner.ScanContract
|
||||
import com.journeyapps.barcodescanner.ScanOptions
|
||||
import com.vernu.sms.ui.main.NewMainActivity
|
||||
import com.vernu.sms.ui.onboarding.screens.*
|
||||
import com.vernu.sms.ui.theme.TextBeeTheme
|
||||
|
||||
class OnboardingActivity : ComponentActivity() {
|
||||
|
||||
private val viewModel: OnboardingViewModel by viewModels()
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
val qrLauncher = registerForActivityResult(ScanContract()) { result ->
|
||||
result?.contents?.let { scanned ->
|
||||
viewModel.onQrScanned(scanned.trim())
|
||||
}
|
||||
}
|
||||
|
||||
setContent {
|
||||
TextBeeTheme {
|
||||
val navController = rememberNavController()
|
||||
OnboardingNavGraph(
|
||||
navController = navController,
|
||||
viewModel = viewModel,
|
||||
onScanQr = {
|
||||
qrLauncher.launch(ScanOptions().apply {
|
||||
setPrompt("Scan the QR code from textbee.dev/dashboard")
|
||||
setBeepEnabled(true)
|
||||
setOrientationLocked(false)
|
||||
})
|
||||
},
|
||||
onComplete = {
|
||||
val intent = Intent(this, NewMainActivity::class.java)
|
||||
intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
|
||||
startActivity(intent)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun OnboardingNavGraph(
|
||||
navController: NavHostController,
|
||||
viewModel: OnboardingViewModel,
|
||||
onScanQr: () -> Unit,
|
||||
onComplete: () -> Unit
|
||||
) {
|
||||
NavHost(navController = navController, startDestination = "welcome") {
|
||||
composable("welcome") {
|
||||
WelcomeScreen(
|
||||
onGetStarted = {
|
||||
viewModel.setReturningUser(false)
|
||||
navController.navigate("credentials")
|
||||
},
|
||||
onHaveDeviceId = {
|
||||
viewModel.setReturningUser(true)
|
||||
navController.navigate("credentials")
|
||||
}
|
||||
)
|
||||
}
|
||||
composable("credentials") {
|
||||
CredentialsScreen(
|
||||
viewModel = viewModel,
|
||||
onScanQr = onScanQr,
|
||||
onNext = { navController.navigate("device_setup") },
|
||||
onBack = { navController.popBackStack() }
|
||||
)
|
||||
}
|
||||
composable("device_setup") {
|
||||
DeviceSetupScreen(
|
||||
viewModel = viewModel,
|
||||
onSuccess = { navController.navigate("permissions") },
|
||||
onBack = { navController.popBackStack() }
|
||||
)
|
||||
}
|
||||
composable("permissions") {
|
||||
PermissionsScreen(
|
||||
onContinue = { navController.navigate("setup_complete") },
|
||||
onBack = { navController.popBackStack() }
|
||||
)
|
||||
}
|
||||
composable("setup_complete") {
|
||||
SetupCompleteScreen(
|
||||
viewModel = viewModel,
|
||||
onOpenDashboard = onComplete
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
package com.vernu.sms.ui.onboarding
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.google.firebase.messaging.FirebaseMessaging
|
||||
import com.vernu.sms.ApiManagerKt
|
||||
import com.vernu.sms.AppConstants
|
||||
import com.vernu.sms.BuildConfig
|
||||
import com.vernu.sms.TextBeeUtils
|
||||
import com.vernu.sms.dtos.RegisterDeviceInputDTO
|
||||
import com.vernu.sms.dtos.SimInfoCollectionDTO
|
||||
import com.vernu.sms.helpers.HeartbeatManager
|
||||
import com.vernu.sms.helpers.SharedPreferenceHelper
|
||||
import com.vernu.sms.helpers.serverErrorMessage
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.receiveAsFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlin.coroutines.resume
|
||||
import kotlin.coroutines.resumeWithException
|
||||
import kotlin.coroutines.suspendCoroutine
|
||||
|
||||
data class OnboardingState(
|
||||
val apiKey: String = "",
|
||||
val deviceId: String = "",
|
||||
val deviceName: String = "${Build.BRAND} ${Build.MODEL}",
|
||||
val isReturningUser: Boolean = false,
|
||||
val useExistingDeviceId: Boolean = false,
|
||||
val isQrScanned: Boolean = false,
|
||||
val isLoading: Boolean = false,
|
||||
val errorMessage: String? = null,
|
||||
val registeredDeviceId: String? = null,
|
||||
val registeredDeviceName: String? = null
|
||||
)
|
||||
|
||||
class OnboardingViewModel : ViewModel() {
|
||||
|
||||
private val _state = MutableStateFlow(OnboardingState())
|
||||
val state: StateFlow<OnboardingState> = _state.asStateFlow()
|
||||
|
||||
private val _registrationSuccess = Channel<Unit>(Channel.CONFLATED)
|
||||
val registrationSuccess = _registrationSuccess.receiveAsFlow()
|
||||
|
||||
fun setApiKey(key: String) {
|
||||
_state.update { it.copy(apiKey = key.trim(), errorMessage = null, isQrScanned = false) }
|
||||
}
|
||||
|
||||
fun onQrScanned(key: String) {
|
||||
_state.update { it.copy(apiKey = key.trim(), errorMessage = null, isQrScanned = true) }
|
||||
}
|
||||
|
||||
fun setDeviceId(id: String) {
|
||||
_state.update { it.copy(deviceId = id.trim(), errorMessage = null) }
|
||||
}
|
||||
|
||||
fun setDeviceName(name: String) {
|
||||
_state.update { it.copy(deviceName = name) }
|
||||
}
|
||||
|
||||
fun setReturningUser(returning: Boolean) {
|
||||
_state.update { it.copy(isReturningUser = returning, useExistingDeviceId = returning) }
|
||||
}
|
||||
|
||||
fun setUseExistingDeviceId(use: Boolean) {
|
||||
_state.update { it.copy(useExistingDeviceId = use, deviceId = if (!use) "" else it.deviceId) }
|
||||
}
|
||||
|
||||
fun clearError() {
|
||||
_state.update { it.copy(errorMessage = null) }
|
||||
}
|
||||
|
||||
fun registerOrUpdateDevice(context: Context) {
|
||||
val current = _state.value
|
||||
val apiKey = current.apiKey
|
||||
val deviceId = current.deviceId
|
||||
val shouldUpdate = current.isReturningUser || (current.useExistingDeviceId && deviceId.isNotEmpty())
|
||||
|
||||
if (apiKey.isEmpty()) {
|
||||
_state.update { it.copy(errorMessage = "Please enter your API key.") }
|
||||
return
|
||||
}
|
||||
if ((current.isReturningUser || current.useExistingDeviceId) && deviceId.isEmpty()) {
|
||||
_state.update { it.copy(errorMessage = "Please enter your Device ID.") }
|
||||
return
|
||||
}
|
||||
|
||||
viewModelScope.launch {
|
||||
_state.update { it.copy(isLoading = true, errorMessage = null) }
|
||||
try {
|
||||
val fcmToken = getFcmToken()
|
||||
val collectedSimInfo = SimInfoCollectionDTO().apply {
|
||||
lastUpdated = System.currentTimeMillis()
|
||||
sims = TextBeeUtils.collectSimInfo(context)
|
||||
}
|
||||
val input = RegisterDeviceInputDTO().apply {
|
||||
this.fcmToken = fcmToken
|
||||
enabled = true
|
||||
brand = Build.BRAND
|
||||
manufacturer = Build.MANUFACTURER
|
||||
model = Build.MODEL
|
||||
buildId = Build.ID
|
||||
os = Build.VERSION.BASE_OS
|
||||
appVersionCode = BuildConfig.VERSION_CODE
|
||||
appVersionName = BuildConfig.VERSION_NAME
|
||||
name = current.deviceName.ifEmpty { "${Build.BRAND} ${Build.MODEL}" }
|
||||
simInfo = collectedSimInfo
|
||||
}
|
||||
|
||||
val response = if (shouldUpdate) {
|
||||
ApiManagerKt.getApiService().updateDevice(deviceId, apiKey, input)
|
||||
} else {
|
||||
ApiManagerKt.getApiService().registerDevice(apiKey, input)
|
||||
}
|
||||
|
||||
if (response.isSuccessful) {
|
||||
val data = response.body()?.data
|
||||
?: throw IllegalStateException("missing_response")
|
||||
val registeredId = data["_id"] as? String
|
||||
?: throw IllegalStateException("missing_id")
|
||||
val heartbeatInterval = (data["heartbeatIntervalMinutes"] as? Double)?.toInt() ?: 30
|
||||
val name = data["name"] as? String ?: ""
|
||||
|
||||
SharedPreferenceHelper.setSharedPreferenceString(
|
||||
context, AppConstants.SHARED_PREFS_DEVICE_ID_KEY, registeredId
|
||||
)
|
||||
SharedPreferenceHelper.setSharedPreferenceString(
|
||||
context, AppConstants.SHARED_PREFS_API_KEY_KEY, apiKey
|
||||
)
|
||||
SharedPreferenceHelper.setSharedPreferenceInt(
|
||||
context, AppConstants.SHARED_PREFS_HEARTBEAT_INTERVAL_MINUTES_KEY, heartbeatInterval
|
||||
)
|
||||
val resolvedName = name.ifEmpty { current.deviceName }
|
||||
SharedPreferenceHelper.setSharedPreferenceString(
|
||||
context, AppConstants.SHARED_PREFS_DEVICE_NAME_KEY, resolvedName
|
||||
)
|
||||
SharedPreferenceHelper.setSharedPreferenceBoolean(
|
||||
context, AppConstants.SHARED_PREFS_GATEWAY_ENABLED_KEY, true
|
||||
)
|
||||
HeartbeatManager.scheduleHeartbeat(context)
|
||||
|
||||
_state.update {
|
||||
it.copy(
|
||||
isLoading = false,
|
||||
registeredDeviceId = registeredId,
|
||||
registeredDeviceName = resolvedName
|
||||
)
|
||||
}
|
||||
_registrationSuccess.send(Unit)
|
||||
} else {
|
||||
_state.update {
|
||||
it.copy(
|
||||
isLoading = false,
|
||||
errorMessage = when (response.code()) {
|
||||
401 -> "Invalid API key. Go back and check your key."
|
||||
404 -> "Device ID not found. Verify it in your dashboard."
|
||||
429 -> response.serverErrorMessage()
|
||||
?: "You've reached your plan's device limit. Disable or remove another device, or upgrade your plan."
|
||||
in 500..599 -> "Server error. Please try again in a moment."
|
||||
else -> "Request failed (${response.code()}). Please try again."
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
val message = when {
|
||||
e.message == "missing_id" -> "Unexpected server response. Please try again."
|
||||
e.message?.contains("Unable to resolve host") == true ||
|
||||
e.message?.contains("timeout") == true ||
|
||||
e.message?.contains("connect") == true ->
|
||||
"No internet connection. Check your network and retry."
|
||||
else -> "Something went wrong. Please try again."
|
||||
}
|
||||
_state.update { it.copy(isLoading = false, errorMessage = message) }
|
||||
TextBeeUtils.logException(e, "Onboarding device registration failed")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun getFcmToken(): String = suspendCoroutine { cont ->
|
||||
FirebaseMessaging.getInstance().token
|
||||
.addOnSuccessListener { token -> cont.resume(token) }
|
||||
.addOnFailureListener { e -> cont.resumeWithException(e) }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,285 @@
|
||||
package com.vernu.sms.ui.onboarding.screens
|
||||
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.Check
|
||||
import androidx.compose.material.icons.filled.QrCodeScanner
|
||||
import androidx.compose.material.icons.filled.Visibility
|
||||
import androidx.compose.material.icons.filled.VisibilityOff
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||
import androidx.compose.ui.text.input.VisualTransformation
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.vernu.sms.ui.onboarding.OnboardingViewModel
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun CredentialsScreen(
|
||||
viewModel: OnboardingViewModel,
|
||||
onScanQr: () -> Unit,
|
||||
onNext: () -> Unit,
|
||||
onBack: () -> Unit
|
||||
) {
|
||||
val state by viewModel.state.collectAsState()
|
||||
val context = LocalContext.current
|
||||
var selectedTab by remember { mutableStateOf(0) }
|
||||
var showApiKey by remember { mutableStateOf(false) }
|
||||
val tabs = listOf("Scan QR Code", "Enter Manually")
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = {},
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onBack) {
|
||||
Icon(Icons.Default.ArrowBack, contentDescription = "Back")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
) { padding ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(padding)
|
||||
.padding(horizontal = 24.dp)
|
||||
.verticalScroll(rememberScrollState())
|
||||
) {
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
StepIndicator(current = 1, total = 3)
|
||||
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
|
||||
Text(
|
||||
text = "Connect your account",
|
||||
style = MaterialTheme.typography.headlineMedium
|
||||
)
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Text(
|
||||
text = "Enter your API key from your textbee.dev dashboard",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
TextButton(
|
||||
onClick = {
|
||||
context.startActivity(
|
||||
Intent(Intent.ACTION_VIEW, Uri.parse("https://app.textbee.dev/register"))
|
||||
)
|
||||
},
|
||||
contentPadding = PaddingValues(0.dp)
|
||||
) {
|
||||
Text(
|
||||
text = "Don't have an account? Sign up free",
|
||||
style = MaterialTheme.typography.bodySmall
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
TabRow(selectedTabIndex = selectedTab) {
|
||||
tabs.forEachIndexed { index, title ->
|
||||
Tab(
|
||||
selected = selectedTab == index,
|
||||
onClick = { selectedTab = index },
|
||||
text = { Text(title) }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
|
||||
when (selectedTab) {
|
||||
0 -> QrTab(
|
||||
apiKey = state.apiKey,
|
||||
isQrScanned = state.isQrScanned,
|
||||
onScanQr = onScanQr,
|
||||
onSwitchToManual = { selectedTab = 1 }
|
||||
)
|
||||
1 -> ManualTab(
|
||||
apiKey = state.apiKey,
|
||||
showApiKey = showApiKey,
|
||||
onApiKeyChange = { viewModel.setApiKey(it) },
|
||||
onToggleVisibility = { showApiKey = !showApiKey }
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(32.dp))
|
||||
|
||||
Button(
|
||||
onClick = onNext,
|
||||
enabled = state.apiKey.isNotEmpty(),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(52.dp)
|
||||
) {
|
||||
Text("Next →")
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun QrTab(
|
||||
apiKey: String,
|
||||
isQrScanned: Boolean,
|
||||
onScanQr: () -> Unit,
|
||||
onSwitchToManual: () -> Unit
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalArrangement = Arrangement.spacedBy(6.dp)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
text = "1. Go to your textbee.dev dashboard",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
TextButton(
|
||||
onClick = {
|
||||
context.startActivity(
|
||||
Intent(Intent.ACTION_VIEW, Uri.parse("https://app.textbee.dev/dashboard"))
|
||||
)
|
||||
},
|
||||
contentPadding = PaddingValues(horizontal = 8.dp, vertical = 0.dp)
|
||||
) {
|
||||
Text("Open", style = MaterialTheme.typography.bodySmall)
|
||||
}
|
||||
}
|
||||
Text(
|
||||
text = "2. Click \"Register Device\" or \"Generate API Key\"",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
Text(
|
||||
text = "3. Scan the QR code shown on screen",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
|
||||
if (isQrScanned && apiKey.isNotEmpty()) {
|
||||
Card(
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.primaryContainer
|
||||
),
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.Check,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
Spacer(modifier = Modifier.width(12.dp))
|
||||
Column {
|
||||
Text(
|
||||
text = "QR scanned successfully",
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
Text(
|
||||
text = apiKey.take(8) + "••••••••",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
OutlinedButton(onClick = onScanQr) {
|
||||
Icon(Icons.Default.QrCodeScanner, contentDescription = null)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text("Scan Again")
|
||||
}
|
||||
} else {
|
||||
Button(
|
||||
onClick = onScanQr,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(52.dp)
|
||||
) {
|
||||
Icon(Icons.Default.QrCodeScanner, contentDescription = null)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text("Scan QR Code")
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
TextButton(onClick = onSwitchToManual) {
|
||||
Text("Enter manually instead")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ManualTab(
|
||||
apiKey: String,
|
||||
showApiKey: Boolean,
|
||||
onApiKeyChange: (String) -> Unit,
|
||||
onToggleVisibility: () -> Unit
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
|
||||
OutlinedTextField(
|
||||
value = apiKey,
|
||||
onValueChange = onApiKeyChange,
|
||||
label = { Text("API Key") },
|
||||
placeholder = { Text("Paste your API key here") },
|
||||
visualTransformation = if (showApiKey) VisualTransformation.None else PasswordVisualTransformation(),
|
||||
trailingIcon = {
|
||||
IconButton(onClick = onToggleVisibility) {
|
||||
Icon(
|
||||
imageVector = if (showApiKey) Icons.Default.VisibilityOff else Icons.Default.Visibility,
|
||||
contentDescription = if (showApiKey) "Hide" else "Show"
|
||||
)
|
||||
}
|
||||
},
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
singleLine = true
|
||||
)
|
||||
TextButton(
|
||||
onClick = {
|
||||
context.startActivity(
|
||||
Intent(Intent.ACTION_VIEW, Uri.parse("https://app.textbee.dev/dashboard"))
|
||||
)
|
||||
},
|
||||
contentPadding = PaddingValues(0.dp)
|
||||
) {
|
||||
Text(
|
||||
text = "Get your API key at app.textbee.dev/dashboard",
|
||||
style = MaterialTheme.typography.bodySmall
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
package com.vernu.sms.ui.onboarding.screens
|
||||
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.vernu.sms.ui.onboarding.OnboardingViewModel
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun DeviceSetupScreen(
|
||||
viewModel: OnboardingViewModel,
|
||||
onSuccess: () -> Unit,
|
||||
onBack: () -> Unit
|
||||
) {
|
||||
val state by viewModel.state.collectAsState()
|
||||
val context = LocalContext.current
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
viewModel.registrationSuccess.collect { onSuccess() }
|
||||
}
|
||||
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = {},
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onBack) {
|
||||
Icon(Icons.Default.ArrowBack, contentDescription = "Back")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
) { padding ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(padding)
|
||||
.padding(horizontal = 24.dp)
|
||||
.verticalScroll(rememberScrollState())
|
||||
) {
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
StepIndicator(current = 2, total = 3)
|
||||
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
|
||||
Text(
|
||||
text = if (state.isReturningUser) "Reconnect your device" else "Set up your device",
|
||||
style = MaterialTheme.typography.headlineMedium
|
||||
)
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Text(
|
||||
text = if (state.isReturningUser)
|
||||
"Enter your Device ID to reconnect this device to your account"
|
||||
else
|
||||
"Give this device a name and register it to start sending SMS",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
|
||||
if (state.isReturningUser) {
|
||||
OutlinedTextField(
|
||||
value = state.deviceId,
|
||||
onValueChange = { viewModel.setDeviceId(it) },
|
||||
label = { Text("Device ID") },
|
||||
placeholder = { Text("Enter your Device ID") },
|
||||
supportingText = { Text("Find it at textbee.dev/dashboard → Devices") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
singleLine = true
|
||||
)
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
} else {
|
||||
var expandDeviceId by remember { mutableStateOf(false) }
|
||||
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Checkbox(
|
||||
checked = expandDeviceId,
|
||||
onCheckedChange = {
|
||||
expandDeviceId = it
|
||||
viewModel.setUseExistingDeviceId(it)
|
||||
}
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text(
|
||||
text = "This device was previously registered",
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
}
|
||||
Text(
|
||||
text = "Use this if you reinstalled the app and want to reconnect your existing device rather than create a new one in your dashboard",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(start = 48.dp)
|
||||
)
|
||||
|
||||
if (expandDeviceId) {
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
OutlinedTextField(
|
||||
value = state.deviceId,
|
||||
onValueChange = { viewModel.setDeviceId(it) },
|
||||
label = { Text("Device ID") },
|
||||
placeholder = { Text("Enter your Device ID") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
singleLine = true
|
||||
)
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
}
|
||||
}
|
||||
|
||||
OutlinedTextField(
|
||||
value = state.deviceName,
|
||||
onValueChange = { viewModel.setDeviceName(it) },
|
||||
label = { Text("Device Name (optional)") },
|
||||
placeholder = { Text("e.g. My Gateway Phone") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
singleLine = true
|
||||
)
|
||||
|
||||
state.errorMessage?.let { error ->
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
Card(
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.errorContainer
|
||||
),
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
text = error,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onErrorContainer,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
IconButton(onClick = { viewModel.clearError() }, modifier = Modifier.size(20.dp)) {
|
||||
Icon(
|
||||
Icons.Default.Close,
|
||||
contentDescription = "Dismiss",
|
||||
tint = MaterialTheme.colorScheme.onErrorContainer,
|
||||
modifier = Modifier.size(16.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(32.dp))
|
||||
|
||||
Button(
|
||||
onClick = { viewModel.registerOrUpdateDevice(context) },
|
||||
enabled = !state.isLoading,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(52.dp)
|
||||
) {
|
||||
Text(if (state.isReturningUser) "Reconnect Device" else "Register Device")
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
}
|
||||
}
|
||||
|
||||
if (state.isLoading) {
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
color = MaterialTheme.colorScheme.background.copy(alpha = 0.7f)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center
|
||||
) {
|
||||
CircularProgressIndicator(color = MaterialTheme.colorScheme.primary)
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
Text(
|
||||
text = "Connecting your device…",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onBackground
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
package com.vernu.sms.ui.onboarding.screens
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
@Composable
|
||||
fun StepIndicator(current: Int, total: Int) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
repeat(total) { index ->
|
||||
val step = index + 1
|
||||
val isActive = step == current
|
||||
val isDone = step < current
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(if (isActive) 28.dp else 24.dp)
|
||||
.background(
|
||||
color = when {
|
||||
isActive -> MaterialTheme.colorScheme.primary
|
||||
isDone -> MaterialTheme.colorScheme.primary.copy(alpha = 0.4f)
|
||||
else -> MaterialTheme.colorScheme.surfaceVariant
|
||||
},
|
||||
shape = CircleShape
|
||||
),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(
|
||||
text = step.toString(),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = if (isActive || isDone) Color.White
|
||||
else MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
if (index < total - 1) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.width(24.dp)
|
||||
.height(2.dp)
|
||||
.background(
|
||||
if (isDone) MaterialTheme.colorScheme.primary.copy(alpha = 0.4f)
|
||||
else MaterialTheme.colorScheme.surfaceVariant
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text(
|
||||
text = "Step $current of $total",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,252 @@
|
||||
package com.vernu.sms.ui.onboarding.screens
|
||||
|
||||
import android.Manifest
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.net.Uri
|
||||
import android.provider.Settings
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.Check
|
||||
import androidx.compose.material.icons.filled.PhoneAndroid
|
||||
import androidx.compose.material.icons.filled.Sms
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.content.ContextCompat
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun PermissionsScreen(
|
||||
onContinue: () -> Unit,
|
||||
onBack: () -> Unit
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
|
||||
val permissions = remember {
|
||||
listOf(
|
||||
PermissionItem(
|
||||
permission = Manifest.permission.SEND_SMS,
|
||||
label = "Send SMS",
|
||||
rationale = "Required to send messages from your device",
|
||||
icon = Icons.Default.Sms
|
||||
),
|
||||
PermissionItem(
|
||||
permission = Manifest.permission.RECEIVE_SMS,
|
||||
label = "Receive SMS",
|
||||
rationale = "Required to receive and forward incoming messages",
|
||||
icon = Icons.Default.Sms
|
||||
),
|
||||
PermissionItem(
|
||||
permission = Manifest.permission.READ_PHONE_STATE,
|
||||
label = "Phone State",
|
||||
rationale = "Required to detect SIM cards for multi-SIM support",
|
||||
icon = Icons.Default.PhoneAndroid
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
var grantedMap by remember {
|
||||
mutableStateOf(
|
||||
permissions.associate { item ->
|
||||
item.permission to (ContextCompat.checkSelfPermission(context, item.permission) == PackageManager.PERMISSION_GRANTED)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
val launcher = rememberLauncherForActivityResult(
|
||||
ActivityResultContracts.RequestMultiplePermissions()
|
||||
) { results ->
|
||||
grantedMap = grantedMap + results
|
||||
}
|
||||
|
||||
val allGranted = grantedMap.values.all { it }
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = {},
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onBack) {
|
||||
Icon(Icons.Default.ArrowBack, contentDescription = "Back")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
) { padding ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(padding)
|
||||
.padding(horizontal = 24.dp)
|
||||
.verticalScroll(rememberScrollState())
|
||||
) {
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
StepIndicator(current = 3, total = 3)
|
||||
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
|
||||
Text(
|
||||
text = "Grant Permissions",
|
||||
style = MaterialTheme.typography.headlineMedium
|
||||
)
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Text(
|
||||
text = "These permissions are required for the SMS gateway to work",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
|
||||
if (!allGranted) {
|
||||
Button(
|
||||
onClick = {
|
||||
val missing = permissions
|
||||
.filter { grantedMap[it.permission] == false }
|
||||
.map { it.permission }
|
||||
.toTypedArray()
|
||||
launcher.launch(missing)
|
||||
},
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(48.dp)
|
||||
) {
|
||||
Text("Grant All Permissions")
|
||||
}
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
}
|
||||
|
||||
permissions.forEach { item ->
|
||||
val isGranted = grantedMap[item.permission] == true
|
||||
PermissionRow(
|
||||
item = item,
|
||||
isGranted = isGranted,
|
||||
onGrant = { launcher.launch(arrayOf(item.permission)) },
|
||||
onOpenSettings = {
|
||||
context.startActivity(
|
||||
Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply {
|
||||
data = Uri.fromParts("package", context.packageName, null)
|
||||
}
|
||||
)
|
||||
}
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
Text(
|
||||
text = "These permissions are only used to send and receive SMS on your behalf. textbee never accesses your existing message history.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
TextButton(
|
||||
onClick = {
|
||||
context.startActivity(
|
||||
Intent(Intent.ACTION_VIEW, Uri.parse("https://textbee.dev/privacy-policy"))
|
||||
)
|
||||
},
|
||||
contentPadding = PaddingValues(0.dp)
|
||||
) {
|
||||
Text("Privacy Policy", style = MaterialTheme.typography.bodySmall)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
Button(
|
||||
onClick = onContinue,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(52.dp)
|
||||
) {
|
||||
Text(if (allGranted) "Continue" else "Continue Anyway")
|
||||
}
|
||||
|
||||
if (!allGranted) {
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Text(
|
||||
text = "Some features may be limited without all permissions",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PermissionRow(
|
||||
item: PermissionItem,
|
||||
isGranted: Boolean,
|
||||
onGrant: () -> Unit,
|
||||
onOpenSettings: () -> Unit
|
||||
) {
|
||||
Card(
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = if (isGranted)
|
||||
MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.5f)
|
||||
else
|
||||
MaterialTheme.colorScheme.surfaceVariant
|
||||
),
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Icon(
|
||||
imageVector = item.icon,
|
||||
contentDescription = null,
|
||||
tint = if (isGranted) MaterialTheme.colorScheme.primary
|
||||
else MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.size(24.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(12.dp))
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = item.label,
|
||||
style = MaterialTheme.typography.titleMedium
|
||||
)
|
||||
Text(
|
||||
text = item.rationale,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
if (isGranted) {
|
||||
Icon(
|
||||
Icons.Default.Check,
|
||||
contentDescription = "Granted",
|
||||
tint = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
} else {
|
||||
TextButton(onClick = onGrant) {
|
||||
Text("Grant")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private data class PermissionItem(
|
||||
val permission: String,
|
||||
val label: String,
|
||||
val rationale: String,
|
||||
val icon: ImageVector
|
||||
)
|
||||
@@ -0,0 +1,230 @@
|
||||
package com.vernu.sms.ui.onboarding.screens
|
||||
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import androidx.compose.animation.core.*
|
||||
import com.vernu.sms.AppConstants
|
||||
import com.vernu.sms.helpers.SharedPreferenceHelper
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Check
|
||||
import androidx.compose.material.icons.filled.ContentCopy
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.scale
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.ClipboardManager
|
||||
import androidx.compose.ui.platform.LocalClipboardManager
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.vernu.sms.ui.onboarding.OnboardingViewModel
|
||||
|
||||
@Composable
|
||||
fun SetupCompleteScreen(
|
||||
viewModel: OnboardingViewModel,
|
||||
onOpenDashboard: () -> Unit
|
||||
) {
|
||||
val state by viewModel.state.collectAsState()
|
||||
val clipboardManager: ClipboardManager = LocalClipboardManager.current
|
||||
val context = LocalContext.current
|
||||
var receiveSmsEnabled by remember { mutableStateOf(true) }
|
||||
|
||||
val scale = remember { Animatable(0f) }
|
||||
LaunchedEffect(Unit) {
|
||||
scale.animateTo(
|
||||
targetValue = 1f,
|
||||
animationSpec = spring(
|
||||
dampingRatio = Spring.DampingRatioMediumBouncy,
|
||||
stiffness = Spring.StiffnessMedium
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
color = MaterialTheme.colorScheme.background
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(32.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(96.dp)
|
||||
.scale(scale.value)
|
||||
.background(MaterialTheme.colorScheme.primary, CircleShape),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Check,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(48.dp),
|
||||
tint = Color.White
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(32.dp))
|
||||
|
||||
Text(
|
||||
text = "You're all set!",
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
Text(
|
||||
text = state.registeredDeviceName ?: "Your device",
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
|
||||
state.registeredDeviceId?.let { deviceId ->
|
||||
Card(
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceVariant
|
||||
),
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Column(modifier = Modifier.padding(16.dp)) {
|
||||
Text(
|
||||
text = "Device ID",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Text(
|
||||
text = deviceId,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
IconButton(
|
||||
onClick = {
|
||||
clipboardManager.setText(AnnotatedString(deviceId))
|
||||
}
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.ContentCopy,
|
||||
contentDescription = "Copy",
|
||||
modifier = Modifier.size(18.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Text(
|
||||
text = "Keep this handy. You will need it for API calls and managing devices in your dashboard.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Column(modifier = Modifier.weight(1f).padding(end = 12.dp)) {
|
||||
Text(
|
||||
text = "Forward received SMS",
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
fontWeight = FontWeight.SemiBold
|
||||
)
|
||||
Spacer(modifier = Modifier.height(2.dp))
|
||||
Text(
|
||||
text = "SMS you receive on this phone will appear in your textbee dashboard and be accessible via API",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
Switch(
|
||||
checked = receiveSmsEnabled,
|
||||
onCheckedChange = { enabled ->
|
||||
receiveSmsEnabled = enabled
|
||||
SharedPreferenceHelper.setSharedPreferenceBoolean(
|
||||
context,
|
||||
AppConstants.SHARED_PREFS_RECEIVE_SMS_ENABLED_KEY,
|
||||
enabled
|
||||
)
|
||||
},
|
||||
modifier = Modifier.scale(0.75f)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
Text(
|
||||
text = "Your device is registered and ready. Head to your dashboard to send your first SMS or connect via API.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(32.dp))
|
||||
|
||||
Button(
|
||||
onClick = onOpenDashboard,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(52.dp)
|
||||
) {
|
||||
Text("Open App Dashboard", style = MaterialTheme.typography.labelLarge)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
|
||||
OutlinedButton(
|
||||
onClick = {
|
||||
context.startActivity(
|
||||
Intent(Intent.ACTION_VIEW, Uri.parse("https://app.textbee.dev/dashboard"))
|
||||
)
|
||||
},
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(52.dp)
|
||||
) {
|
||||
Text("Open Web Dashboard", style = MaterialTheme.typography.labelLarge)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
TextButton(
|
||||
onClick = {
|
||||
context.startActivity(
|
||||
Intent(Intent.ACTION_VIEW, Uri.parse("https://textbee.dev/docs"))
|
||||
)
|
||||
}
|
||||
) {
|
||||
Text(
|
||||
text = "New to textbee? Read the quickstart guide",
|
||||
style = MaterialTheme.typography.bodySmall
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
package com.vernu.sms.ui.onboarding.screens
|
||||
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.vernu.sms.R
|
||||
|
||||
@Composable
|
||||
fun WelcomeScreen(
|
||||
onGetStarted: () -> Unit,
|
||||
onHaveDeviceId: () -> Unit
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(MaterialTheme.colorScheme.background)
|
||||
.padding(32.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(96.dp)
|
||||
.background(MaterialTheme.colorScheme.primaryContainer, CircleShape),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Image(
|
||||
painter = painterResource(id = R.drawable.ic_app_logo),
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(60.dp)
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(32.dp))
|
||||
|
||||
Text(
|
||||
text = "textbee",
|
||||
style = MaterialTheme.typography.headlineLarge,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
|
||||
Text(
|
||||
text = "Turn your Android into a\nprogrammatic SMS gateway",
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalArrangement = Arrangement.spacedBy(6.dp)
|
||||
) {
|
||||
listOf(
|
||||
"Create a free account at textbee.dev",
|
||||
"Connect this phone as your SMS gateway",
|
||||
"Send SMS via API from any app or automation"
|
||||
).forEachIndexed { i, step ->
|
||||
Row(
|
||||
verticalAlignment = Alignment.Top,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Text(
|
||||
text = "${i + 1}.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
fontWeight = FontWeight.Bold,
|
||||
modifier = Modifier.width(20.dp)
|
||||
)
|
||||
Text(
|
||||
text = step,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(32.dp))
|
||||
|
||||
Button(
|
||||
onClick = onGetStarted,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(52.dp)
|
||||
) {
|
||||
Text("Get Started", style = MaterialTheme.typography.labelLarge)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
|
||||
OutlinedButton(
|
||||
onClick = onHaveDeviceId,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(52.dp)
|
||||
) {
|
||||
Text("Reconnect a device", style = MaterialTheme.typography.labelLarge)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
TextButton(
|
||||
onClick = {
|
||||
context.startActivity(
|
||||
Intent(Intent.ACTION_VIEW, Uri.parse("https://app.textbee.dev/register"))
|
||||
)
|
||||
}
|
||||
) {
|
||||
Text(
|
||||
text = "Don't have an account? Sign up free",
|
||||
style = MaterialTheme.typography.bodySmall
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
TextButton(
|
||||
onClick = {
|
||||
context.startActivity(
|
||||
Intent(Intent.ACTION_VIEW, Uri.parse("https://textbee.dev"))
|
||||
)
|
||||
}
|
||||
) {
|
||||
Text(
|
||||
text = "textbee.dev",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,354 @@
|
||||
package com.vernu.sms.ui.settings
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.*
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.vernu.sms.helpers.SMSFilterHelper
|
||||
import com.vernu.sms.models.SMSFilterRule
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun SMSFilterScreen(
|
||||
onNavigateBack: () -> Unit,
|
||||
viewModel: SMSFilterViewModel = viewModel()
|
||||
) {
|
||||
val config by viewModel.config.collectAsState()
|
||||
var showDialog by remember { mutableStateOf(false) }
|
||||
var editingIndex by remember { mutableStateOf<Int?>(null) }
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text("SMS Filters", fontWeight = FontWeight.SemiBold) },
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onNavigateBack) {
|
||||
Icon(Icons.Default.ArrowBack, contentDescription = "Back")
|
||||
}
|
||||
},
|
||||
colors = TopAppBarDefaults.topAppBarColors(
|
||||
containerColor = MaterialTheme.colorScheme.surface
|
||||
)
|
||||
)
|
||||
},
|
||||
floatingActionButton = {
|
||||
FloatingActionButton(onClick = {
|
||||
editingIndex = null
|
||||
showDialog = true
|
||||
}) {
|
||||
Icon(Icons.Default.Add, contentDescription = "Add rule")
|
||||
}
|
||||
}
|
||||
) { padding ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(padding)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text("Enable SMS Filtering", style = MaterialTheme.typography.bodyLarge)
|
||||
Text(
|
||||
"Filter incoming SMS based on rules below",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
Switch(
|
||||
checked = config.enabled,
|
||||
onCheckedChange = { viewModel.setEnabled(it) }
|
||||
)
|
||||
}
|
||||
|
||||
Divider()
|
||||
|
||||
if (config.enabled) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
FilterChip(
|
||||
selected = config.mode == SMSFilterHelper.FilterMode.ALLOW_LIST,
|
||||
onClick = { viewModel.setMode(SMSFilterHelper.FilterMode.ALLOW_LIST) },
|
||||
label = { Text("Allow List") }
|
||||
)
|
||||
FilterChip(
|
||||
selected = config.mode == SMSFilterHelper.FilterMode.BLOCK_LIST,
|
||||
onClick = { viewModel.setMode(SMSFilterHelper.FilterMode.BLOCK_LIST) },
|
||||
label = { Text("Block List") }
|
||||
)
|
||||
}
|
||||
Text(
|
||||
text = if (config.mode == SMSFilterHelper.FilterMode.ALLOW_LIST)
|
||||
"Only SMS matching a rule will be forwarded"
|
||||
else
|
||||
"SMS matching a rule will be blocked",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(start = 16.dp, end = 16.dp, bottom = 8.dp)
|
||||
)
|
||||
Divider()
|
||||
}
|
||||
|
||||
if (config.rules.isEmpty()) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.weight(1f),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.FilterList,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(48.dp),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
Text(
|
||||
"No filter rules",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
Text(
|
||||
"Tap + to add a rule",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
LazyColumn(modifier = Modifier.weight(1f)) {
|
||||
itemsIndexed(config.rules) { index, rule ->
|
||||
FilterRuleRow(
|
||||
rule = rule,
|
||||
onEdit = {
|
||||
editingIndex = index
|
||||
showDialog = true
|
||||
},
|
||||
onDelete = { viewModel.deleteRule(index) }
|
||||
)
|
||||
if (index < config.rules.lastIndex) {
|
||||
Divider(modifier = Modifier.padding(start = 16.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (showDialog) {
|
||||
FilterRuleDialog(
|
||||
rule = editingIndex?.let { config.rules.getOrNull(it) },
|
||||
onConfirm = { rule ->
|
||||
val idx = editingIndex
|
||||
if (idx != null) viewModel.updateRule(idx, rule) else viewModel.addRule(rule)
|
||||
showDialog = false
|
||||
},
|
||||
onDismiss = { showDialog = false }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun FilterRuleRow(
|
||||
rule: SMSFilterRule,
|
||||
onEdit: () -> Unit,
|
||||
onDelete: () -> Unit
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable(onClick = onEdit)
|
||||
.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = rule.pattern ?: "",
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
style = MaterialTheme.typography.bodyLarge
|
||||
)
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
SuggestionChip(
|
||||
onClick = {},
|
||||
label = {
|
||||
Text(
|
||||
rule.matchType?.name?.replace('_', ' ')
|
||||
?.lowercase()?.replaceFirstChar { it.uppercaseChar() } ?: "",
|
||||
style = MaterialTheme.typography.labelSmall
|
||||
)
|
||||
}
|
||||
)
|
||||
SuggestionChip(
|
||||
onClick = {},
|
||||
label = {
|
||||
Text(
|
||||
rule.filterTarget.name.lowercase().replaceFirstChar { it.uppercaseChar() },
|
||||
style = MaterialTheme.typography.labelSmall
|
||||
)
|
||||
}
|
||||
)
|
||||
if (rule.caseSensitive) {
|
||||
SuggestionChip(
|
||||
onClick = {},
|
||||
label = { Text("Case sensitive", style = MaterialTheme.typography.labelSmall) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
IconButton(onClick = onDelete) {
|
||||
Icon(
|
||||
Icons.Default.Delete,
|
||||
contentDescription = "Delete rule",
|
||||
tint = MaterialTheme.colorScheme.error
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun FilterRuleDialog(
|
||||
rule: SMSFilterRule?,
|
||||
onConfirm: (SMSFilterRule) -> Unit,
|
||||
onDismiss: () -> Unit
|
||||
) {
|
||||
var pattern by remember(rule) { mutableStateOf(rule?.pattern ?: "") }
|
||||
var matchType by remember(rule) {
|
||||
mutableStateOf(rule?.matchType ?: SMSFilterRule.MatchType.CONTAINS)
|
||||
}
|
||||
var filterTarget by remember(rule) {
|
||||
mutableStateOf(rule?.filterTarget ?: SMSFilterRule.FilterTarget.SENDER)
|
||||
}
|
||||
var caseSensitive by remember(rule) { mutableStateOf(rule?.caseSensitive ?: false) }
|
||||
var matchTypeExpanded by remember { mutableStateOf(false) }
|
||||
var filterTargetExpanded by remember { mutableStateOf(false) }
|
||||
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text(if (rule == null) "Add Rule" else "Edit Rule") },
|
||||
text = {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
OutlinedTextField(
|
||||
value = pattern,
|
||||
onValueChange = { pattern = it },
|
||||
label = { Text("Pattern") },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
|
||||
ExposedDropdownMenuBox(
|
||||
expanded = filterTargetExpanded,
|
||||
onExpandedChange = { filterTargetExpanded = !filterTargetExpanded }
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = filterTarget.name.lowercase()
|
||||
.replaceFirstChar { it.uppercaseChar() },
|
||||
onValueChange = {},
|
||||
readOnly = true,
|
||||
label = { Text("Filter Target") },
|
||||
trailingIcon = {
|
||||
ExposedDropdownMenuDefaults.TrailingIcon(expanded = filterTargetExpanded)
|
||||
},
|
||||
modifier = Modifier.menuAnchor().fillMaxWidth()
|
||||
)
|
||||
ExposedDropdownMenu(
|
||||
expanded = filterTargetExpanded,
|
||||
onDismissRequest = { filterTargetExpanded = false }
|
||||
) {
|
||||
SMSFilterRule.FilterTarget.values().forEach { target ->
|
||||
DropdownMenuItem(
|
||||
text = {
|
||||
Text(target.name.lowercase().replaceFirstChar { it.uppercaseChar() })
|
||||
},
|
||||
onClick = {
|
||||
filterTarget = target
|
||||
filterTargetExpanded = false
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ExposedDropdownMenuBox(
|
||||
expanded = matchTypeExpanded,
|
||||
onExpandedChange = { matchTypeExpanded = !matchTypeExpanded }
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = matchType.name.replace('_', ' ').lowercase()
|
||||
.replaceFirstChar { it.uppercaseChar() },
|
||||
onValueChange = {},
|
||||
readOnly = true,
|
||||
label = { Text("Match Type") },
|
||||
trailingIcon = {
|
||||
ExposedDropdownMenuDefaults.TrailingIcon(expanded = matchTypeExpanded)
|
||||
},
|
||||
modifier = Modifier.menuAnchor().fillMaxWidth()
|
||||
)
|
||||
ExposedDropdownMenu(
|
||||
expanded = matchTypeExpanded,
|
||||
onDismissRequest = { matchTypeExpanded = false }
|
||||
) {
|
||||
SMSFilterRule.MatchType.values().forEach { type ->
|
||||
DropdownMenuItem(
|
||||
text = {
|
||||
Text(
|
||||
type.name.replace('_', ' ').lowercase()
|
||||
.replaceFirstChar { it.uppercaseChar() }
|
||||
)
|
||||
},
|
||||
onClick = {
|
||||
matchType = type
|
||||
matchTypeExpanded = false
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Text(
|
||||
"Case sensitive",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
Switch(checked = caseSensitive, onCheckedChange = { caseSensitive = it })
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
onClick = {
|
||||
onConfirm(SMSFilterRule(pattern.trim(), matchType, filterTarget, caseSensitive))
|
||||
},
|
||||
enabled = pattern.isNotBlank()
|
||||
) { Text("Save") }
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = onDismiss) { Text("Cancel") }
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package com.vernu.sms.ui.settings
|
||||
|
||||
import android.app.Application
|
||||
import androidx.lifecycle.AndroidViewModel
|
||||
import com.vernu.sms.helpers.SMSFilterHelper
|
||||
import com.vernu.sms.models.SMSFilterRule
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
|
||||
class SMSFilterViewModel(app: Application) : AndroidViewModel(app) {
|
||||
|
||||
private val _config = MutableStateFlow(SMSFilterHelper.loadFilterConfig(app))
|
||||
val config: StateFlow<SMSFilterHelper.FilterConfig> = _config.asStateFlow()
|
||||
|
||||
fun setEnabled(enabled: Boolean) = mutate { it.enabled = enabled }
|
||||
|
||||
fun setMode(mode: SMSFilterHelper.FilterMode) = mutate { it.mode = mode }
|
||||
|
||||
fun addRule(rule: SMSFilterRule) = mutate { it.rules.add(rule) }
|
||||
|
||||
fun updateRule(index: Int, rule: SMSFilterRule) = mutate { it.rules[index] = rule }
|
||||
|
||||
fun deleteRule(index: Int) = mutate { it.rules.removeAt(index) }
|
||||
|
||||
private fun mutate(block: (SMSFilterHelper.FilterConfig) -> Unit) {
|
||||
val current = _config.value
|
||||
val copy = SMSFilterHelper.FilterConfig().apply {
|
||||
enabled = current.enabled
|
||||
mode = current.mode
|
||||
rules = ArrayList(current.rules)
|
||||
}
|
||||
block(copy)
|
||||
_config.value = copy
|
||||
SMSFilterHelper.saveFilterConfig(getApplication(), copy)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,584 @@
|
||||
package com.vernu.sms.ui.settings
|
||||
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.*
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalClipboardManager
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.vernu.sms.BuildConfig
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun SettingsScreen(
|
||||
onSwitchToLegacy: () -> Unit,
|
||||
onNavigateToFilters: () -> Unit,
|
||||
onDisconnect: () -> Unit,
|
||||
viewModel: SettingsViewModel = viewModel()
|
||||
) {
|
||||
val state by viewModel.state.collectAsState()
|
||||
val context = LocalContext.current
|
||||
val clipboard = LocalClipboardManager.current
|
||||
val snackbarHostState = remember { SnackbarHostState() }
|
||||
|
||||
var showDisconnectDialog by remember { mutableStateOf(false) }
|
||||
var showLegacyDialog by remember { mutableStateOf(false) }
|
||||
var showAboutDialog by remember { mutableStateOf(false) }
|
||||
var showDeviceNameDialog by remember { mutableStateOf(false) }
|
||||
var editedDeviceName by remember(state.deviceName) { mutableStateOf(state.deviceName) }
|
||||
var showDelayDialog by remember { mutableStateOf(false) }
|
||||
var editedDelay by remember(state.smsSendDelaySeconds) { mutableStateOf(state.smsSendDelaySeconds.toString()) }
|
||||
|
||||
LaunchedEffect(state.snackbarMessage) {
|
||||
state.snackbarMessage?.let {
|
||||
snackbarHostState.showSnackbar(it)
|
||||
viewModel.clearSnackbar()
|
||||
}
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Icon(Icons.Default.Settings, contentDescription = null, tint = MaterialTheme.colorScheme.primary)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text("Settings", fontWeight = FontWeight.SemiBold)
|
||||
}
|
||||
},
|
||||
colors = TopAppBarDefaults.topAppBarColors(
|
||||
containerColor = MaterialTheme.colorScheme.surface
|
||||
)
|
||||
)
|
||||
},
|
||||
snackbarHost = { SnackbarHost(snackbarHostState) }
|
||||
) { padding ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(padding)
|
||||
.verticalScroll(rememberScrollState())
|
||||
) {
|
||||
SettingsSectionHeader("Account")
|
||||
|
||||
SettingsRow(
|
||||
icon = Icons.Default.Fingerprint,
|
||||
title = "Device ID",
|
||||
subtitle = state.deviceId.ifEmpty { "—" },
|
||||
subtitleFont = FontFamily.Monospace,
|
||||
trailing = {
|
||||
IconButton(onClick = { clipboard.setText(AnnotatedString(state.deviceId)) }) {
|
||||
Icon(Icons.Default.ContentCopy, contentDescription = "Copy", modifier = Modifier.size(18.dp))
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
SettingsRow(
|
||||
icon = Icons.Default.Key,
|
||||
title = "API Key",
|
||||
subtitle = if (state.apiKey.isEmpty()) "—" else "••••••••" + state.apiKey.takeLast(4),
|
||||
trailing = {
|
||||
IconButton(onClick = { clipboard.setText(AnnotatedString(state.apiKey)) }) {
|
||||
Icon(Icons.Default.ContentCopy, contentDescription = "Copy", modifier = Modifier.size(18.dp))
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
SettingsRow(
|
||||
icon = Icons.Default.Edit,
|
||||
title = "Device Name",
|
||||
subtitle = state.deviceName,
|
||||
onClick = {
|
||||
editedDeviceName = state.deviceName
|
||||
showDeviceNameDialog = true
|
||||
},
|
||||
trailing = {
|
||||
if (state.isSavingDeviceName) {
|
||||
CircularProgressIndicator(modifier = Modifier.size(18.dp), strokeWidth = 2.dp)
|
||||
} else {
|
||||
Icon(Icons.Default.ChevronRight, contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
Divider(modifier = Modifier.padding(start = 56.dp))
|
||||
|
||||
SettingsRow(
|
||||
icon = Icons.Default.LinkOff,
|
||||
title = "Disconnect Device",
|
||||
titleColor = MaterialTheme.colorScheme.error,
|
||||
onClick = { showDisconnectDialog = true }
|
||||
)
|
||||
|
||||
SettingsSectionHeader("Gateway")
|
||||
|
||||
SettingsSwitchRow(
|
||||
icon = Icons.Default.Power,
|
||||
title = "Gateway Enabled",
|
||||
subtitle = "Allow sending and receiving SMS",
|
||||
checked = state.isGatewayEnabled,
|
||||
onCheckedChange = { viewModel.setGatewayEnabled(it) }
|
||||
)
|
||||
|
||||
if (state.availableSims.size > 1) {
|
||||
SimSelectionRow(
|
||||
sims = state.availableSims,
|
||||
selectedId = state.preferredSimSubscriptionId,
|
||||
onSelect = { viewModel.setPreferredSim(it) }
|
||||
)
|
||||
Text(
|
||||
text = "Use the simSubscriptionId field in your API requests to override this setting",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(start = 56.dp, end = 16.dp, bottom = 8.dp)
|
||||
)
|
||||
}
|
||||
|
||||
SettingsSectionHeader("SMS")
|
||||
|
||||
SettingsSwitchRow(
|
||||
icon = Icons.Default.MoveToInbox,
|
||||
title = "Receive SMS",
|
||||
subtitle = "Forward incoming SMS to backend",
|
||||
checked = state.isReceiveSmsEnabled,
|
||||
onCheckedChange = { viewModel.setReceiveSms(it) }
|
||||
)
|
||||
|
||||
SettingsRow(
|
||||
icon = Icons.Default.Timer,
|
||||
title = "Send Delay",
|
||||
subtitle = "${state.smsSendDelaySeconds}s between each SMS",
|
||||
onClick = {
|
||||
editedDelay = state.smsSendDelaySeconds.toString()
|
||||
showDelayDialog = true
|
||||
},
|
||||
trailing = {
|
||||
Icon(Icons.Default.ChevronRight, contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
)
|
||||
|
||||
SettingsRow(
|
||||
icon = Icons.Default.FilterList,
|
||||
title = "Configure Filters",
|
||||
subtitle = "Allow/block list for incoming SMS",
|
||||
onClick = onNavigateToFilters,
|
||||
trailing = {
|
||||
Icon(Icons.Default.ChevronRight, contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
)
|
||||
|
||||
SettingsSectionHeader("System")
|
||||
|
||||
SettingsSwitchRow(
|
||||
icon = Icons.Default.NotificationsActive,
|
||||
title = "Sticky Notification",
|
||||
subtitle = "Keeps the gateway alive in the background",
|
||||
checked = state.isStickyNotificationEnabled,
|
||||
onCheckedChange = { viewModel.setStickyNotification(it) }
|
||||
)
|
||||
|
||||
SettingsRow(
|
||||
icon = Icons.Default.Info,
|
||||
title = "App Version",
|
||||
subtitle = "${state.appVersionName} (Build ${state.appVersionCode})"
|
||||
)
|
||||
|
||||
SettingsRow(
|
||||
icon = Icons.Default.AutoAwesome,
|
||||
title = "About",
|
||||
subtitle = "textbee.dev",
|
||||
onClick = { showAboutDialog = true },
|
||||
trailing = {
|
||||
Icon(Icons.Default.ChevronRight, contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
)
|
||||
|
||||
SettingsRow(
|
||||
icon = Icons.Default.SystemUpdate,
|
||||
title = "Check for Updates",
|
||||
onClick = {
|
||||
val versionInfo = "${BuildConfig.VERSION_NAME}(${BuildConfig.VERSION_CODE})"
|
||||
val url = "https://textbee.dev/download?currentVersion=${Uri.encode(versionInfo)}"
|
||||
context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(url)))
|
||||
},
|
||||
trailing = {
|
||||
Icon(Icons.Default.OpenInBrowser, contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.size(18.dp))
|
||||
}
|
||||
)
|
||||
|
||||
SettingsSectionHeader("Community")
|
||||
|
||||
SettingsRow(
|
||||
icon = Icons.Default.SupportAgent,
|
||||
title = "Get Support",
|
||||
onClick = {
|
||||
context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse("https://app.textbee.dev/dashboard/account/get-support")))
|
||||
},
|
||||
trailing = {
|
||||
Icon(Icons.Default.OpenInBrowser, contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.size(18.dp))
|
||||
}
|
||||
)
|
||||
|
||||
SettingsRow(
|
||||
icon = Icons.Default.Share,
|
||||
title = "Share textbee",
|
||||
subtitle = "Help spread the word",
|
||||
onClick = {
|
||||
val shareText = "i've been using textbee.dev to send SMS via API from my own phone, " +
|
||||
"no Twilio or paid services needed. works great for automations, alerts, " +
|
||||
"notifications, or anything that needs programmatic SMS. open source and free to start\n\n" +
|
||||
"https://textbee.dev"
|
||||
context.startActivity(
|
||||
Intent.createChooser(
|
||||
Intent(Intent.ACTION_SEND).apply {
|
||||
type = "text/plain"
|
||||
putExtra(Intent.EXTRA_TEXT, shareText)
|
||||
},
|
||||
"Share TextBee"
|
||||
)
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
SettingsSectionHeader("Legal")
|
||||
|
||||
SettingsRow(
|
||||
icon = Icons.Default.Gavel,
|
||||
title = "Terms of Service",
|
||||
onClick = {
|
||||
context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse("https://textbee.dev/terms-of-service")))
|
||||
},
|
||||
trailing = {
|
||||
Icon(Icons.Default.OpenInBrowser, contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.size(18.dp))
|
||||
}
|
||||
)
|
||||
|
||||
SettingsRow(
|
||||
icon = Icons.Default.Policy,
|
||||
title = "Privacy Policy",
|
||||
onClick = {
|
||||
context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse("https://textbee.dev/privacy-policy")))
|
||||
},
|
||||
trailing = {
|
||||
Icon(Icons.Default.OpenInBrowser, contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.size(18.dp))
|
||||
}
|
||||
)
|
||||
|
||||
SettingsSectionHeader("UI")
|
||||
|
||||
SettingsRow(
|
||||
icon = Icons.Default.SwapHoriz,
|
||||
title = "Switch to Legacy UI",
|
||||
subtitle = "Use the original interface",
|
||||
onClick = { showLegacyDialog = true },
|
||||
trailing = {
|
||||
Icon(Icons.Default.ChevronRight, contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(32.dp))
|
||||
}
|
||||
}
|
||||
|
||||
if (showDeviceNameDialog) {
|
||||
AlertDialog(
|
||||
onDismissRequest = { showDeviceNameDialog = false },
|
||||
title = { Text("Edit Device Name") },
|
||||
text = {
|
||||
OutlinedTextField(
|
||||
value = editedDeviceName,
|
||||
onValueChange = { editedDeviceName = it },
|
||||
label = { Text("Device Name") },
|
||||
singleLine = true
|
||||
)
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = {
|
||||
viewModel.saveDeviceName(editedDeviceName)
|
||||
showDeviceNameDialog = false
|
||||
}) { Text("Save") }
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { showDeviceNameDialog = false }) { Text("Cancel") }
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
if (showDelayDialog) {
|
||||
AlertDialog(
|
||||
onDismissRequest = { showDelayDialog = false },
|
||||
title = { Text("SMS Send Delay") },
|
||||
text = {
|
||||
Column {
|
||||
Text(
|
||||
"Delay between each SMS in seconds (0–3600)",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
OutlinedTextField(
|
||||
value = editedDelay,
|
||||
onValueChange = { editedDelay = it.filter { c -> c.isDigit() } },
|
||||
label = { Text("Seconds") },
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
|
||||
singleLine = true
|
||||
)
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = {
|
||||
viewModel.setSmsSendDelay(editedDelay.toIntOrNull() ?: 5)
|
||||
showDelayDialog = false
|
||||
}) { Text("Save") }
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { showDelayDialog = false }) { Text("Cancel") }
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
if (showLegacyDialog) {
|
||||
AlertDialog(
|
||||
onDismissRequest = { showLegacyDialog = false },
|
||||
title = { Text("Switch to Legacy UI?") },
|
||||
text = { Text("You can switch back from the Legacy Settings screen anytime.") },
|
||||
confirmButton = {
|
||||
TextButton(onClick = {
|
||||
showLegacyDialog = false
|
||||
onSwitchToLegacy()
|
||||
}) { Text("Switch") }
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { showLegacyDialog = false }) { Text("Cancel") }
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
if (showAboutDialog) {
|
||||
AlertDialog(
|
||||
onDismissRequest = { showAboutDialog = false },
|
||||
title = {
|
||||
Text("textbee.dev", fontWeight = FontWeight.Bold)
|
||||
},
|
||||
text = {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Text(
|
||||
"An open-source SMS gateway that turns your Android into a personal SMS API. " +
|
||||
"Send and receive messages programmatically without relying on expensive third-party services.",
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
OutlinedButton(
|
||||
onClick = {
|
||||
context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse("https://textbee.dev")))
|
||||
}
|
||||
) {
|
||||
Text("textbee.dev")
|
||||
}
|
||||
OutlinedButton(
|
||||
onClick = {
|
||||
context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse("https://github.com/vernu/textbee")))
|
||||
}
|
||||
) {
|
||||
Text("GitHub")
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = { showAboutDialog = false }) { Text("Close") }
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
if (showDisconnectDialog) {
|
||||
AlertDialog(
|
||||
onDismissRequest = { showDisconnectDialog = false },
|
||||
title = { Text("Disconnect Device?") },
|
||||
text = { Text("This will remove all credentials from this device. You'll need to reconnect to use the gateway.") },
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
onClick = {
|
||||
showDisconnectDialog = false
|
||||
onDisconnect()
|
||||
},
|
||||
colors = ButtonDefaults.textButtonColors(contentColor = MaterialTheme.colorScheme.error)
|
||||
) { Text("Disconnect") }
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { showDisconnectDialog = false }) { Text("Cancel") }
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SettingsSectionHeader(title: String) {
|
||||
Text(
|
||||
text = title.uppercase(),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.padding(start = 16.dp, top = 20.dp, bottom = 4.dp)
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SettingsRow(
|
||||
icon: androidx.compose.ui.graphics.vector.ImageVector,
|
||||
title: String,
|
||||
subtitle: String? = null,
|
||||
subtitleFont: FontFamily = FontFamily.Default,
|
||||
titleColor: androidx.compose.ui.graphics.Color = MaterialTheme.colorScheme.onSurface,
|
||||
onClick: (() -> Unit)? = null,
|
||||
trailing: @Composable (() -> Unit)? = null
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.then(if (onClick != null) Modifier.clickable(onClick = onClick) else Modifier)
|
||||
.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Icon(
|
||||
imageVector = icon,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(24.dp),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
Spacer(modifier = Modifier.width(16.dp))
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(text = title, style = MaterialTheme.typography.bodyLarge, color = titleColor)
|
||||
subtitle?.let {
|
||||
Text(
|
||||
text = it,
|
||||
style = MaterialTheme.typography.bodySmall.copy(fontFamily = subtitleFont),
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
trailing?.invoke()
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SettingsSwitchRow(
|
||||
icon: androidx.compose.ui.graphics.vector.ImageVector,
|
||||
title: String,
|
||||
subtitle: String? = null,
|
||||
checked: Boolean,
|
||||
onCheckedChange: (Boolean) -> Unit
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Icon(
|
||||
imageVector = icon,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(24.dp),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
Spacer(modifier = Modifier.width(16.dp))
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(text = title, style = MaterialTheme.typography.bodyLarge)
|
||||
subtitle?.let {
|
||||
Text(text = it, style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
}
|
||||
Switch(checked = checked, onCheckedChange = onCheckedChange)
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun SimSelectionRow(
|
||||
sims: List<SimOption>,
|
||||
selectedId: Int,
|
||||
onSelect: (Int) -> Unit
|
||||
) {
|
||||
var expanded by remember { mutableStateOf(false) }
|
||||
val selectedSim = sims.find { it.subscriptionId == selectedId }
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.SimCard,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(24.dp),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
Spacer(modifier = Modifier.width(16.dp))
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(text = "Default SIM", style = MaterialTheme.typography.bodyLarge)
|
||||
ExposedDropdownMenuBox(
|
||||
expanded = expanded,
|
||||
onExpandedChange = { expanded = !expanded }
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = selectedSim?.let { "${it.displayName} · ID: ${it.subscriptionId}" }
|
||||
?: "Device Default",
|
||||
onValueChange = {},
|
||||
readOnly = true,
|
||||
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) },
|
||||
modifier = Modifier
|
||||
.menuAnchor()
|
||||
.fillMaxWidth(),
|
||||
textStyle = MaterialTheme.typography.bodySmall
|
||||
)
|
||||
ExposedDropdownMenu(
|
||||
expanded = expanded,
|
||||
onDismissRequest = { expanded = false }
|
||||
) {
|
||||
DropdownMenuItem(
|
||||
text = { Text("Device Default") },
|
||||
onClick = {
|
||||
onSelect(-1)
|
||||
expanded = false
|
||||
}
|
||||
)
|
||||
sims.forEach { sim ->
|
||||
DropdownMenuItem(
|
||||
text = { Text("${sim.displayName} · ID: ${sim.subscriptionId}") },
|
||||
onClick = {
|
||||
onSelect(sim.subscriptionId)
|
||||
expanded = false
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
package com.vernu.sms.ui.settings
|
||||
|
||||
import android.app.Application
|
||||
import android.os.Build
|
||||
import android.telephony.SubscriptionManager
|
||||
import androidx.lifecycle.AndroidViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.vernu.sms.ApiManagerKt
|
||||
import com.vernu.sms.AppConstants
|
||||
import com.vernu.sms.BuildConfig
|
||||
import com.vernu.sms.TextBeeUtils
|
||||
import com.vernu.sms.dtos.RegisterDeviceInputDTO
|
||||
import com.vernu.sms.helpers.SharedPreferenceHelper
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import org.json.JSONObject
|
||||
import retrofit2.Response
|
||||
|
||||
data class SimOption(val subscriptionId: Int, val displayName: String)
|
||||
|
||||
data class SettingsState(
|
||||
val deviceId: String = "",
|
||||
val apiKey: String = "",
|
||||
val deviceName: String = "",
|
||||
val isGatewayEnabled: Boolean = false,
|
||||
val isReceiveSmsEnabled: Boolean = false,
|
||||
val isStickyNotificationEnabled: Boolean = false,
|
||||
val smsSendDelaySeconds: Int = AppConstants.DEFAULT_SMS_SEND_DELAY_SECONDS,
|
||||
val preferredSimSubscriptionId: Int = -1,
|
||||
val availableSims: List<SimOption> = emptyList(),
|
||||
val appVersionName: String = BuildConfig.VERSION_NAME,
|
||||
val appVersionCode: Int = BuildConfig.VERSION_CODE,
|
||||
val isSavingDeviceName: Boolean = false,
|
||||
val snackbarMessage: String? = null
|
||||
)
|
||||
|
||||
class SettingsViewModel(app: Application) : AndroidViewModel(app) {
|
||||
|
||||
private val context get() = getApplication<Application>().applicationContext
|
||||
|
||||
private val _state = MutableStateFlow(SettingsState())
|
||||
val state: StateFlow<SettingsState> = _state.asStateFlow()
|
||||
|
||||
init {
|
||||
loadSettings()
|
||||
}
|
||||
|
||||
private fun loadSettings() {
|
||||
val deviceId = SharedPreferenceHelper.getSharedPreferenceString(
|
||||
context, AppConstants.SHARED_PREFS_DEVICE_ID_KEY, ""
|
||||
) ?: ""
|
||||
val apiKey = SharedPreferenceHelper.getSharedPreferenceString(
|
||||
context, AppConstants.SHARED_PREFS_API_KEY_KEY, ""
|
||||
) ?: ""
|
||||
val deviceName = SharedPreferenceHelper.getSharedPreferenceString(
|
||||
context, AppConstants.SHARED_PREFS_DEVICE_NAME_KEY, ""
|
||||
) ?: ""
|
||||
val isGatewayEnabled = SharedPreferenceHelper.getSharedPreferenceBoolean(
|
||||
context, AppConstants.SHARED_PREFS_GATEWAY_ENABLED_KEY, false
|
||||
)
|
||||
val isReceiveSms = SharedPreferenceHelper.getSharedPreferenceBoolean(
|
||||
context, AppConstants.SHARED_PREFS_RECEIVE_SMS_ENABLED_KEY, false
|
||||
)
|
||||
val isSticky = SharedPreferenceHelper.getSharedPreferenceBoolean(
|
||||
context, AppConstants.SHARED_PREFS_STICKY_NOTIFICATION_ENABLED_KEY, false
|
||||
)
|
||||
val smsDelay = SharedPreferenceHelper.getSharedPreferenceInt(
|
||||
context, AppConstants.SHARED_PREFS_SMS_SEND_DELAY_SECONDS_KEY,
|
||||
AppConstants.DEFAULT_SMS_SEND_DELAY_SECONDS
|
||||
)
|
||||
val preferredSim = SharedPreferenceHelper.getSharedPreferenceInt(
|
||||
context, AppConstants.SHARED_PREFS_PREFERRED_SIM_KEY, -1
|
||||
)
|
||||
|
||||
val sims = try {
|
||||
TextBeeUtils.getAvailableSimSlots(context).map { info ->
|
||||
SimOption(
|
||||
subscriptionId = info.subscriptionId,
|
||||
displayName = "${info.carrierName} (SIM ${info.simSlotIndex + 1})"
|
||||
)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
emptyList()
|
||||
}
|
||||
|
||||
_state.update {
|
||||
it.copy(
|
||||
deviceId = deviceId,
|
||||
apiKey = apiKey,
|
||||
deviceName = deviceName.ifEmpty { "${Build.BRAND} ${Build.MODEL}" },
|
||||
isGatewayEnabled = isGatewayEnabled,
|
||||
isReceiveSmsEnabled = isReceiveSms,
|
||||
isStickyNotificationEnabled = isSticky,
|
||||
smsSendDelaySeconds = smsDelay,
|
||||
preferredSimSubscriptionId = preferredSim,
|
||||
availableSims = sims
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun setGatewayEnabled(enabled: Boolean) {
|
||||
val deviceId = _state.value.deviceId
|
||||
val apiKey = _state.value.apiKey
|
||||
if (deviceId.isEmpty() || apiKey.isEmpty()) return
|
||||
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
val input = RegisterDeviceInputDTO().apply { this.enabled = enabled }
|
||||
val response = ApiManagerKt.getApiService().updateDevice(deviceId, apiKey, input)
|
||||
if (response.isSuccessful) {
|
||||
SharedPreferenceHelper.setSharedPreferenceBoolean(
|
||||
context, AppConstants.SHARED_PREFS_GATEWAY_ENABLED_KEY, enabled
|
||||
)
|
||||
_state.update { it.copy(isGatewayEnabled = enabled) }
|
||||
if (enabled) {
|
||||
TextBeeUtils.startStickyNotificationService(context)
|
||||
com.vernu.sms.helpers.HeartbeatManager.scheduleHeartbeat(context)
|
||||
} else {
|
||||
TextBeeUtils.stopStickyNotificationService(context)
|
||||
com.vernu.sms.helpers.HeartbeatManager.cancelHeartbeat(context)
|
||||
}
|
||||
} else {
|
||||
_state.update { it.copy(snackbarMessage = extractErrorMessage(response, "Failed to update gateway status")) }
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
_state.update { it.copy(snackbarMessage = "Network error. Try again.") }
|
||||
TextBeeUtils.logException(e, "Gateway toggle from settings failed")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun setReceiveSms(enabled: Boolean) {
|
||||
SharedPreferenceHelper.setSharedPreferenceBoolean(
|
||||
context, AppConstants.SHARED_PREFS_RECEIVE_SMS_ENABLED_KEY, enabled
|
||||
)
|
||||
_state.update { it.copy(isReceiveSmsEnabled = enabled) }
|
||||
}
|
||||
|
||||
fun setStickyNotification(enabled: Boolean) {
|
||||
SharedPreferenceHelper.setSharedPreferenceBoolean(
|
||||
context, AppConstants.SHARED_PREFS_STICKY_NOTIFICATION_ENABLED_KEY, enabled
|
||||
)
|
||||
try {
|
||||
if (enabled) TextBeeUtils.startStickyNotificationService(context)
|
||||
else TextBeeUtils.stopStickyNotificationService(context)
|
||||
} catch (e: Exception) {
|
||||
TextBeeUtils.logException(e, "Sticky notification toggle failed")
|
||||
_state.update { it.copy(snackbarMessage = "Could not start notification service") }
|
||||
}
|
||||
_state.update { it.copy(isStickyNotificationEnabled = enabled) }
|
||||
}
|
||||
|
||||
fun setSmsSendDelay(seconds: Int) {
|
||||
val clamped = seconds.coerceIn(0, 3600)
|
||||
SharedPreferenceHelper.setSharedPreferenceInt(
|
||||
context, AppConstants.SHARED_PREFS_SMS_SEND_DELAY_SECONDS_KEY, clamped
|
||||
)
|
||||
_state.update { it.copy(smsSendDelaySeconds = clamped) }
|
||||
}
|
||||
|
||||
fun setPreferredSim(subscriptionId: Int) {
|
||||
SharedPreferenceHelper.setSharedPreferenceInt(
|
||||
context, AppConstants.SHARED_PREFS_PREFERRED_SIM_KEY, subscriptionId
|
||||
)
|
||||
_state.update { it.copy(preferredSimSubscriptionId = subscriptionId) }
|
||||
}
|
||||
|
||||
fun saveDeviceName(name: String) {
|
||||
val deviceId = _state.value.deviceId
|
||||
val apiKey = _state.value.apiKey
|
||||
if (deviceId.isEmpty() || apiKey.isEmpty() || name.isBlank()) return
|
||||
|
||||
viewModelScope.launch {
|
||||
_state.update { it.copy(isSavingDeviceName = true) }
|
||||
try {
|
||||
val input = RegisterDeviceInputDTO().apply { this.name = name.trim() }
|
||||
val response = ApiManagerKt.getApiService().updateDevice(deviceId, apiKey, input)
|
||||
if (response.isSuccessful) {
|
||||
SharedPreferenceHelper.setSharedPreferenceString(
|
||||
context, AppConstants.SHARED_PREFS_DEVICE_NAME_KEY, name.trim()
|
||||
)
|
||||
_state.update { it.copy(deviceName = name.trim(), snackbarMessage = "Device name saved") }
|
||||
} else {
|
||||
_state.update { it.copy(snackbarMessage = extractErrorMessage(response, "Failed to save device name")) }
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
_state.update { it.copy(snackbarMessage = "Network error. Try again.") }
|
||||
TextBeeUtils.logException(e, "Save device name failed")
|
||||
} finally {
|
||||
_state.update { it.copy(isSavingDeviceName = false) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun clearSnackbar() = _state.update { it.copy(snackbarMessage = null) }
|
||||
|
||||
private fun extractErrorMessage(response: Response<*>, fallback: String): String {
|
||||
return try {
|
||||
val body = response.errorBody()?.string()
|
||||
if (!body.isNullOrBlank()) {
|
||||
val json = JSONObject(body)
|
||||
json.optString("message").takeIf { it.isNotBlank() }
|
||||
?: json.optString("error").takeIf { it.isNotBlank() }
|
||||
?: fallback
|
||||
} else {
|
||||
fallback
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
fallback
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
package com.vernu.sms.ui.splash
|
||||
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.vernu.sms.R
|
||||
import com.vernu.sms.AppConstants
|
||||
import com.vernu.sms.activities.MainActivity
|
||||
import com.vernu.sms.helpers.SharedPreferenceHelper
|
||||
import com.vernu.sms.ui.main.NewMainActivity
|
||||
import com.vernu.sms.ui.onboarding.OnboardingActivity
|
||||
import com.vernu.sms.ui.theme.TextBeeTheme
|
||||
|
||||
class SplashActivity : ComponentActivity() {
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
setContent {
|
||||
TextBeeTheme {
|
||||
SplashContent()
|
||||
}
|
||||
}
|
||||
Handler(Looper.getMainLooper()).postDelayed({ route() }, 400)
|
||||
}
|
||||
|
||||
private fun route() {
|
||||
val useNewUi = SharedPreferenceHelper.getSharedPreferenceBoolean(
|
||||
this, AppConstants.SHARED_PREFS_USE_NEW_UI_KEY, true
|
||||
)
|
||||
val deviceId = SharedPreferenceHelper.getSharedPreferenceString(
|
||||
this, AppConstants.SHARED_PREFS_DEVICE_ID_KEY, ""
|
||||
)
|
||||
val target = when {
|
||||
!useNewUi -> Intent(this, MainActivity::class.java)
|
||||
deviceId.isNullOrEmpty() -> Intent(this, OnboardingActivity::class.java)
|
||||
else -> Intent(this, NewMainActivity::class.java)
|
||||
}
|
||||
startActivity(target)
|
||||
finish()
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SplashContent() {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(MaterialTheme.colorScheme.primary),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Image(
|
||||
painter = painterResource(id = R.drawable.ic_app_logo),
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(96.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
Text(
|
||||
text = "textbee",
|
||||
style = MaterialTheme.typography.headlineLarge,
|
||||
color = Color.White,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Text(
|
||||
text = "SMS Gateway",
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = Color.White.copy(alpha = 0.8f)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package com.vernu.sms.ui.theme
|
||||
|
||||
import androidx.compose.ui.graphics.Color
|
||||
|
||||
val Orange700 = Color(0xFFC4620A)
|
||||
val Orange800 = Color(0xFFA04405)
|
||||
val Orange600 = Color(0xFFB45309)
|
||||
val Orange900 = Color(0xFF92400E)
|
||||
val OrangeLight = Color(0xFFFFF7ED)
|
||||
|
||||
val Gray50 = Color(0xFFF9FAFB)
|
||||
val Gray100 = Color(0xFFF3F4F6)
|
||||
val Gray200 = Color(0xFFE5E7EB)
|
||||
val Gray400 = Color(0xFF9CA3AF)
|
||||
val Gray600 = Color(0xFF4B5563)
|
||||
val Gray900 = Color(0xFF111827)
|
||||
|
||||
val Green500 = Color(0xFF22C55E)
|
||||
val Red500 = Color(0xFFEF4444)
|
||||
val Blue500 = Color(0xFF3B82F6)
|
||||
@@ -0,0 +1,84 @@
|
||||
package com.vernu.sms.ui.theme
|
||||
|
||||
import android.app.Activity
|
||||
import android.os.Build
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.darkColorScheme
|
||||
import androidx.compose.material3.dynamicDarkColorScheme
|
||||
import androidx.compose.material3.dynamicLightColorScheme
|
||||
import androidx.compose.material3.lightColorScheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.SideEffect
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.toArgb
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalView
|
||||
import androidx.core.view.WindowCompat
|
||||
|
||||
private val LightColorScheme = lightColorScheme(
|
||||
primary = Orange700,
|
||||
onPrimary = Color.White,
|
||||
primaryContainer = OrangeLight,
|
||||
onPrimaryContainer = Orange900,
|
||||
secondary = Orange600,
|
||||
onSecondary = Color.White,
|
||||
secondaryContainer = OrangeLight,
|
||||
onSecondaryContainer = Orange900,
|
||||
background = Gray50,
|
||||
onBackground = Gray900,
|
||||
surface = Color.White,
|
||||
onSurface = Gray900,
|
||||
surfaceVariant = Gray100,
|
||||
onSurfaceVariant = Gray600,
|
||||
outline = Gray200,
|
||||
error = Red500,
|
||||
)
|
||||
|
||||
private val DarkColorScheme = darkColorScheme(
|
||||
primary = Orange600,
|
||||
onPrimary = Color.White,
|
||||
primaryContainer = Orange900,
|
||||
onPrimaryContainer = OrangeLight,
|
||||
secondary = Orange700,
|
||||
onSecondary = Color.White,
|
||||
background = Gray900,
|
||||
onBackground = Gray100,
|
||||
surface = Color(0xFF1F2937),
|
||||
onSurface = Gray100,
|
||||
surfaceVariant = Color(0xFF374151),
|
||||
onSurfaceVariant = Gray400,
|
||||
outline = Color(0xFF4B5563),
|
||||
error = Red500,
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun TextBeeTheme(
|
||||
darkTheme: Boolean = isSystemInDarkTheme(),
|
||||
dynamicColor: Boolean = false,
|
||||
content: @Composable () -> Unit
|
||||
) {
|
||||
val colorScheme = when {
|
||||
dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
|
||||
val context = LocalContext.current
|
||||
if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
|
||||
}
|
||||
darkTheme -> DarkColorScheme
|
||||
else -> LightColorScheme
|
||||
}
|
||||
|
||||
val view = LocalView.current
|
||||
if (!view.isInEditMode) {
|
||||
SideEffect {
|
||||
val window = (view.context as Activity).window
|
||||
window.statusBarColor = colorScheme.background.toArgb()
|
||||
WindowCompat.getInsetsController(window, view).isAppearanceLightStatusBars = !darkTheme
|
||||
}
|
||||
}
|
||||
|
||||
MaterialTheme(
|
||||
colorScheme = colorScheme,
|
||||
typography = Typography,
|
||||
content = content
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package com.vernu.sms.ui.theme
|
||||
|
||||
import androidx.compose.material3.Typography
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.sp
|
||||
|
||||
val Typography = Typography(
|
||||
headlineLarge = TextStyle(
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontSize = 28.sp,
|
||||
lineHeight = 36.sp
|
||||
),
|
||||
headlineMedium = TextStyle(
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
fontSize = 22.sp,
|
||||
lineHeight = 30.sp
|
||||
),
|
||||
titleLarge = TextStyle(
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
fontSize = 18.sp,
|
||||
lineHeight = 26.sp
|
||||
),
|
||||
titleMedium = TextStyle(
|
||||
fontWeight = FontWeight.Medium,
|
||||
fontSize = 16.sp,
|
||||
lineHeight = 24.sp
|
||||
),
|
||||
bodyLarge = TextStyle(
|
||||
fontWeight = FontWeight.Normal,
|
||||
fontSize = 16.sp,
|
||||
lineHeight = 24.sp
|
||||
),
|
||||
bodyMedium = TextStyle(
|
||||
fontWeight = FontWeight.Normal,
|
||||
fontSize = 14.sp,
|
||||
lineHeight = 20.sp
|
||||
),
|
||||
labelLarge = TextStyle(
|
||||
fontWeight = FontWeight.Medium,
|
||||
fontSize = 14.sp,
|
||||
lineHeight = 20.sp
|
||||
),
|
||||
labelSmall = TextStyle(
|
||||
fontWeight = FontWeight.Medium,
|
||||
fontSize = 11.sp,
|
||||
lineHeight = 16.sp
|
||||
)
|
||||
)
|
||||
@@ -0,0 +1,38 @@
|
||||
package com.vernu.sms.workers
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import androidx.work.Worker
|
||||
import androidx.work.WorkerParameters
|
||||
import com.vernu.sms.AppConstants
|
||||
import com.vernu.sms.helpers.HeartbeatHelper
|
||||
import com.vernu.sms.helpers.SharedPreferenceHelper
|
||||
|
||||
class HeartbeatWorker(context: Context, workerParams: WorkerParameters) : Worker(context, workerParams) {
|
||||
companion object {
|
||||
private const val TAG = "HeartbeatWorker"
|
||||
}
|
||||
|
||||
override fun doWork(): Result {
|
||||
val context = applicationContext
|
||||
|
||||
if (!HeartbeatHelper.isDeviceEligibleForHeartbeat(context)) {
|
||||
Log.d(TAG, "Device not eligible for heartbeat, skipping")
|
||||
return Result.success()
|
||||
}
|
||||
|
||||
val deviceId = SharedPreferenceHelper.getSharedPreferenceString(
|
||||
context, AppConstants.SHARED_PREFS_DEVICE_ID_KEY, ""
|
||||
) ?: ""
|
||||
val apiKey = SharedPreferenceHelper.getSharedPreferenceString(
|
||||
context, AppConstants.SHARED_PREFS_API_KEY_KEY, ""
|
||||
) ?: ""
|
||||
|
||||
return if (HeartbeatHelper.sendHeartbeat(context, deviceId, apiKey)) {
|
||||
Result.success()
|
||||
} else {
|
||||
Log.e(TAG, "Failed to send heartbeat, will retry")
|
||||
Result.retry()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,106 +0,0 @@
|
||||
package com.vernu.sms.workers;
|
||||
|
||||
import android.content.Context;
|
||||
import android.util.Log;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.work.Data;
|
||||
import androidx.work.Worker;
|
||||
import androidx.work.WorkerParameters;
|
||||
import androidx.work.BackoffPolicy;
|
||||
import androidx.work.Constraints;
|
||||
import androidx.work.NetworkType;
|
||||
import androidx.work.OneTimeWorkRequest;
|
||||
import androidx.work.WorkManager;
|
||||
|
||||
import com.google.gson.Gson;
|
||||
import com.vernu.sms.ApiManager;
|
||||
import com.vernu.sms.dtos.SMSDTO;
|
||||
import com.vernu.sms.dtos.SMSForwardResponseDTO;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
import retrofit2.Call;
|
||||
import retrofit2.Response;
|
||||
|
||||
public class SMSReceivedWorker extends Worker {
|
||||
private static final String TAG = "SMSReceivedWorker";
|
||||
private static final int MAX_RETRIES = 5;
|
||||
|
||||
public static final String KEY_DEVICE_ID = "device_id";
|
||||
public static final String KEY_API_KEY = "api_key";
|
||||
public static final String KEY_SMS_DTO = "sms_dto";
|
||||
public static final String KEY_RETRY_COUNT = "retry_count";
|
||||
|
||||
public SMSReceivedWorker(@NonNull Context context, @NonNull WorkerParameters workerParams) {
|
||||
super(context, workerParams);
|
||||
}
|
||||
|
||||
@NonNull
|
||||
@Override
|
||||
public Result doWork() {
|
||||
String deviceId = getInputData().getString(KEY_DEVICE_ID);
|
||||
String apiKey = getInputData().getString(KEY_API_KEY);
|
||||
String smsDtoJson = getInputData().getString(KEY_SMS_DTO);
|
||||
int retryCount = getInputData().getInt(KEY_RETRY_COUNT, 0);
|
||||
|
||||
if (deviceId == null || apiKey == null || smsDtoJson == null) {
|
||||
Log.e(TAG, "Missing required parameters");
|
||||
return Result.failure();
|
||||
}
|
||||
|
||||
// Check if we've exceeded the maximum retry count
|
||||
if (retryCount >= MAX_RETRIES) {
|
||||
Log.e(TAG, "Maximum retry count reached for received SMS");
|
||||
return Result.failure();
|
||||
}
|
||||
|
||||
SMSDTO smsDTO = new Gson().fromJson(smsDtoJson, SMSDTO.class);
|
||||
|
||||
try {
|
||||
Call<SMSForwardResponseDTO> call = ApiManager.getApiService().sendReceivedSMS(deviceId, apiKey, smsDTO);
|
||||
Response<SMSForwardResponseDTO> response = call.execute();
|
||||
|
||||
if (response.isSuccessful()) {
|
||||
Log.d(TAG, "Received SMS sent to server successfully");
|
||||
return Result.success();
|
||||
} else {
|
||||
Log.e(TAG, "Failed to send received SMS to server. Response code: " + response.code());
|
||||
return Result.retry();
|
||||
}
|
||||
} catch (IOException e) {
|
||||
Log.e(TAG, "API call failed: " + e.getMessage());
|
||||
return Result.retry();
|
||||
}
|
||||
}
|
||||
|
||||
public static void enqueueWork(Context context, String deviceId, String apiKey, SMSDTO smsDTO) {
|
||||
Data inputData = new Data.Builder()
|
||||
.putString(KEY_DEVICE_ID, deviceId)
|
||||
.putString(KEY_API_KEY, apiKey)
|
||||
.putString(KEY_SMS_DTO, new Gson().toJson(smsDTO))
|
||||
.putInt(KEY_RETRY_COUNT, 0)
|
||||
.build();
|
||||
|
||||
Constraints constraints = new Constraints.Builder()
|
||||
.setRequiredNetworkType(NetworkType.CONNECTED)
|
||||
.build();
|
||||
|
||||
OneTimeWorkRequest workRequest = new OneTimeWorkRequest.Builder(SMSReceivedWorker.class)
|
||||
.setConstraints(constraints)
|
||||
.setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 10, TimeUnit.SECONDS)
|
||||
.setInputData(inputData)
|
||||
.addTag("sms_received")
|
||||
.build();
|
||||
|
||||
String uniqueWorkName = "sms_received_" + System.currentTimeMillis();
|
||||
WorkManager.getInstance(context)
|
||||
.beginUniqueWork(uniqueWorkName,
|
||||
androidx.work.ExistingWorkPolicy.APPEND_OR_REPLACE,
|
||||
workRequest)
|
||||
.enqueue();
|
||||
|
||||
Log.d(TAG, "Work enqueued for received SMS from: " + smsDTO.getSender());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
package com.vernu.sms.workers
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import androidx.work.*
|
||||
import com.google.gson.Gson
|
||||
import com.vernu.sms.ApiManager
|
||||
import com.vernu.sms.dtos.SMSDTO
|
||||
import com.vernu.sms.dtos.SMSForwardResponseDTO
|
||||
import java.io.IOException
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
class SMSReceivedWorker(context: Context, workerParams: WorkerParameters) : Worker(context, workerParams) {
|
||||
companion object {
|
||||
private const val TAG = "SMSReceivedWorker"
|
||||
private const val MAX_RETRIES = 5
|
||||
|
||||
const val KEY_DEVICE_ID = "device_id"
|
||||
const val KEY_API_KEY = "api_key"
|
||||
const val KEY_SMS_DTO = "sms_dto"
|
||||
const val KEY_RETRY_COUNT = "retry_count"
|
||||
|
||||
fun enqueueWork(context: Context, deviceId: String, apiKey: String, smsDTO: SMSDTO) {
|
||||
val inputData = Data.Builder()
|
||||
.putString(KEY_DEVICE_ID, deviceId)
|
||||
.putString(KEY_API_KEY, apiKey)
|
||||
.putString(KEY_SMS_DTO, Gson().toJson(smsDTO))
|
||||
.putInt(KEY_RETRY_COUNT, 0)
|
||||
.build()
|
||||
|
||||
val constraints = Constraints.Builder()
|
||||
.setRequiredNetworkType(NetworkType.CONNECTED)
|
||||
.build()
|
||||
|
||||
val workRequest = OneTimeWorkRequest.Builder(SMSReceivedWorker::class.java)
|
||||
.setConstraints(constraints)
|
||||
.setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 10, TimeUnit.SECONDS)
|
||||
.setInputData(inputData)
|
||||
.addTag("sms_received")
|
||||
.build()
|
||||
|
||||
val fp = smsDTO.fingerprint
|
||||
val uniqueWorkName = if (!fp.isNullOrEmpty()) {
|
||||
"sms_received_$fp"
|
||||
} else {
|
||||
Log.w(TAG, "Fingerprint not available, using timestamp for work name")
|
||||
"sms_received_${System.currentTimeMillis()}"
|
||||
}
|
||||
|
||||
WorkManager.getInstance(context)
|
||||
.beginUniqueWork(uniqueWorkName, ExistingWorkPolicy.KEEP, workRequest)
|
||||
.enqueue()
|
||||
|
||||
Log.d(TAG, "Work enqueued for received SMS from: ${smsDTO.sender} with fingerprint: $uniqueWorkName")
|
||||
}
|
||||
}
|
||||
|
||||
override fun doWork(): Result {
|
||||
val deviceId = inputData.getString(KEY_DEVICE_ID)
|
||||
val apiKey = inputData.getString(KEY_API_KEY)
|
||||
val smsDtoJson = inputData.getString(KEY_SMS_DTO)
|
||||
val retryCount = inputData.getInt(KEY_RETRY_COUNT, 0)
|
||||
|
||||
if (deviceId == null || apiKey == null || smsDtoJson == null) {
|
||||
Log.e(TAG, "Missing required parameters")
|
||||
return Result.failure()
|
||||
}
|
||||
|
||||
if (retryCount >= MAX_RETRIES) {
|
||||
Log.e(TAG, "Maximum retry count reached for received SMS")
|
||||
return Result.failure()
|
||||
}
|
||||
|
||||
val smsDTO = Gson().fromJson(smsDtoJson, SMSDTO::class.java)
|
||||
|
||||
return try {
|
||||
val response = ApiManager.getApiService().sendReceivedSMS(deviceId, apiKey, smsDTO).execute()
|
||||
if (response.isSuccessful) {
|
||||
Log.d(TAG, "Received SMS sent to server successfully")
|
||||
Result.success()
|
||||
} else {
|
||||
Log.e(TAG, "Failed to send received SMS to server. Response code: ${response.code()}")
|
||||
Result.retry()
|
||||
}
|
||||
} catch (e: IOException) {
|
||||
Log.e(TAG, "API call failed: ${e.message}")
|
||||
Result.retry()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,105 +0,0 @@
|
||||
package com.vernu.sms.workers;
|
||||
|
||||
import android.content.Context;
|
||||
import android.util.Log;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.work.Data;
|
||||
import androidx.work.Worker;
|
||||
import androidx.work.WorkerParameters;
|
||||
import androidx.work.BackoffPolicy;
|
||||
import androidx.work.Constraints;
|
||||
import androidx.work.NetworkType;
|
||||
import androidx.work.OneTimeWorkRequest;
|
||||
import androidx.work.WorkManager;
|
||||
|
||||
import com.google.gson.Gson;
|
||||
import com.vernu.sms.ApiManager;
|
||||
import com.vernu.sms.dtos.SMSDTO;
|
||||
import com.vernu.sms.dtos.SMSForwardResponseDTO;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
import retrofit2.Call;
|
||||
import retrofit2.Response;
|
||||
|
||||
public class SMSStatusUpdateWorker extends Worker {
|
||||
private static final String TAG = "SMSStatusUpdateWorker";
|
||||
private static final int MAX_RETRIES = 5;
|
||||
|
||||
public static final String KEY_DEVICE_ID = "device_id";
|
||||
public static final String KEY_API_KEY = "api_key";
|
||||
public static final String KEY_SMS_DTO = "sms_dto";
|
||||
public static final String KEY_RETRY_COUNT = "retry_count";
|
||||
|
||||
public SMSStatusUpdateWorker(@NonNull Context context, @NonNull WorkerParameters workerParams) {
|
||||
super(context, workerParams);
|
||||
}
|
||||
|
||||
@NonNull
|
||||
@Override
|
||||
public Result doWork() {
|
||||
String deviceId = getInputData().getString(KEY_DEVICE_ID);
|
||||
String apiKey = getInputData().getString(KEY_API_KEY);
|
||||
String smsDtoJson = getInputData().getString(KEY_SMS_DTO);
|
||||
int retryCount = getInputData().getInt(KEY_RETRY_COUNT, 0);
|
||||
|
||||
if (deviceId == null || apiKey == null || smsDtoJson == null) {
|
||||
Log.e(TAG, "Missing required parameters");
|
||||
return Result.failure();
|
||||
}
|
||||
|
||||
// Check if we've exceeded the maximum retry count
|
||||
if (retryCount >= MAX_RETRIES) {
|
||||
Log.e(TAG, "Maximum retry count reached for SMS status update");
|
||||
return Result.failure();
|
||||
}
|
||||
|
||||
SMSDTO smsDTO = new Gson().fromJson(smsDtoJson, SMSDTO.class);
|
||||
|
||||
try {
|
||||
Call<SMSForwardResponseDTO> call = ApiManager.getApiService().updateSMSStatus(deviceId, apiKey, smsDTO);
|
||||
Response<SMSForwardResponseDTO> response = call.execute();
|
||||
|
||||
if (response.isSuccessful()) {
|
||||
Log.d(TAG, "SMS status updated successfully - ID: " + smsDTO.getSmsId() + ", Status: " + smsDTO.getStatus());
|
||||
return Result.success();
|
||||
} else {
|
||||
Log.e(TAG, "Failed to update SMS status. Response code: " + response.code());
|
||||
return Result.retry();
|
||||
}
|
||||
} catch (IOException e) {
|
||||
Log.e(TAG, "API call failed: " + e.getMessage());
|
||||
return Result.retry();
|
||||
}
|
||||
}
|
||||
|
||||
public static void enqueueWork(Context context, String deviceId, String apiKey, SMSDTO smsDTO) {
|
||||
Data inputData = new Data.Builder()
|
||||
.putString(KEY_DEVICE_ID, deviceId)
|
||||
.putString(KEY_API_KEY, apiKey)
|
||||
.putString(KEY_SMS_DTO, new Gson().toJson(smsDTO))
|
||||
.putInt(KEY_RETRY_COUNT, 0)
|
||||
.build();
|
||||
|
||||
Constraints constraints = new Constraints.Builder()
|
||||
.setRequiredNetworkType(NetworkType.CONNECTED)
|
||||
.build();
|
||||
|
||||
OneTimeWorkRequest workRequest = new OneTimeWorkRequest.Builder(SMSStatusUpdateWorker.class)
|
||||
.setConstraints(constraints)
|
||||
.setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 10, TimeUnit.SECONDS)
|
||||
.setInputData(inputData)
|
||||
.build();
|
||||
|
||||
String uniqueWorkName = "sms_status_" + smsDTO.getStatus() + "_" + System.currentTimeMillis();
|
||||
WorkManager.getInstance(context)
|
||||
.beginUniqueWork(uniqueWorkName,
|
||||
androidx.work.ExistingWorkPolicy.REPLACE,
|
||||
workRequest)
|
||||
.enqueue();
|
||||
|
||||
Log.d(TAG, "Work enqueued for SMS status update - ID: " + smsDTO.getSmsId());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
package com.vernu.sms.workers
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import androidx.work.*
|
||||
import com.google.gson.Gson
|
||||
import com.vernu.sms.ApiManager
|
||||
import com.vernu.sms.dtos.SMSDTO
|
||||
import com.vernu.sms.dtos.SMSForwardResponseDTO
|
||||
import java.io.IOException
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
class SMSStatusUpdateWorker(context: Context, workerParams: WorkerParameters) : Worker(context, workerParams) {
|
||||
companion object {
|
||||
private const val TAG = "SMSStatusUpdateWorker"
|
||||
private const val MAX_RETRIES = 5
|
||||
|
||||
const val KEY_DEVICE_ID = "device_id"
|
||||
const val KEY_API_KEY = "api_key"
|
||||
const val KEY_SMS_DTO = "sms_dto"
|
||||
const val KEY_RETRY_COUNT = "retry_count"
|
||||
|
||||
fun enqueueWork(context: Context, deviceId: String, apiKey: String, smsDTO: SMSDTO) {
|
||||
val inputData = Data.Builder()
|
||||
.putString(KEY_DEVICE_ID, deviceId)
|
||||
.putString(KEY_API_KEY, apiKey)
|
||||
.putString(KEY_SMS_DTO, Gson().toJson(smsDTO))
|
||||
.putInt(KEY_RETRY_COUNT, 0)
|
||||
.build()
|
||||
|
||||
val constraints = Constraints.Builder()
|
||||
.setRequiredNetworkType(NetworkType.CONNECTED)
|
||||
.build()
|
||||
|
||||
val workRequest = OneTimeWorkRequest.Builder(SMSStatusUpdateWorker::class.java)
|
||||
.setConstraints(constraints)
|
||||
.setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 10, TimeUnit.SECONDS)
|
||||
.setInputData(inputData)
|
||||
.build()
|
||||
|
||||
val uniqueWorkName = "sms_status_${smsDTO.status}_${System.currentTimeMillis()}"
|
||||
WorkManager.getInstance(context)
|
||||
.beginUniqueWork(uniqueWorkName, ExistingWorkPolicy.REPLACE, workRequest)
|
||||
.enqueue()
|
||||
|
||||
Log.d(TAG, "Work enqueued for SMS status update - ID: ${smsDTO.smsId}")
|
||||
}
|
||||
}
|
||||
|
||||
override fun doWork(): Result {
|
||||
val deviceId = inputData.getString(KEY_DEVICE_ID)
|
||||
val apiKey = inputData.getString(KEY_API_KEY)
|
||||
val smsDtoJson = inputData.getString(KEY_SMS_DTO)
|
||||
val retryCount = inputData.getInt(KEY_RETRY_COUNT, 0)
|
||||
|
||||
if (deviceId == null || apiKey == null || smsDtoJson == null) {
|
||||
Log.e(TAG, "Missing required parameters")
|
||||
return Result.failure()
|
||||
}
|
||||
|
||||
if (retryCount >= MAX_RETRIES) {
|
||||
Log.e(TAG, "Maximum retry count reached for SMS status update")
|
||||
return Result.failure()
|
||||
}
|
||||
|
||||
val smsDTO = Gson().fromJson(smsDtoJson, SMSDTO::class.java)
|
||||
|
||||
return try {
|
||||
val response = ApiManager.getApiService().updateSMSStatus(deviceId, apiKey, smsDTO).execute()
|
||||
if (response.isSuccessful) {
|
||||
Log.d(TAG, "SMS status updated successfully - ID: ${smsDTO.smsId}, Status: ${smsDTO.status}")
|
||||
Result.success()
|
||||
} else {
|
||||
Log.e(TAG, "Failed to update SMS status. Response code: ${response.code()}")
|
||||
Result.retry()
|
||||
}
|
||||
} catch (e: IOException) {
|
||||
Log.e(TAG, "API call failed: ${e.message}")
|
||||
Result.retry()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
package com.vernu.sms.workers
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import androidx.work.*
|
||||
import com.vernu.sms.AppConstants
|
||||
import com.vernu.sms.TextBeeUtils
|
||||
import com.vernu.sms.helpers.SMSHelper
|
||||
import com.vernu.sms.helpers.SharedPreferenceHelper
|
||||
|
||||
class SmsSendWorker(context: Context, workerParams: WorkerParameters) : Worker(context, workerParams) {
|
||||
companion object {
|
||||
private const val TAG = "SmsSendWorker"
|
||||
private const val QUEUE_NAME = "sms_send_queue"
|
||||
|
||||
const val KEY_PHONE = "phone"
|
||||
const val KEY_MESSAGE = "message"
|
||||
const val KEY_SMS_ID = "sms_id"
|
||||
const val KEY_SMS_BATCH_ID = "sms_batch_id"
|
||||
const val KEY_SIM_SUBSCRIPTION_ID = "sim_subscription_id"
|
||||
|
||||
fun enqueue(
|
||||
context: Context, phone: String, message: String,
|
||||
smsId: String?, smsBatchId: String?, simSubscriptionId: Int?
|
||||
) {
|
||||
val inputData = Data.Builder()
|
||||
.putString(KEY_PHONE, phone)
|
||||
.putString(KEY_MESSAGE, message)
|
||||
.putString(KEY_SMS_ID, smsId)
|
||||
.putString(KEY_SMS_BATCH_ID, smsBatchId)
|
||||
.putInt(KEY_SIM_SUBSCRIPTION_ID, simSubscriptionId ?: -1)
|
||||
.build()
|
||||
|
||||
val workRequest = OneTimeWorkRequest.Builder(SmsSendWorker::class.java)
|
||||
.setInputData(inputData)
|
||||
.build()
|
||||
|
||||
WorkManager.getInstance(context)
|
||||
.beginUniqueWork(QUEUE_NAME, ExistingWorkPolicy.APPEND_OR_REPLACE, workRequest)
|
||||
.enqueue()
|
||||
|
||||
Log.d(TAG, "SMS enqueued for sending - ID: $smsId, Phone: $phone")
|
||||
}
|
||||
}
|
||||
|
||||
override fun doWork(): Result {
|
||||
val phone = inputData.getString(KEY_PHONE)
|
||||
val message = inputData.getString(KEY_MESSAGE)
|
||||
val smsId = inputData.getString(KEY_SMS_ID)
|
||||
val smsBatchId = inputData.getString(KEY_SMS_BATCH_ID)
|
||||
val simSubscriptionId = inputData.getInt(KEY_SIM_SUBSCRIPTION_ID, -1)
|
||||
|
||||
if (phone == null || message == null || smsId == null) {
|
||||
Log.e(TAG, "Missing required parameters")
|
||||
return Result.failure()
|
||||
}
|
||||
|
||||
val context = applicationContext
|
||||
val resolvedSim = resolveSim(context, simSubscriptionId)
|
||||
|
||||
if (resolvedSim != null) {
|
||||
SMSHelper.sendSMSFromSpecificSim(phone, message, resolvedSim, smsId, smsBatchId ?: "", context)
|
||||
} else {
|
||||
SMSHelper.sendSMS(phone, message, smsId, smsBatchId ?: "", context)
|
||||
}
|
||||
|
||||
val delaySeconds = SharedPreferenceHelper.getSharedPreferenceInt(
|
||||
context, AppConstants.SHARED_PREFS_SMS_SEND_DELAY_SECONDS_KEY,
|
||||
AppConstants.DEFAULT_SMS_SEND_DELAY_SECONDS
|
||||
).coerceIn(0, 3600)
|
||||
|
||||
if (delaySeconds > 0) {
|
||||
try {
|
||||
Thread.sleep(delaySeconds * 1000L)
|
||||
} catch (e: InterruptedException) {
|
||||
Thread.currentThread().interrupt()
|
||||
}
|
||||
}
|
||||
|
||||
return Result.success()
|
||||
}
|
||||
|
||||
private fun resolveSim(context: Context, backendSimId: Int): Int? {
|
||||
if (backendSimId != -1 && TextBeeUtils.isValidSubscriptionId(context, backendSimId)) {
|
||||
Log.d(TAG, "Using backend-provided SIM subscription ID: $backendSimId")
|
||||
return backendSimId
|
||||
}
|
||||
|
||||
val preferredSim = SharedPreferenceHelper.getSharedPreferenceInt(
|
||||
context, AppConstants.SHARED_PREFS_PREFERRED_SIM_KEY, -1
|
||||
)
|
||||
if (preferredSim != -1 && TextBeeUtils.isValidSubscriptionId(context, preferredSim)) {
|
||||
Log.d(TAG, "Using app-preferred SIM subscription ID: $preferredSim")
|
||||
return preferredSim
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<selector xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<item android:color="?attr/colorPrimary" android:state_enabled="true"/>
|
||||
<item android:color="?attr/colorPrimary" android:alpha="0.12"/>
|
||||
</selector>
|
||||
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<selector xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<item android:color="@color/white" android:state_enabled="true"/>
|
||||
<item android:color="@color/white" android:alpha="0.38"/>
|
||||
</selector>
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 6.4 KiB |
@@ -20,26 +20,52 @@
|
||||
android:layout_height="wrap_content"
|
||||
android:background="?attr/colorPrimary"
|
||||
android:orientation="vertical"
|
||||
android:padding="24dp">
|
||||
android:padding="28dp">
|
||||
|
||||
<TextView
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="textbee.dev - sms gateway"
|
||||
android:textAlignment="center"
|
||||
android:textColor="@color/white"
|
||||
android:textSize="22sp"
|
||||
android:textStyle="bold" />
|
||||
android:orientation="horizontal"
|
||||
android:gravity="center_vertical"
|
||||
android:layout_gravity="center_horizontal"
|
||||
android:layout_marginBottom="16dp">
|
||||
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="8dp"
|
||||
android:layout_marginBottom="8dp"
|
||||
android:text="Your ultimate solution for seamless SMS communication"
|
||||
android:textAlignment="center"
|
||||
android:textColor="@color/white"
|
||||
android:textSize="12sp" />
|
||||
<ImageView
|
||||
android:layout_width="48dp"
|
||||
android:layout_height="48dp"
|
||||
android:layout_marginEnd="12dp"
|
||||
android:src="@mipmap/ic_launcher"
|
||||
android:contentDescription="App Icon" />
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical">
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="textbee.dev - sms gateway"
|
||||
android:textColor="@color/white"
|
||||
android:textSize="24sp"
|
||||
android:textStyle="bold"
|
||||
android:letterSpacing="0.02"
|
||||
android:shadowColor="#80000000"
|
||||
android:shadowDx="0"
|
||||
android:shadowDy="2"
|
||||
android:shadowRadius="4" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="4dp"
|
||||
android:text="Streamline your SMS workflows"
|
||||
android:textColor="@color/white"
|
||||
android:textSize="13sp"
|
||||
android:letterSpacing="0.01"
|
||||
android:alpha="0.95" />
|
||||
</LinearLayout>
|
||||
</LinearLayout>
|
||||
</LinearLayout>
|
||||
|
||||
<!-- Device Info Card -->
|
||||
@@ -50,21 +76,21 @@
|
||||
android:layout_marginTop="-24dp"
|
||||
android:layout_marginBottom="16dp"
|
||||
app:cardBackgroundColor="@color/background_secondary"
|
||||
app:cardCornerRadius="8dp"
|
||||
app:cardElevation="4dp">
|
||||
app:cardCornerRadius="12dp"
|
||||
app:cardElevation="6dp">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:gravity="center"
|
||||
android:orientation="horizontal"
|
||||
android:padding="6dp">
|
||||
android:padding="12dp">
|
||||
|
||||
<ImageView
|
||||
android:layout_width="48dp"
|
||||
android:layout_height="48dp"
|
||||
android:layout_width="52dp"
|
||||
android:layout_height="52dp"
|
||||
android:layout_gravity="center"
|
||||
android:padding="3dp"
|
||||
android:padding="6dp"
|
||||
android:src="@drawable/ic_baseline_phone_android_24"
|
||||
android:tint="?attr/colorPrimary" />
|
||||
|
||||
@@ -72,7 +98,7 @@
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_gravity="center"
|
||||
android:layout_marginStart="3dp"
|
||||
android:layout_marginStart="8dp"
|
||||
android:layout_weight="1"
|
||||
android:orientation="vertical">
|
||||
|
||||
@@ -109,11 +135,11 @@
|
||||
|
||||
<ImageButton
|
||||
android:id="@+id/copyDeviceIdImgBtn"
|
||||
android:layout_width="24dp"
|
||||
android:layout_height="24dp"
|
||||
android:layout_marginStart="2dp"
|
||||
android:layout_width="26dp"
|
||||
android:layout_height="26dp"
|
||||
android:layout_marginStart="4dp"
|
||||
android:background="?attr/selectableItemBackgroundBorderless"
|
||||
android:padding="2dp"
|
||||
android:padding="4dp"
|
||||
android:src="@drawable/ic_baseline_content_copy_24"
|
||||
android:tint="?attr/colorPrimary" />
|
||||
</LinearLayout>
|
||||
@@ -163,23 +189,24 @@
|
||||
android:layout_marginHorizontal="16dp"
|
||||
android:layout_marginBottom="16dp"
|
||||
app:cardBackgroundColor="@color/background_secondary"
|
||||
app:cardCornerRadius="8dp"
|
||||
app:cardElevation="2dp">
|
||||
app:cardCornerRadius="12dp"
|
||||
app:cardElevation="4dp">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:padding="16dp">
|
||||
android:padding="20dp">
|
||||
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginBottom="16dp"
|
||||
android:layout_marginBottom="18dp"
|
||||
android:text="Account Information"
|
||||
android:textColor="@color/text_primary"
|
||||
android:textSize="18sp"
|
||||
android:textStyle="bold" />
|
||||
android:textSize="19sp"
|
||||
android:textStyle="bold"
|
||||
android:letterSpacing="0.01" />
|
||||
|
||||
<!-- Device ID Input Field -->
|
||||
<com.google.android.material.textfield.TextInputLayout
|
||||
@@ -212,12 +239,33 @@
|
||||
android:hint="API Key"
|
||||
app:boxBackgroundColor="@android:color/transparent"
|
||||
app:boxStrokeColor="?attr/colorPrimary"
|
||||
app:hintTextColor="?attr/colorPrimary">
|
||||
app:hintTextColor="?attr/colorPrimary"
|
||||
app:endIconMode="password_toggle"
|
||||
app:endIconTint="?attr/colorPrimary">
|
||||
|
||||
<com.google.android.material.textfield.TextInputEditText
|
||||
android:id="@+id/apiKeyEditText"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:inputType="textPassword"
|
||||
android:textColor="@color/text_primary"
|
||||
android:textIsSelectable="true" />
|
||||
</com.google.android.material.textfield.TextInputLayout>
|
||||
|
||||
<com.google.android.material.textfield.TextInputLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginBottom="16dp"
|
||||
android:hint="Device name (optional)"
|
||||
app:boxBackgroundColor="@android:color/transparent"
|
||||
app:boxStrokeColor="?attr/colorPrimary"
|
||||
app:hintTextColor="?attr/colorPrimary"
|
||||
app:endIconTint="?attr/colorPrimary">
|
||||
|
||||
<com.google.android.material.textfield.TextInputEditText
|
||||
android:id="@+id/deviceNameEditText"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:inputType="text"
|
||||
android:textColor="@color/text_primary"
|
||||
android:textIsSelectable="true" />
|
||||
@@ -246,10 +294,15 @@
|
||||
android:id="@+id/registerDeviceBtn"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:backgroundTint="?attr/colorPrimary"
|
||||
android:paddingHorizontal="24dp"
|
||||
android:backgroundTint="@color/button_background_tint"
|
||||
android:paddingHorizontal="28dp"
|
||||
android:paddingVertical="12dp"
|
||||
android:text="Connect"
|
||||
android:textColor="@color/white" />
|
||||
android:textColor="@color/button_text_color"
|
||||
android:textSize="15sp"
|
||||
android:letterSpacing="0.01"
|
||||
style="@style/Widget.MaterialComponents.Button"
|
||||
app:cornerRadius="8dp" />
|
||||
|
||||
<View
|
||||
android:layout_width="0dp"
|
||||
@@ -259,11 +312,20 @@
|
||||
android:id="@+id/scanQRButton"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_margin="5dp"
|
||||
android:layout_marginStart="8dp"
|
||||
android:drawableLeft="@drawable/ic_baseline_qr_code_24"
|
||||
android:drawablePadding="6dp"
|
||||
android:paddingHorizontal="20dp"
|
||||
android:paddingVertical="12dp"
|
||||
android:text="Scan QR"
|
||||
android:textColor="@color/black"
|
||||
android:theme="@style/Theme.Design.Light" />
|
||||
android:textColor="?attr/colorPrimary"
|
||||
android:textSize="15sp"
|
||||
android:letterSpacing="0.01"
|
||||
android:backgroundTint="@android:color/transparent"
|
||||
style="@style/Widget.MaterialComponents.Button.OutlinedButton"
|
||||
app:cornerRadius="8dp"
|
||||
app:strokeColor="?attr/colorPrimary"
|
||||
app:strokeWidth="1.5dp" />
|
||||
|
||||
</LinearLayout>
|
||||
</LinearLayout>
|
||||
@@ -276,23 +338,24 @@
|
||||
android:layout_marginHorizontal="16dp"
|
||||
android:layout_marginBottom="16dp"
|
||||
app:cardBackgroundColor="@color/background_secondary"
|
||||
app:cardCornerRadius="8dp"
|
||||
app:cardElevation="2dp">
|
||||
app:cardCornerRadius="12dp"
|
||||
app:cardElevation="4dp">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:padding="16dp">
|
||||
android:padding="20dp">
|
||||
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginBottom="16dp"
|
||||
android:layout_marginBottom="18dp"
|
||||
android:text="Configuration"
|
||||
android:textColor="@color/text_primary"
|
||||
android:textSize="18sp"
|
||||
android:textStyle="bold" />
|
||||
android:textSize="19sp"
|
||||
android:textStyle="bold"
|
||||
android:letterSpacing="0.01" />
|
||||
|
||||
<!-- Permissions Section -->
|
||||
<LinearLayout
|
||||
@@ -309,30 +372,28 @@
|
||||
android:textSize="16sp"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="REQUIRED for textbee to function!"
|
||||
android:textColor="@android:color/holo_red_dark"
|
||||
android:textSize="14sp"
|
||||
android:textStyle="bold"
|
||||
android:layout_marginBottom="8dp" />
|
||||
|
||||
<Button
|
||||
android:id="@+id/grantSMSPermissionBtn"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:backgroundTint="?attr/colorPrimary"
|
||||
android:backgroundTint="@color/button_background_tint"
|
||||
android:paddingHorizontal="24dp"
|
||||
android:paddingVertical="12dp"
|
||||
android:text="Grant SMS Permissions"
|
||||
android:textColor="@color/white"
|
||||
android:visibility="visible" />
|
||||
android:textColor="@color/button_text_color"
|
||||
android:textSize="15sp"
|
||||
android:letterSpacing="0.01"
|
||||
android:visibility="visible"
|
||||
style="@style/Widget.MaterialComponents.Button"
|
||||
app:cornerRadius="8dp" />
|
||||
</LinearLayout>
|
||||
|
||||
<View
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="1dp"
|
||||
android:layout_height="1.5dp"
|
||||
android:background="@color/divider"
|
||||
android:layout_marginBottom="16dp" />
|
||||
android:alpha="0.6"
|
||||
android:layout_marginBottom="18dp" />
|
||||
|
||||
<!-- Receive SMS Toggle -->
|
||||
<LinearLayout
|
||||
@@ -371,6 +432,22 @@
|
||||
android:minHeight="32dp" />
|
||||
</LinearLayout>
|
||||
|
||||
<!-- Filter Configuration Link -->
|
||||
<Button
|
||||
android:id="@+id/configureFilterBtn"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginBottom="16dp"
|
||||
android:text="Configure Filter"
|
||||
android:textColor="?attr/colorPrimary"
|
||||
android:textSize="15sp"
|
||||
android:letterSpacing="0.01"
|
||||
android:backgroundTint="@android:color/transparent"
|
||||
android:drawableStart="@android:drawable/ic_menu_manage"
|
||||
android:drawablePadding="6dp"
|
||||
android:paddingHorizontal="0dp"
|
||||
style="@style/Widget.MaterialComponents.Button.TextButton" />
|
||||
|
||||
<!-- Sticky Notification Setting -->
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
@@ -400,11 +477,11 @@
|
||||
android:gravity="center_vertical">
|
||||
|
||||
<ImageView
|
||||
android:layout_width="16dp"
|
||||
android:layout_height="16dp"
|
||||
android:layout_width="18dp"
|
||||
android:layout_height="18dp"
|
||||
android:src="@android:drawable/ic_dialog_info"
|
||||
android:tint="?attr/colorPrimary"
|
||||
android:layout_marginEnd="4dp" />
|
||||
android:layout_marginEnd="6dp" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
@@ -424,9 +501,61 @@
|
||||
|
||||
<View
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="1dp"
|
||||
android:layout_height="1.5dp"
|
||||
android:background="@color/divider"
|
||||
android:layout_marginBottom="16dp" />
|
||||
android:alpha="0.6"
|
||||
android:layout_marginBottom="18dp" />
|
||||
|
||||
<!-- SMS Send Delay Setting -->
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:layout_marginBottom="16dp">
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="SMS Send Delay"
|
||||
android:textColor="@color/text_primary"
|
||||
android:textSize="16sp"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="4dp"
|
||||
android:layout_marginBottom="8dp"
|
||||
android:text="Delay between each SMS in seconds (0 = no delay, max 3600)"
|
||||
android:textColor="@color/text_secondary"
|
||||
android:textSize="14sp"
|
||||
android:lineSpacingMultiplier="1.2" />
|
||||
|
||||
<com.google.android.material.textfield.TextInputLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:hint="Delay (seconds)"
|
||||
app:boxBackgroundColor="@android:color/transparent"
|
||||
app:boxStrokeColor="?attr/colorPrimary"
|
||||
app:hintTextColor="?attr/colorPrimary"
|
||||
app:suffixText="sec"
|
||||
app:suffixTextColor="@color/text_secondary">
|
||||
|
||||
<com.google.android.material.textfield.TextInputEditText
|
||||
android:id="@+id/smsSendDelayEditText"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:inputType="number"
|
||||
android:textColor="@color/text_primary" />
|
||||
</com.google.android.material.textfield.TextInputLayout>
|
||||
</LinearLayout>
|
||||
|
||||
<View
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="1.5dp"
|
||||
android:background="@color/divider"
|
||||
android:alpha="0.6"
|
||||
android:layout_marginBottom="18dp" />
|
||||
|
||||
<!-- Default SIM Selection -->
|
||||
<TextView
|
||||
@@ -440,10 +569,11 @@
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginBottom="8dp"
|
||||
android:text="Select your preferred SIM for sending SMS"
|
||||
android:layout_marginBottom="12dp"
|
||||
android:text="This is the default SIM that will be used for sending SMS. You can override for individual SMS by providing the specific simSubscriptionId in your API request."
|
||||
android:textColor="@color/text_secondary"
|
||||
android:textSize="14sp" />
|
||||
android:textSize="14sp"
|
||||
android:lineSpacingMultiplier="1.2" />
|
||||
|
||||
<RadioGroup
|
||||
android:id="@+id/defaultSimSlotRadioGroup"
|
||||
@@ -462,38 +592,41 @@
|
||||
android:layout_marginHorizontal="16dp"
|
||||
android:layout_marginBottom="16dp"
|
||||
app:cardBackgroundColor="@color/background_secondary"
|
||||
app:cardCornerRadius="8dp"
|
||||
app:cardElevation="2dp">
|
||||
app:cardCornerRadius="12dp"
|
||||
app:cardElevation="4dp">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:padding="16dp">
|
||||
android:padding="20dp">
|
||||
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginBottom="8dp"
|
||||
android:layout_marginBottom="12dp"
|
||||
android:text="How To Use"
|
||||
android:textColor="@color/text_primary"
|
||||
android:textSize="18sp"
|
||||
android:textStyle="bold" />
|
||||
android:textSize="19sp"
|
||||
android:textStyle="bold"
|
||||
android:letterSpacing="0.01" />
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
android:layout_marginBottom="8dp">
|
||||
android:layout_marginBottom="10dp"
|
||||
android:gravity="center_vertical">
|
||||
|
||||
<TextView
|
||||
android:layout_width="24dp"
|
||||
android:layout_height="24dp"
|
||||
android:layout_marginEnd="8dp"
|
||||
android:layout_width="28dp"
|
||||
android:layout_height="28dp"
|
||||
android:layout_marginEnd="12dp"
|
||||
android:background="?attr/colorPrimary"
|
||||
android:gravity="center"
|
||||
android:text="1"
|
||||
android:textColor="@color/white"
|
||||
android:textSize="14sp"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<TextView
|
||||
@@ -512,16 +645,18 @@
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
android:layout_marginBottom="8dp">
|
||||
android:layout_marginBottom="10dp"
|
||||
android:gravity="center_vertical">
|
||||
|
||||
<TextView
|
||||
android:layout_width="24dp"
|
||||
android:layout_height="24dp"
|
||||
android:layout_marginEnd="8dp"
|
||||
android:layout_width="28dp"
|
||||
android:layout_height="28dp"
|
||||
android:layout_marginEnd="12dp"
|
||||
android:background="?attr/colorPrimary"
|
||||
android:gravity="center"
|
||||
android:text="2"
|
||||
android:textColor="@color/white"
|
||||
android:textSize="14sp"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<TextView
|
||||
@@ -534,16 +669,18 @@
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal">
|
||||
android:orientation="horizontal"
|
||||
android:gravity="center_vertical">
|
||||
|
||||
<TextView
|
||||
android:layout_width="24dp"
|
||||
android:layout_height="24dp"
|
||||
android:layout_marginEnd="8dp"
|
||||
android:layout_width="28dp"
|
||||
android:layout_height="28dp"
|
||||
android:layout_marginEnd="12dp"
|
||||
android:background="?attr/colorPrimary"
|
||||
android:gravity="center"
|
||||
android:text="3"
|
||||
android:textColor="@color/white"
|
||||
android:textSize="14sp"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<TextView
|
||||
@@ -562,14 +699,14 @@
|
||||
android:layout_marginHorizontal="16dp"
|
||||
android:layout_marginBottom="24dp"
|
||||
app:cardBackgroundColor="@color/background_secondary"
|
||||
app:cardCornerRadius="8dp"
|
||||
app:cardElevation="2dp">
|
||||
app:cardCornerRadius="12dp"
|
||||
app:cardElevation="4dp">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:padding="16dp">
|
||||
android:padding="20dp">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
@@ -578,11 +715,12 @@
|
||||
android:gravity="center_horizontal">
|
||||
|
||||
<ImageView
|
||||
android:layout_width="32dp"
|
||||
android:layout_height="32dp"
|
||||
android:layout_width="36dp"
|
||||
android:layout_height="36dp"
|
||||
android:src="@drawable/ic_baseline_info_24"
|
||||
android:tint="?attr/colorPrimary"
|
||||
android:layout_marginEnd="12dp" />
|
||||
android:padding="4dp"
|
||||
android:layout_marginEnd="14dp" />
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
@@ -654,6 +792,18 @@
|
||||
android:textAllCaps="false"
|
||||
android:paddingHorizontal="0dp"
|
||||
style="@style/Widget.AppCompat.Button.Borderless" />
|
||||
|
||||
<Button
|
||||
android:id="@+id/tryNewUIBtn"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="2dp"
|
||||
android:text="✨ Try New UI"
|
||||
android:textColor="?attr/colorPrimary"
|
||||
android:background="@android:color/transparent"
|
||||
android:textAllCaps="false"
|
||||
android:paddingHorizontal="0dp"
|
||||
style="@style/Widget.AppCompat.Button.Borderless" />
|
||||
</LinearLayout>
|
||||
</LinearLayout>
|
||||
</LinearLayout>
|
||||
|
||||
@@ -0,0 +1,230 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<RelativeLayout 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"
|
||||
android:background="@color/background_primary"
|
||||
tools:context=".activities.SMSFilterActivity">
|
||||
|
||||
<!-- Header Section -->
|
||||
<LinearLayout
|
||||
android:id="@+id/header"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:background="?attr/colorPrimary"
|
||||
android:orientation="vertical"
|
||||
android:padding="28dp">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
android:gravity="center_vertical"
|
||||
android:layout_marginBottom="10dp">
|
||||
|
||||
<ImageButton
|
||||
android:id="@+id/backButton"
|
||||
android:layout_width="48dp"
|
||||
android:layout_height="48dp"
|
||||
android:background="?attr/selectableItemBackgroundBorderless"
|
||||
android:src="@android:drawable/ic_menu_revert"
|
||||
android:tint="@color/white"
|
||||
android:contentDescription="Back"
|
||||
android:padding="8dp" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:text="SMS Filter"
|
||||
android:textAlignment="center"
|
||||
android:textColor="@color/white"
|
||||
android:textSize="24sp"
|
||||
android:textStyle="bold"
|
||||
android:letterSpacing="0.02"
|
||||
android:shadowColor="#80000000"
|
||||
android:shadowDx="0"
|
||||
android:shadowDy="2"
|
||||
android:shadowRadius="4" />
|
||||
|
||||
<View
|
||||
android:layout_width="48dp"
|
||||
android:layout_height="48dp" />
|
||||
</LinearLayout>
|
||||
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Configure SMS filtering rules"
|
||||
android:textAlignment="center"
|
||||
android:textColor="@color/white"
|
||||
android:textSize="13sp"
|
||||
android:letterSpacing="0.01"
|
||||
android:alpha="0.95" />
|
||||
</LinearLayout>
|
||||
|
||||
<!-- Scrollable Content -->
|
||||
<ScrollView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:layout_below="@id/header">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical">
|
||||
|
||||
<!-- Filter Settings Card -->
|
||||
<androidx.cardview.widget.CardView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginHorizontal="16dp"
|
||||
android:layout_marginTop="16dp"
|
||||
android:layout_marginBottom="16dp"
|
||||
app:cardBackgroundColor="@color/background_secondary"
|
||||
app:cardCornerRadius="12dp"
|
||||
app:cardElevation="4dp">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:padding="20dp">
|
||||
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginBottom="18dp"
|
||||
android:text="Filter Settings"
|
||||
android:textColor="@color/text_primary"
|
||||
android:textSize="19sp"
|
||||
android:textStyle="bold"
|
||||
android:letterSpacing="0.01" />
|
||||
|
||||
<!-- Filter Enabled Toggle -->
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
android:gravity="center_vertical"
|
||||
android:layout_marginBottom="16dp">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:orientation="vertical">
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Enable Filter"
|
||||
android:textColor="@color/text_primary"
|
||||
android:textSize="16sp"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Enable SMS filtering"
|
||||
android:textColor="@color/text_secondary"
|
||||
android:textSize="14sp" />
|
||||
</LinearLayout>
|
||||
|
||||
<Switch
|
||||
android:id="@+id/filterEnabledSwitch"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:minHeight="32dp" />
|
||||
</LinearLayout>
|
||||
|
||||
<View
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="1.5dp"
|
||||
android:background="@color/divider"
|
||||
android:alpha="0.6"
|
||||
android:layout_marginBottom="18dp" />
|
||||
|
||||
<!-- Filter Mode Selection -->
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginBottom="12dp"
|
||||
android:text="Filter Mode"
|
||||
android:textColor="@color/text_primary"
|
||||
android:textSize="16sp"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<RadioGroup
|
||||
android:id="@+id/filterModeRadioGroup"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:layout_marginStart="8dp">
|
||||
|
||||
<RadioButton
|
||||
android:id="@+id/allowListRadio"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Allow List (only process SMS matching these filters)"
|
||||
android:textColor="@color/text_primary"
|
||||
android:paddingVertical="8dp" />
|
||||
|
||||
<RadioButton
|
||||
android:id="@+id/blockListRadio"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Block List (exclude these numbers/messages)"
|
||||
android:textColor="@color/text_primary"
|
||||
android:paddingVertical="8dp" />
|
||||
</RadioGroup>
|
||||
</LinearLayout>
|
||||
</androidx.cardview.widget.CardView>
|
||||
|
||||
<!-- Filter Rules Card -->
|
||||
<androidx.cardview.widget.CardView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginHorizontal="16dp"
|
||||
android:layout_marginBottom="16dp"
|
||||
app:cardBackgroundColor="@color/background_secondary"
|
||||
app:cardCornerRadius="12dp"
|
||||
app:cardElevation="4dp">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:padding="20dp">
|
||||
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginBottom="18dp"
|
||||
android:text="Filter Rules"
|
||||
android:textColor="@color/text_primary"
|
||||
android:textSize="19sp"
|
||||
android:textStyle="bold"
|
||||
android:letterSpacing="0.01" />
|
||||
|
||||
<androidx.recyclerview.widget.RecyclerView
|
||||
android:id="@+id/filterRulesRecyclerView"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:nestedScrollingEnabled="false" />
|
||||
|
||||
<com.google.android.material.floatingactionbutton.FloatingActionButton
|
||||
android:id="@+id/addRuleFab"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_gravity="end"
|
||||
android:layout_marginTop="16dp"
|
||||
android:src="@android:drawable/ic_input_add"
|
||||
app:tint="@color/white"
|
||||
app:backgroundTint="?attr/colorPrimary" />
|
||||
</LinearLayout>
|
||||
</androidx.cardview.widget.CardView>
|
||||
</LinearLayout>
|
||||
</ScrollView>
|
||||
</RelativeLayout>
|
||||
@@ -0,0 +1,132 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:padding="24dp">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/dialogTitle"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginBottom="20dp"
|
||||
android:text="Add Filter Rule"
|
||||
android:textColor="@color/text_primary"
|
||||
android:textSize="20sp"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<com.google.android.material.textfield.TextInputLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginBottom="16dp"
|
||||
android:hint="Pattern (phone number or text)"
|
||||
app:boxBackgroundColor="@android:color/transparent"
|
||||
app:boxStrokeColor="?attr/colorPrimary"
|
||||
app:hintTextColor="?attr/colorPrimary">
|
||||
|
||||
<com.google.android.material.textfield.TextInputEditText
|
||||
android:id="@+id/patternEditText"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:inputType="text"
|
||||
android:textColor="@color/text_primary"
|
||||
android:maxLines="1" />
|
||||
</com.google.android.material.textfield.TextInputLayout>
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginBottom="8dp"
|
||||
android:text="Filter Target"
|
||||
android:textColor="@color/text_primary"
|
||||
android:textSize="16sp"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<Spinner
|
||||
android:id="@+id/filterTargetSpinner"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginBottom="16dp"
|
||||
android:minHeight="48dp" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginBottom="8dp"
|
||||
android:text="Match Type"
|
||||
android:textColor="@color/text_primary"
|
||||
android:textSize="16sp"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<Spinner
|
||||
android:id="@+id/matchTypeSpinner"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginBottom="16dp"
|
||||
android:minHeight="48dp" />
|
||||
|
||||
<!-- Case Sensitivity Toggle -->
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
android:gravity="center_vertical"
|
||||
android:layout_marginBottom="24dp">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:orientation="vertical">
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Case Sensitive"
|
||||
android:textColor="@color/text_primary"
|
||||
android:textSize="16sp"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Match pattern with exact case"
|
||||
android:textColor="@color/text_secondary"
|
||||
android:textSize="14sp" />
|
||||
</LinearLayout>
|
||||
|
||||
<Switch
|
||||
android:id="@+id/caseSensitiveSwitch"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:minHeight="32dp" />
|
||||
</LinearLayout>
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
android:gravity="end">
|
||||
|
||||
<Button
|
||||
android:id="@+id/cancelButton"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginEnd="8dp"
|
||||
android:text="Cancel"
|
||||
android:textColor="?attr/colorPrimary"
|
||||
android:backgroundTint="@android:color/transparent"
|
||||
style="@style/Widget.MaterialComponents.Button.TextButton" />
|
||||
|
||||
<Button
|
||||
android:id="@+id/saveButton"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Save"
|
||||
android:textColor="@color/button_text_color"
|
||||
android:backgroundTint="@color/button_background_tint"
|
||||
style="@style/Widget.MaterialComponents.Button"
|
||||
app:cornerRadius="8dp" />
|
||||
</LinearLayout>
|
||||
</LinearLayout>
|
||||
@@ -0,0 +1,71 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<androidx.cardview.widget.CardView xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginBottom="8dp"
|
||||
app:cardBackgroundColor="@color/background_primary"
|
||||
app:cardCornerRadius="8dp"
|
||||
app:cardElevation="2dp">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
android:gravity="center_vertical"
|
||||
android:padding="16dp">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:orientation="vertical">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/patternText"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Pattern"
|
||||
android:textColor="@color/text_primary"
|
||||
android:textSize="16sp"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/matchTypeText"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="4dp"
|
||||
android:text="Match Type"
|
||||
android:textColor="@color/text_secondary"
|
||||
android:textSize="14sp" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/filterTargetText"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="2dp"
|
||||
android:text="Filter Target"
|
||||
android:textColor="@color/text_secondary"
|
||||
android:textSize="12sp" />
|
||||
</LinearLayout>
|
||||
|
||||
<ImageButton
|
||||
android:id="@+id/editButton"
|
||||
android:layout_width="40dp"
|
||||
android:layout_height="40dp"
|
||||
android:layout_marginEnd="8dp"
|
||||
android:background="?attr/selectableItemBackgroundBorderless"
|
||||
android:src="@android:drawable/ic_menu_edit"
|
||||
android:tint="?attr/colorPrimary"
|
||||
android:contentDescription="Edit rule" />
|
||||
|
||||
<ImageButton
|
||||
android:id="@+id/deleteButton"
|
||||
android:layout_width="40dp"
|
||||
android:layout_height="40dp"
|
||||
android:background="?attr/selectableItemBackgroundBorderless"
|
||||
android:src="@android:drawable/ic_menu_delete"
|
||||
android:tint="#F44336"
|
||||
android:contentDescription="Delete rule" />
|
||||
</LinearLayout>
|
||||
</androidx.cardview.widget.CardView>
|
||||
@@ -2,12 +2,12 @@
|
||||
<!-- Base application theme. -->
|
||||
<style name="Theme.SMSGateway" parent="Theme.MaterialComponents.DayNight.DarkActionBar">
|
||||
<!-- Primary brand color. -->
|
||||
<item name="colorPrimary">#D97706</item>
|
||||
<item name="colorPrimaryVariant">#B86504</item>
|
||||
<item name="colorPrimary">#C4620A</item>
|
||||
<item name="colorPrimaryVariant">#A04405</item>
|
||||
<item name="colorOnPrimary">@color/white</item>
|
||||
<!-- Secondary brand color. -->
|
||||
<item name="colorSecondary">#f35b04</item>
|
||||
<item name="colorSecondaryVariant">#f18701</item>
|
||||
<item name="colorSecondary">#B45309</item>
|
||||
<item name="colorSecondaryVariant">#92400E</item>
|
||||
<item name="colorOnSecondary">@color/black</item>
|
||||
<!-- Status bar color. -->
|
||||
<item name="android:statusBarColor" tools:targetApi="l">?attr/colorPrimaryVariant</item>
|
||||
@@ -23,12 +23,12 @@
|
||||
<!-- Theme without action bar for MainActivity -->
|
||||
<style name="Theme.SMSGateway.NoActionBar" parent="Theme.MaterialComponents.DayNight.NoActionBar">
|
||||
<!-- Primary brand color. -->
|
||||
<item name="colorPrimary">#D97706</item>
|
||||
<item name="colorPrimaryVariant">#B86504</item>
|
||||
<item name="colorPrimary">#C4620A</item>
|
||||
<item name="colorPrimaryVariant">#A04405</item>
|
||||
<item name="colorOnPrimary">@color/white</item>
|
||||
<!-- Secondary brand color. -->
|
||||
<item name="colorSecondary">#f35b04</item>
|
||||
<item name="colorSecondaryVariant">#f18701</item>
|
||||
<item name="colorSecondary">#B45309</item>
|
||||
<item name="colorSecondaryVariant">#92400E</item>
|
||||
<item name="colorOnSecondary">@color/black</item>
|
||||
<!-- Status bar color. -->
|
||||
<item name="android:statusBarColor" tools:targetApi="l">?attr/colorPrimaryVariant</item>
|
||||
|
||||
@@ -2,12 +2,12 @@
|
||||
<!-- Base application theme. -->
|
||||
<style name="Theme.SMSGateway" parent="Theme.MaterialComponents.DayNight.DarkActionBar">
|
||||
<!-- Primary brand color. -->
|
||||
<item name="colorPrimary">#D97706</item>
|
||||
<item name="colorPrimaryVariant">#B86504</item>
|
||||
<item name="colorPrimary">#C4620A</item>
|
||||
<item name="colorPrimaryVariant">#A04405</item>
|
||||
<item name="colorOnPrimary">@color/white</item>
|
||||
<!-- Secondary brand color. -->
|
||||
<item name="colorSecondary">#f35b04</item>
|
||||
<item name="colorSecondaryVariant">#f18701</item>
|
||||
<item name="colorSecondary">#B45309</item>
|
||||
<item name="colorSecondaryVariant">#92400E</item>
|
||||
<item name="colorOnSecondary">@color/black</item>
|
||||
<!-- Status bar color. -->
|
||||
<item name="android:statusBarColor" tools:targetApi="l">?attr/colorPrimaryVariant</item>
|
||||
@@ -17,12 +17,12 @@
|
||||
<!-- Theme without action bar for MainActivity -->
|
||||
<style name="Theme.SMSGateway.NoActionBar" parent="Theme.MaterialComponents.DayNight.NoActionBar">
|
||||
<!-- Primary brand color. -->
|
||||
<item name="colorPrimary">#D97706</item>
|
||||
<item name="colorPrimaryVariant">#B86504</item>
|
||||
<item name="colorPrimary">#C4620A</item>
|
||||
<item name="colorPrimaryVariant">#A04405</item>
|
||||
<item name="colorOnPrimary">@color/white</item>
|
||||
<!-- Secondary brand color. -->
|
||||
<item name="colorSecondary">#f35b04</item>
|
||||
<item name="colorSecondaryVariant">#f18701</item>
|
||||
<item name="colorSecondary">#B45309</item>
|
||||
<item name="colorSecondaryVariant">#92400E</item>
|
||||
<item name="colorOnSecondary">@color/black</item>
|
||||
<!-- Status bar color. -->
|
||||
<item name="android:statusBarColor" tools:targetApi="l">?attr/colorPrimaryVariant</item>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user