Compare commits

...
326 Commits
Author SHA1 Message Date
vernu 4d60c10d9a Merge pull request #325 from textbee/chore/push-dispatch-config
Make push dispatch configurable per account and device
2026-08-22 18:10:42 +03:00
isra elandClaude Opus 5 15caae3b62 chore(api): make push dispatch configurable per account and device
Two optional env lists gate whether a message is handed to the push
service. Both are empty by default, so behavior is unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-22 17:56:35 +03:00
vernuandClaude Fable 5 cbf657442d Tighten the recipient and endpoint redaction helpers (#324)
* refactor(api): tighten recipient and endpoint redaction helpers

Only mask a single bare address, so a display name or a multi-recipient list
redacts whole instead of leaking a fragment. Drop the query marker from the
endpoint shown in the admin summary, leaving origin and path.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test(api): assert exact redacted recipient output

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-22 17:20:55 +03:00
vernuandClaude Fable 5 5d6cd93a25 Supply the shared email layout context from MailService (#323)
* refactor(api): supply shared email layout context from MailService

Move brandName and year into MailService so every templated email gets the
layout partial's context without each caller repeating it. Align the webhook
email callers with the template fields (subscription name and endpoint, summary
rows) and set a preheader on the admin summary. Mail send failures now go
through the Nest logger with the template name.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* refactor(api): redact recipients and endpoint URLs in mail paths

Log a masked recipient and the error message instead of the full address and
stack. Make the shared layout values win over caller context. Strip userinfo and
query strings from the endpoint shown in the admin summary, and cover both
helpers with tests.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-22 17:06:37 +03:00
vernu a9484f8c72 Merge pull request #322 from textbee/chore/readme-screenshot
Update README landing page screenshot
2026-08-22 16:43:54 +03:00
isra elandClaude Fable 5 7432d4f07b docs: update README landing page screenshot
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-22 16:41:25 +03:00
vernu defb02ee1b Rebuild the transactional emails on a shared layout (#320)
* feat(api): rebuild the transactional emails on a shared layout

All eight templates now share one layout, header, footer, and button. Six of
them were on an older markup generation that used bootstrap blue or Discord
blurple, centered with max-width on body, and carried no preheader, so they
rendered off-brand and unstyled in Outlook. verify-email also opened with a
stray empty html element that put the whole document outside it.

The mailer inlines CSS before sending, which strips media queries, so the
layout is fluid rather than depending on one.

Billing emails now carry the figures as their own block instead of a single
sentence, plus a per-type preheader, subject, and call to action. Copy leads
with the reader's situation and offers the option that costs nothing before
the paid one. The upgrade link points at the pricing page rather than an
anchor on the landing page, and the duplicated subject maps are now one map.

Adds scripts/preview-emails.ts, which renders every template and every
notification type the way the mailer does, including the inline-css pass, and
fails on structural problems. Without it these could only be reviewed by
sending mail, which is how the drift went unnoticed.

* fix(api): describe the monthly counter as the rolling window it is

Monthly usage counts from one month before now, not from the billing period
start, so telling a user their allowance resets at renewal was wrong in both
directions: they can stay blocked after renewing, or resume before it.

The emails, the in-app notifications, and the API rejection message now all
describe the rolling window instead.

Also declares inline-css, which the preview script imports directly and was
only present as a transitive dependency of the mailer.
2026-08-22 16:25:57 +03:00
vernu a5fbc29e4e Set bulk send expectations by time rather than recipient count (#319)
* feat(web): set bulk send expectations by time rather than recipient count

Recipient counts stop being a plan feature in the dashboard plan picker,
and the bulk send limit tile no longer reads "Unlimited": a phone sends one
message at a time, so a very large batch is delivered over hours.

The wizard now warns above a configurable recipient count and states how
long the send will actually take, computed from the selected device's own
send delay. The warning is advisory and never blocks a send.

Also fixes what the plan cap is measured against. It compared the raw CSV
row count, while only validated recipients are ever posted, so a file with
blank or duplicate rows could be rejected when the real send was within
the limit.

* fix(web): compare raw duration before rounding it for display

Rounding first put 59.9 seconds in the minutes branch and 89.9 minutes in
the hours branch, each below its stated boundary.
2026-08-22 16:25:49 +03:00
vernu 8f29a90804 chore(api): broaden the ignore rules for local config files (#321)
The rule matched one exact filename, so local variants were not covered.
Widens it to the whole pattern plus two other local-only config files.

Nothing here was ever tracked, so no history is affected.
2026-08-22 16:24:29 +03:00
vernu cba1196fb4 Merge pull request #318 from textbee/feat/mcp-readme
Add the MCP server section to the README
2026-08-22 08:00:15 +03:00
isra elandClaude Fable 5 a48cf4003e docs: add the MCP server section and the agents one-liner to the readme
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-22 07:23:14 +03:00
vernu 6101d05f44 Merge pull request #317 from textbee/feat/paced-bulk-dispatch
Pace bulk dispatch to the device send rate and bound push freshness
2026-08-22 00:52:40 +03:00
isra elandClaude Opus 5 36a4f87551 fix(api): roll back queued jobs when a later enqueue step fails
Bulk sends now persist every group's wave plan before any job is added,
and both send paths remove the jobs already added if a later wave or
group fails to enqueue, so the failure handling never leaves live jobs
behind records it marked failed. addSendSmsJob returns the added jobs
for that purpose. The queue processor also tolerates a batch document
that was deleted mid-flight.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-22 00:50:00 +03:00
isra elandClaude Opus 5 8a62bd76d0 fix(api): persist the dispatch plan before enqueueing and tighten pacing edges
Address review feedback on the paced dispatch change:

- Plan the waves first (planSendSmsJob), stamp dispatchDueAt, then enqueue,
  so a failed write leaves no live jobs behind.
- Group bulk entries by absolute scheduled time instead of per-message delay,
  so equal scheduledAt values share one paced plan.
- Clamp configured and schedule-extended push ttl to the FCM 28 day maximum.
- Keep an incomplete batch in processing regardless of failures, and reuse
  resolveBatchStatus in the job failure path.
- Log the paced duration the spread warning actually checks.
- Describe dispatchDueAt as set for any queued message that waits server-side.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-22 00:42:16 +03:00
isra elandClaude Opus 5 95149a28c2 feat(api): pace bulk dispatch to the device send rate and bound push freshness
Large sends are now released to the push service in waves of
BULK_DISPATCH_WINDOW messages spaced by the device's configured send
delay, so the backlog waits in the queue instead of piling up ahead of
the phone. Each message records when its wave is due (dispatchDueAt) and
the stale-status cron leaves paced messages alone until then. A paced
batch stays in processing until every wave has been handed off, and the
send response carries estimatedCompletionAt for multi-wave sends.

SMS pushes now carry a 72 hour ttl (FCM_SMS_TTL_SECONDS) that never
expires before scheduledAt, and the heartbeat probe collapses with a
30 minute ttl. Swagger wording for dispatchedAt no longer implies device
receipt.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-22 00:24:54 +03:00
vernu 8e18a4622f Merge pull request #313 from textbee/chore/lowercase-branding
Lowercase textbee branding in copy and identifiers
2026-08-12 15:23:22 +03:00
isra elandClaude Fable 5 eaa14f2aca chore: lowercase textbee branding in copy and identifiers
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-12 15:13:20 +03:00
vernu 5f5328ccb2 Merge pull request #312 from textbee/fix/android-recyclerview-binding-position
Fix the Android build and compile fork pull requests
2026-08-11 13:12:42 +03:00
isra elandClaude Opus 5 f06c996731 ci(android): compile fork pull requests against the sample firebase config
The build job is skipped on forks because google-services.json comes from
repository secrets, so contributor changes reached main without ever being
compiled. Add a fork-only job that uses the committed sample config.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 11:58:22 +03:00
isra elandClaude Opus 5 ce8009bae2 fix(android): pin recyclerview to 1.3.2
RecyclerView was resolving to 1.1.0 transitively through the material
library, which predates getBindingAdapterPosition(). Declaring it
explicitly keeps the available API independent of what material pulls in.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 11:58:17 +03:00
vernu 87f40dee88 Merge pull request #308 from Vishalsahani156/fix/281-filter-delete-wrong-rule
fix(android): delete/edit correct SMS filter rule by resolving position at click time (#281)
2026-08-11 11:25:45 +03:00
vernu fa6a4fcc87 Merge pull request #311 from textbee/chore/repo-url-textbee-org
Update GitHub repository URLs to the textbee org
2026-08-10 21:23:17 +03:00
isra elandClaude Opus 5 23fc6c776a chore: point repository URLs at the textbee org
Updates every hardcoded github.com/vernu/textbee reference to
github.com/textbee/textbee: README badges and links, the dashboard
external links and download page, the Android settings link, the
OpenAPI license URL, the email footers, and the commented ghcr image
names in docker-compose.

Also comments out every entry in FUNDING.yml and drops the Patreon
link from the three transactional email footers.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-10 18:21:53 +03:00
vernu b7ac7fef7a Merge pull request #310 from vernu/feat/discord-community-links
Add Discord community links across the web app, android app, and emails
2026-08-10 17:38:35 +03:00
isra elandClaude Opus 5 4eda62610e feat: add community links across web, android, and email
Route every Discord link through textbee.dev/discord so the invite can
rotate in one place. Adds a community card on the support page, a
dismissible dashboard banner, a CTA in the onboarding success state,
footer and error-state links, an android settings row, and a community
line in the support-confirmation and billing emails.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-10 17:29:26 +03:00
vernu 6208a523f5 Merge pull request #309 from vernu/chore/example-numbers
Refresh example phone numbers in docs and fixtures
2026-08-09 22:58:16 +03:00
isra el dd00ed4add chore: refresh example numbers in docs and fixtures 2026-08-09 22:54:41 +03:00
Vishalsahani156 96e8b1d3e8 fix(android): ensure valid position for edit and delete actions in FilterRulesAdapter 2026-08-09 21:41:33 +05:30
vernu ab995bf311 Merge pull request #307 from vernu/fix/api-guide-sdk-snippets
Update API guide SDK snippets to the account-level client
2026-08-09 13:47:48 +03:00
isra elandClaude Opus 5 20d642a958 fix(web): update API guide SDK snippets to the account-level client
The REST samples moved to /gateway/messages but the JS SDK samples still
passed a device id positionally, which no longer type-checks against
@textbee/sdk 0.1.0. They now use getMessages(options), show the
deviceIds and smsBatchId filters, and demonstrate iterateMessages for
cursor polling. The history blurbs name the available filters.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-09 13:41:20 +03:00
vernu 472eb80a1e Merge pull request #305 from vernu/docs/account-level-history-sweep
Point README receiving example at the account-level history endpoint
2026-08-09 13:24:49 +03:00
vernu 7a79920e9c Merge pull request #306 from vernu/feat/messages-batch-filter
Filter account message history by batch id
2026-08-09 13:24:40 +03:00
isra elandClaude Opus 5 32b8d455eb feat(api): filter account message history by smsBatchId
A deviceless send returns smsBatchId, but reading the batch back
required the device-scoped batch endpoint and a device id the sender
never needed to know. The account-level list now takes an optional
smsBatchId filter that composes with the rest: smsBatchId plus
status=failed lists the recipients of a batch that failed. Unowned or
missing batches 404, matching the deviceIds contract; legacy batches
without a user field stay readable, scoped by the caller's devices.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-09 13:15:18 +03:00
isra elandClaude Opus 5 8e25c15d7c docs: point the receiving example at the account-level messages endpoint
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-09 13:07:03 +03:00
vernu d83b5999ad Merge pull request #304 from vernu/feat/account-level-message-history
Account-level message history endpoint
2026-08-09 12:52:47 +03:00
isra elandClaude Opus 5 44c72eeeed docs(api): type pagination counts as integers with encoded bounds
page and limit were declared as number in the spec, which permits
fractional values for what are discrete counts. The query params now
declare integer schemas with their minimum, maximum, and default, and
the pagination meta counters are integers too. Runtime behavior is
unchanged; parsePagination already clamped. Also hoists a repeated
inline require in the service spec to a top-level import.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-09 12:48:54 +03:00
isra elandClaude Opus 5 28e11cee47 fix(api): declare page-mode cursor fields in the message list meta contract
Page mode returns nextCursor and hasMore, but the meta schema declared
neither and used oneOf, so a valid page-mode response matched both
branches and strict validators rejected it. A dedicated page meta DTO
documents the full shape and the union becomes anyOf. Also makes the
history device checkboxes render checked under the all-devices scope,
fixes a response example still showing the deprecated type field, and
drops a fail() call that jest-circus does not define.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-09 12:37:38 +03:00
isra elandClaude Opus 5 064d09e2f1 feat(web): message history on the account-level endpoint with multi-device filter
The history screen now reads GET /gateway/messages: the device picker
becomes a multi-select where an empty selection means every device, the
direction filter uses the new lowercase vocabulary, and sends invalidate
the whole messages cache prefix since the visible list may not key on
the sending device. Dashboard API guide snippets teach the new endpoint.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-09 12:19:03 +03:00
isra elandClaude Opus 5 f0f23e0e1d feat(api): account-level message history endpoint
GET /gateway/messages lists sent and received messages across all of a
user's devices, with deviceIds, direction, status, search, and half-open
from/to filters, plus keyset cursor pagination for pollers alongside the
existing page mode. Messages from deleted devices are excluded by
constraining every query to the caller's live devices. The device-scoped
history endpoints stay fully supported but are marked deprecated in the
spec, and the published OpenAPI document now promotes the account-level
route instead.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-09 12:06:45 +03:00
vernu 5d784e5126 Merge pull request #303 from vernu/fix/message-type-enum-casing
Report the real message direction casing and stop the dashboard dropping it
2026-08-09 09:56:21 +03:00
isra elandClaude Opus 5 c1e22f4e74 fix: report the real message direction casing and stop dropping it
The API stores and returns type as SENT and RECEIVED, but the spec declared
the enum lowercase and the dashboard compared against lowercase too. The
comparison in messageDirection therefore never matched, so every row fell
through to the sender heuristic the code keeps only for legacy rows, and a
sent message that carries a sender was labelled received. The tests missed
it because every fixture used a casing the API never emits.

The exported spec now takes the enum from SMSType directly, guarded by a
test, and both the field and the message history filter say which casing
they use, since the filter is lowercase and silently applies no filter at
all when it does not recognise a value.

Also corrects two schemas that misdescribed stored data: message and batch
status are optional, because rows predating status tracking carry none, and
the batch enum was missing two values that older batches use.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-09 09:37:31 +03:00
vernu 54fe4b321e Merge pull request #302 from vernu/feat/openapi-public-spec
Document every API endpoint and export a curated public OpenAPI spec
2026-08-09 08:38:26 +03:00
isra elandClaude Opus 5 10b4e7b635 docs(api): describe the remaining response and nested fields
Closes the last fields the docs audit flagged: the data wrappers on response
DTOs and the nested device state objects. Every property in every schema now
carries a description, and the spec adds its MIT license so it lints clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-09 08:15:11 +03:00
isra elandClaude Opus 5 c7cb7cd1d0 feat(api): export a curated public openapi spec
Adds pnpm run export:openapi, which boots the app in preview mode, reads the
controller decorators, and writes openapi.json. Preview mode instantiates no
providers, so this runs with no Mongo, no Redis, and no env file, and the
same code path is what CI checks.

The document is filtered by an explicit allowlist of 14 operations rather
than by tag, so a new endpoint stays out of the public spec until someone
adds it. Generation fails if an allowlisted route disappears or if anything
deprecated survives the filter. Bearer auth is stripped, since it is the
dashboard session flow no public consumer can obtain, and schemas no longer
reachable from the kept operations are pruned.

A spec asserts the operation set, the absence of deprecated operations, that
every operation has a summary and every request body has fields, and that
the committed openapi.json is current.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-09 08:13:10 +03:00
isra elandClaude Opus 5 d88404db95 docs(api): describe billing and support endpoints, hide empty surfaces
Billing and support carried no summaries at all, and support had no tag.
Adds summaries, descriptions, field descriptions, and response schemas for
plans, subscription usage, checkout, plan changes, and support requests.

Extracts a DTO for the account deletion body, which was typed inline and so
documented nothing. Hides the Polar callback, which is a signed provider to
server request rather than a developer endpoint, and hides the users
controller, which has no routes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-09 07:50:40 +03:00
isra elandClaude Opus 5 9140e46e31 docs(api): describe every auth endpoint and field in swagger
Replaces the bare title-case summaries with imperative ones plus
descriptions, documents the api key path params, and adds response schemas.
Extracts request DTOs for the five bodies that were typed inline, since an
inline object type renders as no schema at all.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-09 07:47:44 +03:00
isra elandClaude Opus 5 036e2c6d1a docs(api): describe every webhook endpoint and field in swagger
The webhook routes carried no swagger decorators at all and both request
DTOs had no ApiProperty, so they rendered with empty summaries and empty
request bodies. Adds summaries, path and query params, request field
descriptions with the real constraints (signing secret length, allowed
delivery URLs, event enum), and typed response schemas for subscriptions
and delivery records.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-09 07:45:27 +03:00
isra elandClaude Opus 5 e24f351911 docs(api): describe every gateway endpoint and field in swagger
Extracts the swagger config and the global prefix plus versioning pair out
of main.ts so a spec exporter can reuse them without drifting from the
server.

Adds the missing summary on the stats route, path and query params on every
device route, the undocumented search filter on message history, and typed
response schemas. Expands DeviceDTO and RetrieveSMSDTO to the shape the
service actually returns, and splits out MessageDeviceDTO for the smaller
device summary that message queries populate.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-09 07:43:13 +03:00
vernu 81a32787b3 Merge pull request #301 from vernu/feat/sdk-in-api-guide
Add a JavaScript SDK tab to the dashboard API guide
2026-08-08 14:43:14 +03:00
isra el 020fea374b feat(web): let users pick their package manager in the api guide
Adds a PackageManagerTabs widget offering pnpm, npm, yarn, and bun, with
pnpm default and the choice remembered in localStorage. It renders once
under the language picker, and only for the SDK tab, since the language
picker is global and the REST samples have nothing to install.

The install command moves out of the SDK sample, so the sample's copy
button hands back runnable code instead of a comment.

Each trigger gets its own TabsContent. Radix points aria-controls at a
panel per trigger, and a trigger without one is an invalid ARIA
reference, which the marketing repo's axe suite caught on the twin of
this component.

The widget is duplicated from the marketing site rather than shared. The
two live in separate repos, and a package for one small widget is more
machinery than it is worth. This copy uses theme tokens because the
dashboard renders in light and dark.
2026-08-08 14:38:38 +03:00
isra el 416a28d735 fix(web): restore the lockfile and keep npm in the sdk snippet
Reverts the pnpm-lock.yaml rewrite from the CodeRabbit auto-fix commit.
Nothing in this branch touches web/package.json, so the lockfile had no
business changing, and the lockfile guard failed the PR as designed.
It is now byte identical to main again.

Also restores "npm install @textbee/sdk" in the SDK snippet. The
pnpm-only rule is about our own scripts and CI; this string runs in the
reader's project, where npm is the right default. Scopes that rule in
.coderabbit.yaml so the same false positive does not come back, and
leaves a comment at the call site explaining the exception.

Keeps the other half of the auto-fix, which dropped commented-out code
from the message-status sample. The docs page covers getSms properly, so
the hint was not carrying its weight in a copy-paste snippet.
2026-08-08 14:23:47 +03:00
coderabbitai[bot]andCodeRabbit 7ebcf86e7f fix: apply CodeRabbit auto-fixes
Fixed 2 file(s) based on 4 unresolved review comments.

Co-authored-by: CodeRabbit <noreply@coderabbit.ai>
2026-08-08 11:19:24 +00:00
isra el ac4c1fa631 feat(web): add a JS SDK tab to the dashboard api guide
Adds @textbee/sdk samples alongside the existing REST examples. cURL
stays first and stays the default, so the SDK is an extra option rather
than a replacement.

Three of the four endpoints map to real SDK calls. Bulk send has no SDK
method yet, so that sample keeps the REST call behind a comment saying
so instead of implying coverage that does not exist.

The received sample uses getMessages with a type filter, which is how
the SDK reads inbound messages, and returns the same rows as the REST
route the tab documents.
2026-08-08 14:04:18 +03:00
vernu b39d9bc355 Merge pull request #300 from vernu/feat/os-version-from-build-id
Infer the Android version from the build id when nothing better is available
2026-08-08 12:34:50 +03:00
isra elandClaude Opus 5 bfcafe598f fix(gateway): drop the conditional OS write, restrict writable device fields
Reverts the conditional findOneAndUpdate filter added for the OS
provenance race. When the filter did not match it discarded the whole
update rather than just the OS fields, so a raced call silently dropped
the fcm token, the enabled flag and, on the heartbeat path, lastHeartbeat
itself, leaving the device looking offline with no error and no retry.
The race it guarded against only produced a temporarily wrong osVersion
that the next heartbeat corrects, so the guard cost more than it saved.
It also only engaged when normalizeOsFields had already approved the
write, and it broke the existing updateDevice tests.

The stored-version check in normalizeOsFields is kept as-is.

Also restricts what a client may write on a device. There is no global
ValidationPipe, so the request body reached $set unfiltered and any
device field was settable on your own device, including user and the SMS
counters. The allowed set is applied at the controller, which is where
input stops being trusted; the service still receives internally built
payloads that legitimately carry other fields.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-08 12:14:15 +03:00
coderabbitai[bot]andCodeRabbit d60ea6e9bc fix: apply CodeRabbit auto-fixes
Fixed 4 file(s) based on 2 unresolved review comments.

Co-authored-by: CodeRabbit <noreply@coderabbit.ai>
2026-08-08 08:59:47 +00:00
isra elandClaude Opus 5 11abd3e148 feat(gateway): infer the android version from the build id
Many devices have no OS version recorded. They report an empty
Build.VERSION.BASE_OS, so there is no fingerprint to read, and they run
older builds that never send a version directly. Almost all of them do
send a Build.ID, and its leading letter identifies the release.

Adds that as a last-resort source behind the two existing ones, so a
client that sends neither a version nor a usable fingerprint still
resolves. Precedence is reported, then fingerprint, then build id.

Only letters that resolve to a single release are included. R, H and M
are deliberately absent: Samsung's legacy R16NW builds are Android 8
rather than 11, Huawei and Honor ids carry no usable release signal, and
M conflicts with the AOSP convention. A shape guard keeps the lookup
from firing on arbitrary strings that merely start with a mapped letter.

Unlike the other two sources this is inference rather than measurement,
so osVersionSource now records how the value was arrived at and a weaker
source can never overwrite a stronger one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-08 11:46:59 +03:00
vernu 65c84b3f4c Merge pull request #299 from vernu/fix/os-build-fingerprint
Keep the build string a current client reports
2026-08-08 11:09:02 +03:00
isra elandClaude Opus 5 fb629dcdfe fix(gateway): keep the build string a current client reports
The previous change stopped raw OS metadata from reaching the display
fields, which correctly closed the case where an empty string or a null
could blank a stored value. It also added osBuildFingerprint to
OsFieldsInput, but normalizeOsFields never read it.

Current clients send `os` as the plain 'Android' label and put the build
string in osBuildFingerprint. With the raw field now stripped before the
patch is merged, and nothing emitting it, the value was dropped on every
write. The field ended up writable only by the backfill script, so no
app build could populate it and the per-OEM detail was lost.

normalizeOsFields now emits a reported build string when it is non
blank, and still falls back to parsing one out of `os` for older clients
that only ever sent it there. A blank one is still dropped, so it cannot
overwrite a stored value.

Three of the added tests fail without this change.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-08 11:06:14 +03:00
vernu 8643c446d7 Merge pull request #298 from vernu/feat/device-os-version
Fix the Android version reported in device info
2026-08-08 10:53:09 +03:00
coderabbitai[bot]andCodeRabbit ec00bca0f3 fix: apply CodeRabbit auto-fixes
Fixed 2 file(s) based on 1 unresolved review comment.

Co-authored-by: CodeRabbit <noreply@coderabbit.ai>
2026-08-08 07:48:58 +00:00
isra elandClaude Opus 5 149767cf12 fix(gateway): report the android version in device info
The `osVersion` field has existed on the device DTO and schema since the
early commits but nothing ever assigned it, so it was never populated.
The only OS value sent was Build.VERSION.BASE_OS, a raw build string
that many OEMs leave empty, which meant device info showed either an
unreadable build string or nothing at all.

Current builds now report Build.VERSION.RELEASE and SDK_INT, on
register, on update, and on heartbeat so a device fills the field in
without needing to re-register. The raw build string moves to
osBuildFingerprint rather than being dropped.

This replaces one already-reported value with a readable one. No new
kind of information is read from the device, and no permission is
involved: both constants are public and permission-free.

Most installs run older builds that will never send the new fields, so
the release is also derived server side from the build string those
clients already send. normalizeOsFields returns only the keys it has
real values for, which keeps an empty BASE_OS from overwriting a version
already stored.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-08 10:39:41 +03:00
vernu a7cb7da138 Merge pull request #297 from vernu/chore/api-docs-cache-headers
Send no-cache for the swagger init script
2026-08-08 08:00:53 +03:00
isra elandClaude Fable 5 c6486fd67b chore(api): send no-cache for the swagger init script
The generated swagger-ui-init.js embeds the whole spec but was served
as a plain static .js, so CDNs and browsers kept a stale copy of the
docs after a deploy. The swagger-ui library assets stay cacheable.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-08 07:51:44 +03:00
vernu fa58735908 Merge pull request #296 from vernu/feat/get-device-by-id
Add get device by id endpoint to the gateway API
2026-08-07 22:23:06 +03:00
isra elandClaude Fable 5 8b8e1e0e2b feat(api): add get device by id endpoint
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-07 22:13:56 +03:00
vernu d21d8a3c75 Merge pull request #295 from vernu/refactor/schema-typings-ownership-checks
Standardize schema typings and unify ownership checks
2026-08-07 21:37:47 +03:00
isra elandClaude Fable 5 6adff75024 refactor(api): normalize pagination parsing and inbound sms validation
Share one pagination parser across the message list endpoints so
malformed values resolve to defaults instead of propagating, and
check inbound sms payload field types before they reach queries.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-07 21:25:15 +03:00
isra elandClaude Fable 5 41fa3236bd chore(web): restore pnpm lockfile
Revert an unnecessary lockfile regeneration; no dependency changes
were intended on this branch.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-07 21:25:15 +03:00
coderabbitai[bot]andCodeRabbit 91336ac7a9 fix: apply CodeRabbit auto-fixes
Fixed 1 file(s) based on 1 unresolved review comment.

Co-authored-by: CodeRabbit <noreply@coderabbit.ai>
2026-08-07 18:18:16 +00:00
coderabbitai[bot]andCodeRabbit dd6e0e252d fix: apply CodeRabbit auto-fixes
Fixed 3 file(s) based on 1 unresolved review comment.

Co-authored-by: CodeRabbit <noreply@coderabbit.ai>
2026-08-07 18:13:41 +00:00
isra elandClaude Fable 5 e5bb185e47 chore(api): mark legacy receive route aliases deprecated in api docs
Split the camelCase aliases into their own documented routes so the
dashed forms stay the canonical ones. Behavior is unchanged and the
aliases remain supported.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-07 21:06:23 +03:00
isra elandClaude Fable 5 59cc59bef1 chore(api): set cache headers on api docs
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-07 20:54:24 +03:00
isra elandClaude Fable 5 c4e2178ba5 refactor(web): scope session-expiry handling to auth failures
Only sign the user out when the API identifies the 401 as an
authentication failure; other 401s stay with the caller.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-07 20:54:24 +03:00
isra elandClaude Fable 5 5d84fca237 refactor(api): unify ownership checks across roles
All roles resolve devices and api keys through the same owner-scoped
lookup, with explicit id casts and not-found semantics for misses.
Auth failures now carry a machine-readable code.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-07 20:54:24 +03:00
isra elandClaude Fable 5 0d8522e465 test(api): assert schema path types
Compile every schema and check that reference fields resolve to
ObjectId paths.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-07 20:54:14 +03:00
isra elandClaude Fable 5 4124f27ec8 refactor(api): standardize ObjectId declarations in schema props
Use SchemaTypes.ObjectId consistently for referenced documents across
all schemas.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-07 20:54:13 +03:00
vernu 10e7242bef Merge pull request #294 from vernu/feat/deviceless-send-and-default-device
Add deviceless send endpoints and a default device
2026-08-07 16:35:03 +03:00
isra elandClaude Fable 5 d6b0aac069 fix(api): align guard spec with scoped device lookup and require a user id
CodeRabbit moved the guard's ownership check into the query
(getDeviceById scoped by userId), which is sound, but left the spec
mock returning a foreign device regardless of the scope argument, so
the cross-tenant test saw a pass instead of a 401. The mocks now honor
the scoped-lookup contract and assert the scoping arguments.

Also restores the old guard's defense in depth: a non-admin request
with no authenticated user id is rejected before any lookup, since a
missing id would otherwise degrade the scoped lookup to an unscoped
findById.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-07 16:30:58 +03:00
coderabbitai[bot]andCodeRabbit 1f8cf1685d fix: apply CodeRabbit auto-fixes
Fixed 7 file(s) based on 4 unresolved review comments.

Co-authored-by: CodeRabbit <noreply@coderabbit.ai>
2026-08-07 13:21:59 +00:00
isra elandClaude Fable 5 9442e12ae8 docs: point README snippets at the deviceless send-sms endpoint
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-07 15:58:24 +03:00
isra elandClaude Fable 5 9256442af3 feat(web): prefer the default device in message history and rebalance filter widths
History now opens on the default device instead of whichever device
happens to be first, its dropdown marks the default like the other
pickers, the device select widens from w-52 to w-64, and the search
input caps at max-w-sm instead of absorbing the whole row.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-07 15:48:58 +03:00
isra elandClaude Fable 5 7b986f7596 feat(web): mark the default device in pickers and explain the badge
The send and bulk-send device dropdowns suffix the default device the
same way disabled devices are suffixed, and the Default badge on the
device list gets a tooltip stating that sends without a deviceId go
out from that device.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-07 15:41:25 +03:00
isra elandClaude Fable 5 90985bd1cd feat(web): switch sends to deviceless endpoints and add default device UI
The dashboard now posts to /gateway/send-sms and /gateway/send-bulk-sms
with deviceId in the body (selection stays explicit in the UI). The
device list shows a Default badge and offers Set as default on enabled
non-default devices, the send form preselects the default device, and
the API guide snippets are rebuilt for the new endpoints in all five
languages via a single deviceIdField helper so the with and without
variants cannot drift.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-07 09:42:51 +03:00
isra elandClaude Fable 5 d0411f31ab feat(api): add deviceless send endpoints and default device support
New POST /gateway/send-sms and /gateway/send-bulk-sms accept the same
bodies as the device-scoped routes plus an optional deviceId. When it
is omitted the sender is resolved server-side: the user's default
device if enabled, else the enabled device with the most recent
heartbeat. Both return 200. Explicit ids are authorized in the query
itself (scoped to the requesting user, admin exempt) and mirror the
CanModifyDevice error contract exactly.

Devices gain isDefault plus an index on user. The first device a user
registers becomes their default, POST /devices/:id/set-default moves
it, and isDefault is stripped from register/update input so the
dedicated endpoint stays the only writer (no global ValidationPipe).

The old device-scoped send routes are unchanged (201, same guards) and
only marked deprecated in Swagger.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-07 09:42:42 +03:00
vernu 20651b3a91 Merge pull request #292 from vernu/fix/sync-checkout-session-status
Record checkout status from the Polar webhook
2026-08-06 13:43:09 +03:00
isra elandClaude Opus 5 fb681f2564 fix(billing): record checkout status from the polar webhook
Polar already sends checkout.updated, but the webhook switch had no case
for it, so the event was stored and then dropped. Nothing ever wrote
isCompleted, which meant a checkout the customer had already paid for
stayed reusable from the cached session until it expired, and the
completed count read as zero.

Adds syncCheckoutSessionStatus, keyed on the checkout id rather than the
user: the session cache holds one row per user, so a late webhook for a
checkout that has since been replaced should match nothing rather than
overwrite the newer row. Only terminal states are written. open and
confirmed are still in flight and failed is retryable, so the cached
checkout stays valid for those.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-06 13:39:12 +03:00
vernu cb703bdc32 Merge pull request #291 from vernu/fix/restore-in-app-checkout-redirect
Keep in-app upgrade links pointing straight at checkout
2026-08-06 13:34:56 +03:00
isra elandClaude Opus 5 94825b023f fix(web): keep in-app upgrade links pointing straight at checkout
In-app upgrade links omitted the billing interval, so they landed on the
interval chooser instead of continuing to the payment page. They now pass
an interval explicitly, via a single checkoutPath() helper so a call site
cannot drop it again.

Also in this change:
- plan cards headline the month-to-month price the CTA actually charges
- plan cards and the checkout chooser quote the refund window for the interval
- the device-limit and "Learn More" links point at the public pricing page
  instead of routes that do not exist in this app

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-06 13:26:01 +03:00
vernu bf0197e788 Merge pull request #286 from Siteshcodes/fix-invalid-deviceid-400
fix(api): validate deviceId before ObjectId conversion
2026-08-04 18:59:36 +03:00
Siteshcodes 1cc05c01b1 fix(api): validate deviceId before ObjectId conversion 2026-08-04 20:40:36 +05:30
vernu ea8c2cb8e8 Merge pull request #274 from waterWang/main
fix: invalidate message list and stats after sending SMS
2026-08-04 17:05:18 +03:00
isra elandClaude Opus 5 aa6e677364 fix: invalidate the quota and the bulk-send caches after a send
Completes issue #261. A send also consumes quota, so the subscription
query has to be invalidated alongside the message list and the stats,
and the bulk sender needs the same three keys: it posts its own raw
mutation and invalidated nothing, so a 500-message campaign left the
quota bar untouched. The invalidation set now lives in one exported
helper both call sites use.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-04 17:02:25 +03:00
waterWang ee5e246cda fix: invalidate message list and stats after sending SMS
The useSendSms mutation was the only mutation in hooks.ts that did not
invalidate any queries on success. After sending an SMS the message
history dialog and the dashboard usage stats remained stale until the
next manual page refresh.

- Added queryClient.invalidateQueries for deviceMessages on success
- Added queryClient.invalidateQueries for stats on success

Fixes #261
2026-08-03 19:58:27 +08:00
vernu 80bb470098 Merge pull request #273 from vernu/dev
Dev
2026-08-03 14:07:31 +03:00
vernu 7fb7d7ff51 Merge pull request #272 from vernu/ci/lockfile-guardrails
Guard pnpm lockfiles against bot regeneration and pin the pnpm toolchain
2026-08-03 14:06:33 +03:00
isra elandClaude Fable 5 21f33c8293 ci: address CodeRabbit review on lockfile guardrails
- pass github.base_ref through env instead of template interpolation so
  a crafted branch name cannot inject shell into the runner
- pin actions/checkout in the new workflow to the v7 commit SHA
- install pnpm@9.14.2 in both Dockerfiles via corepack instead of a
  loose npm install -g pnpm@9, so containers build with the same pnpm
  the manifests pin

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-03 13:56:04 +03:00
coderabbitai[bot]andCodeRabbit 14c1dbbc41 fix: apply CodeRabbit auto-fixes
Fixed 2 file(s) based on 3 unresolved review comments.

Co-authored-by: CodeRabbit <noreply@coderabbit.ai>
2026-08-03 10:49:44 +00:00
isra elandClaude Fable 5 bb3551009d ci: guard pnpm lockfiles against wholesale regeneration
CodeRabbit's auto-fix agent regenerated both lockfiles on PR #270,
floating zod to v4 inside @polar-sh/sdk and breaking the API build.
Path filters already excluded lockfiles from review but do not stop the
fix agent from committing whatever its sandbox produces.

- lockfile-guard workflow fails any PR where a pnpm-lock.yaml changes
  without its sibling package.json
- packageManager pins pnpm@9.14.2 in api and web, and CI reads the pin
  via package_json_file instead of a loose version: 9
- CI installs use --frozen-lockfile so an out-of-sync lockfile fails at
  install time instead of being silently re-resolved
- .coderabbit.yaml instructs the fix agent to never run installs or
  commit lockfile changes

Closes #271

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-03 13:31:13 +03:00
vernu f2b856394e Merge pull request #270 from vernu/dev
Dev
2026-08-03 13:20:28 +03:00
isra elandClaude Fable 5 3fa7265924 ci: stop persisting checkout credentials in build workflows
actions/checkout writes the GITHUB_TOKEN into .git/config by default,
where any later step can read it. None of these jobs push or use git
auth after checkout, so persist-credentials: false removes the exposure.
Applies the CodeRabbit suggestion it could not commit itself.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-03 13:14:41 +03:00
isra elandClaude Fable 5 33f55bc738 test(web): scope api-guide copy test to the send-sms sample
The page's first Copy code button belongs to the Base URL chip, so the
exact-match assertion CodeRabbit added could never pass. Click the button
inside the #send-sms section instead, and build the expected snippet from
buildEndpoints() so the test cannot drift from the source template.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-03 13:12:10 +03:00
isra elandClaude Fable 5 0ee62ea8c9 fix: restore lockfiles clobbered by CodeRabbit's full re-resolution
The bot regenerated both pnpm lockfiles, floating every dependency to the
newest allowed version. That pulled zod 4.4.3 into @polar-sh/sdk, which
broke @polar-sh/sdk/webhooks type resolution and failed the API build on
CI. Restored from 967ddd4e and verified with a local pnpm install (no
diff, zod pinned back to 3.25.76).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-03 13:12:10 +03:00
coderabbitai[bot]andCodeRabbit 5e49637a61 fix: apply CodeRabbit auto-fixes
Fixed 5 file(s) based on 6 unresolved review comments.

Co-authored-by: CodeRabbit <noreply@coderabbit.ai>
2026-08-03 09:56:52 +00:00
vernu 967ddd4e3a Merge pull request #268 from kalayciburak/fix/webhook-history-date-filter-query-key
fix(web): include start/end in webhook notifications query key
2026-08-03 12:40:11 +03:00
vernu adc3e7b638 Merge pull request #269 from vernu/fix/swagger-x-api-key-scheme
Make Swagger UI actually send the x-api-key header
2026-08-03 12:37:33 +03:00
isra elandClaude Fable 5 4dd3364d5b fix(api): name the swagger x-api-key scheme and attach it to guarded routes
The scheme was registered without a name, so it landed in the OpenAPI doc
as api_key and no operation referenced it. Swagger UI stored the key but
never sent the header. Name the scheme x-api-key and add ApiSecurity to
every AuthGuard route, mirroring the existing ApiBearerAuth placement.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-03 12:32:00 +03:00
Burak KALAYCI 3c12d39bad fix(web): include start/end in webhook notifications query key
Date filter changes for webhook history were ignored by react-query
because start and end were only sent as request params, not cache keys.

Fixes #256
2026-08-03 12:28:14 +03:00
vernu b85908cd7a Merge pull request #255 from vernu/chore/coderabbit-setup
Add CodeRabbit review, split CI into per-app workflows
2026-08-03 09:39:55 +03:00
isra elandClaude Opus 5 4ab8c0c611 test(web): wait for hydration before clicking in dashboard and guide specs
Four specs had been failing on main for as long as the run history goes
back. The old workflow set continue-on-error on the e2e step rather than
the job, and a step with continue-on-error reports its conclusion as
success even when the command exits non-zero, so the failures never
surfaced. Splitting e2e into its own job made them visible.

None of them were stale. Every string they assert on still exists in the
source. Three are hydration races of the shape ab96e73 fixed for the
messaging specs: the sections stream behind a loading.tsx boundary, so the
HTML paints before React attaches handlers, and a click landing in that
gap is silently dropped. The test then waits out its timeout on a dialog
that was never going to open.

Both specs now navigate through a helper that waits on a value only the
client render can produce: the gateway stats figure on the dashboard, and
the fetched device id in the guide. Prism tokens are not usable as that
signal, because react-syntax-highlighter renders its spans server side
too, so they are already in the SSR payload.

The clipboard spec had a second, unrelated cause. It asserted on the
button's "Copied" label, and code-block.tsx clears that label two seconds
after the click, so the assertion raced a window narrow enough to miss on
a loaded runner. It now polls the clipboard, which is the behaviour under
test and does not expire.

Verified with --repeat-each=4 --retries=0 --workers=2: 56 passed. Single
worker runs are why local looked green while CI failed; the specs' own
comments note that parallel workers background the page, which one worker
never reproduces.

No production code changed. These were test bugs.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 09:12:13 +03:00
isra elandClaude Opus 5 40b15f25de ci: split build-and-test into per-app workflows and run on PRs
The workflow fired on push only, so pull requests carried no checks of
their own. CodeRabbit's github-checks tool waits on GitHub Checks and
comments on failures, which gave it nothing to read, and outside
contributors got no CI signal before merge.

It also ran everything in one workflow, with web and api sharing a single
job. On the last green run that job was 5m23s, of which the e2e suite was
2m52s while already carrying continue-on-error, so it dominated wall clock
while gating nothing. An api-only change paid for all of it plus both
android builds, because a workflow-level paths filter is all or nothing.

Now api, web and android each have their own workflow and their own paths
filter, which needs no third-party change-detection action, and web e2e is
its own job so the blocking signal lands in about 90 seconds.

Also folded in, since the files were being rewritten anyway:

- permissions: contents: read. Nothing was declared, so the token got
  default write scope on every run.
- concurrency with cancel-in-progress, and timeout-minutes. A hung job
  previously burned the six hour default.
- Action versions. actionlint rejected setup-node@v3, cache@v3 and
  setup-java@v3 as too old for current runners, and gradle-build-action
  is deprecated in favour of gradle/actions/setup-gradle.
- setup-node's built-in pnpm cache replaces the manual store-path and
  actions/cache block. pnpm is installed first so the cache has a package
  manager to query.
- The android job is skipped on fork pull requests. It writes
  google-services.json from repository secrets, and GitHub withholds
  secrets from forks, so it would fail every outside contribution.
- google-services.json is written through an env var instead of shell
  interpolation, so the secret never lands on a command line and JSON
  containing quotes cannot break the redirect.
- fail-fast: false on the android matrix, so a dev failure no longer
  cancels the prod build.
- Dropped the android_variant dispatch input. It was declared but never
  referenced; the matrix always built both variants.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 09:12:03 +03:00
isra elandClaude Opus 5 f123feb2e7 chore: add CodeRabbit config for automated PR review
textbee is public, so it qualifies for CodeRabbit's Open Source plan (the
Pro+ feature set at no cost). Reviews are comment-only on the chill
profile, so nothing gates a merge.

Two settings are load-bearing and easy to get wrong:

base_branches always includes the default branch and cannot exclude it,
so main is reviewed implicitly and dev has to be listed explicitly.
Without it, every feature PR would go unreviewed since they all target
dev. The skip-ai-review label is the only lever left for skipping the
dev to main roll-ups, which would otherwise re-review code already seen.

CodeRabbit auto-reads CLAUDE.md from the repo root as review guidelines,
but this repo's CLAUDE.md sits one directory above the git root and
.cursor/rules is not on its auto-read list. The no-em-dash and pnpm-only
rules are therefore encoded directly in tone_instructions and a global
path instruction.

Validated against schema.v2.json.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 09:11:51 +03:00
vernu f25b117442 Merge pull request #253 from vernu/dev
Dev
2026-08-02 14:41:35 +03:00
vernu 7cfa0e0cf7 Merge pull request #252 from vernu/fix/api-request-scoping
fix(api): scope record lookups to the caller and tighten auth checks
2026-08-02 14:36:37 +03:00
isra elandClaude Opus 5 083f2c8cd5 fix(support): require authentication for support requests
The customer-support route accepted anonymous callers and only overwrote
the user field when one was authenticated, so the body could set it. The
route now requires authentication and always takes the user from the
token. The daily cap counts per authenticated user rather than per
self-reported email address.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 14:29:31 +03:00
isra elandClaude Opus 5 fdc1609946 fix(auth): check Google token audience and align reset responses
The tokeninfo response was trusted without checking who the token was
issued for, so a token minted for any other Google OAuth client was
accepted. The audience is now matched against GOOGLE_CLIENT_ID and the
verified-email flag is required. The audience check is skipped with a
logged warning when the variable is unset, so an unconfigured environment
does not lose Google sign-in.

Password reset requests now return the same response whether or not the
address is registered, an unknown address on reset reports the same error
as a bad code, a non-string email no longer reaches the query, and the
code is drawn from crypto.randomInt.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 14:29:31 +03:00
isra elandClaude Opus 5 7347f2b730 fix(gateway): scope SMS and batch lookups to the requesting device
getSMSById and getSmsBatchById resolved records by id alone, so the device
in the route was not part of the query. Both now filter on the device, and
the batch id supplied in the sms-status body is matched the same way.

A record that does not belong to the device reads as not found, so a
mismatch is indistinguishable from a missing id.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 14:29:22 +03:00
vernu 829498f65f Merge pull request #251 from vernu/dev
Dev
2026-08-02 14:08:14 +03:00
vernu 0790be7b00 Merge pull request #250 from vernu/feat/api-key-sha256-lookup
perf(auth): resolve API keys via indexed sha256 instead of bcrypt
2026-08-02 13:28:21 +03:00
isra elandClaude Opus 5 8fb918d2ed perf(auth): resolve API keys via indexed sha256 instead of bcrypt
Every API-key-authenticated request ran bcrypt.compareSync, blocking the
event loop 60-100ms and capping each process at roughly 10-20 req/s. That
was the first hard ceiling for device traffic (heartbeats, status
callbacks, inbound SMS).

API keys are high-entropy random tokens, so slow hashing buys nothing:
bcrypt exists to protect low-entropy passwords from offline brute force.
verifyApiKey now resolves a key with a single indexed sha256 lookup.

Keys issued before this change have no sha256 stored, so they fall back
to the old masked lookup plus one async bcrypt.compare, then backfill
their own sha256 and use the fast path from then on. Nothing is
invalidated and no user action is required. bcrypt hashes are still
written for new keys so a revert keeps authenticating every key.

New keys are txb_ plus 32 base62 characters, replacing uuidv4. The
prefix makes a leaked key identifiable and allows registering a secret
scanning pattern later. The masked display shape is unchanged, which is
what keeps a revert working for keys issued mid-migration.

Both AuthGuard and OptionalAuthGuard are migrated; the latter had the
same compareSync call and no test file, so it gets one.

Rejecting non-string input also fixes a pre-existing 500: an object or
array from the apiKey query param previously reached .substring().

Passwords continue to use bcrypt.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 12:39:48 +03:00
vernu c5e5932168 Merge pull request #248 from vernu/dev
Dev
2026-07-22 17:34:38 +03:00
vernu 8964df5911 Merge pull request #247 from vernu/fix/dialog-long-text-overflow
fix(web): keep long message text inside the details dialog
2026-07-22 17:18:51 +03:00
vernu 0b45933b26 Merge pull request #246 from vernu/perf/dashboard-nav-speed
perf(web): make dashboard navigation fast
2026-07-22 17:18:42 +03:00
isra elandClaude Opus 4.8 bb58cb3ba8 fix(web): keep long message text inside the details dialog
DialogContent is a CSS grid, and an implicit grid track has a min-content
floor. `break-words` does not reduce min-content size, so a long
unbreakable URL in any child pushed every child past the card's
max-width instead of wrapping. In message history that left the message
box, the delivery error panel and the action button rendered hundreds of
pixels outside the dialog, off the side of the screen.

An explicit minmax(0,1fr) column removes that floor, after which
break-words can do its job. This fixes every dialog in the app, not just
this one: any long URL, API key or gateway ID hit the same track.

Also makes the details dialog the single scroll container, matching
api-keys and the webhook payload modal, instead of capping the message
body at 224px behind a scrollbar nested in a scrollbar. And the list row
preview now wraps, so a URL-heavy message fills both clamped lines
rather than being cut off partway through the first.

Covered by an e2e regression test at 375px and 1280px that asserts the
dialog does not scroll sideways and that the action button stays inside
it. Verified failing before this change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-22 15:47:18 +03:00
isra elandClaude Fable 5 83d0c3803a fix(web): stop the SupportHQ widget rebuilding itself on every session refetch
The effect depended on the whole session object. SessionProvider returns a
new object on every refetch, and React compares deps by reference, so an
identical session still ran the cleanup and the body again: the widget was
destroyed and its script re-injected even though nothing about the user
had changed, closing any chat the user had open.

It now depends on the individual strings the effect actually reads, which
compare by value, so it only re-initialises when the metadata genuinely
changes.

Two related leaks fixed at the same time:

- cleanup called destroy() but left the script tag in the document, so
  every re-run added another one for the life of the page. It is now
  removed.
- a script whose src is already cached can finish loading after cleanup
  ran, and initialising then left a widget behind that nothing would
  destroy. That late load is now ignored.

Tests cover all four behaviours and fail against the previous version.

Also gives the API guide page focus before the clipboard test: writeText
rejects on an unfocused document, which is where a page sits while other
workers run, and granting clipboard permissions does not cover it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-22 14:45:40 +03:00
isra elandClaude Fable 5 3eba8d3444 perf(web): register only the Prism languages used, scope Google OAuth
The API guide imported react-syntax-highlighter's default entry, which is
the highlight.js build with every language compiled in. It also applied
Prism themes to it, and the two use different class names, so the samples
were largely uncoloured on top of being heavy. Switch to PrismLight and
register the six languages the guide actually renders.

GoogleOAuthProvider wrapped the whole app but only the login and register
pages use it, so the Google Identity SDK initialised on every dashboard
page. Move it inside LoginWithGoogle, which is where both consumers go
through.

Total client chunks drop from 3.1M to 2.3M.

Also fixes four e2e tests that were relying on page.goto returning a
fully loaded page. Adding loading.tsx introduced a short skeleton state
that did not exist before, and each test measured or interacted during
it. They now wait on real signals instead of timing:

- bulk send waits for the dropzone row cap, which comes from
  useSubscription inside that page's own hook. The previous wait on the
  devices response was wrong: the dashboard layout requests the same
  query key, so it can resolve before the page hydrates.
- the mobile overflow sweep and the API guide replace networkidle, which
  needs a 500ms window with zero connections that link prefetching keeps
  pushing out of reach.
- the footer/tab-bar check re-scrolls as the page grows, instead of
  scrolling once while the skeleton is still short.
- the overflow sweep measures the billing tab rather than
  /dashboard/account, a redirect stub with no layout of its own whose
  client-side redirect tore down the measurement.

A new test asserts the samples carry Prism token markup in all five
languages, so a revert to the highlight.js entry fails instead of
silently dropping the colours.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-22 14:36:34 +03:00
isra elandClaude Fable 5 ab96e731a6 test(web): wait for hydration before typing in messaging e2e specs
The messaging pages now stream behind a loading.tsx boundary, so their
HTML can paint before React hydrates. Tests that filled the To field or
dropped a CSV immediately after page.goto raced that window: the input
was there, the handlers were not, and the interaction was silently lost.
Under parallel workers the race lost often enough to fail runs at random.

Both specs now navigate through a helper that waits for the client-side
devices fetch, which only fires after hydration effects run, so the form
is proven interactive before the test touches it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-22 14:02:13 +03:00
isra elandClaude Fable 5 18e7d1b3d8 perf(web): make dashboard navigation fast and cut session fetches
- prefetch route-tab links so tab clicks reuse a cached payload instead
  of paying an uncached server round trip each time
- add loading.tsx boundaries (dashboard root and each section) so clicks
  paint within a frame; section-level files keep the header and tabs
  mounted while only the content area swaps
- link the sidebar Account item and the checkout page straight to
  /dashboard/account/billing. The /dashboard/account redirect stub stays
  for old links, but no internal link pays the extra hop anymore. The
  stub also dropped query params, which silently ate the plan-change
  success toast.
- set QueryClient defaults (staleTime 60s, no focus refetch, retry 1);
  mutations already invalidate their keys, so the user's own changes
  stay instant. Device messages and webhook deliveries get a 15s
  staleTime since they change from outside the tab.
- replace the axios getCachedSession TTL cache with a token seeded from
  the server session in Providers and kept in sync by a session bridge.
  Requests attach the token synchronously; /api/auth/session is only a
  deduped fallback, instead of a refetch every 2 minutes with a
  thundering herd on expiry.
- swap the billing card's 16px loading spinner for a card-shaped
  skeleton so the tab no longer looks blank while loading

Tests: interceptor seeding/dedupe/signed-out behavior, provider
defaults and token seeding, nav active-state matching, tab prefetch,
billing loading state, plus e2e coverage for direct-to-billing
navigation and an at-most-one-session-call budget guard.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-22 13:49:14 +03:00
vernu c36cfc12a6 Merge pull request #245 from vernu/fix/checkout-plan-params-next16
fix(billing): send the plan name from route params under Next 16
2026-07-20 22:12:25 +03:00
isra elandClaude Opus 4.8 0bd5d4c900 fix(billing): send the plan name from route params under Next 16
/checkout/pro and /checkout/scale failed with "Plan cannot be purchased"
for every user since the Next 16 upgrade (ea1b00c).

The checkout page still typed `params` as a plain object and read
`params.planName` synchronously. Next 16 removed the sync compat shim, so
the value was undefined, axios dropped the key, and the API ran
findOne({ name: undefined }). Mongoose 9 returns null for that where
older versions stripped the key, so the guard threw a message about a
misconfigured plan for a request that never named one. Plan data and
Polar product ids were correct throughout.

- unwrap params with React's use() and drop the `as string` cast that
  suppressed the type error
- split the conflated guard into PLAN_NAME_REQUIRED, PLAN_NOT_FOUND and
  the existing unpurchasable case, each logged server side
- stop retrying a rejected checkout twice: a 400 is not transient
- mark the Polar product id indexes sparse, they have been failing to
  build with E11000 on every boot because most plans leave them unset

Checkout now defaults to yearly. A URL that names an interval still
redirects straight through, so the marketing funnel is unchanged; the
in-app CTAs that name none get an interval chooser with yearly
preselected instead of a silent monthly default.

Also redesigns the checkout states, which were the only place in the
dashboard hardcoding gray-100/white and so rendering a light slab in dark
mode, and the plan picker, which painted the accent on every tier.

Tests: a checkout e2e spec asserting the request body carries planName,
which is what would have caught this, plus API cases pinning the three
guard failures apart.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-20 21:47:17 +03:00
vernu 48607239bd Merge pull request #244 from vernu/dev
Dev
2026-07-19 21:07:08 +03:00
isra elandClaude Opus 4.8 ede855e15a Merge branch 'main' into dev
Resolve auth.service.spec.ts by keeping dev's suite and porting the
password-reset lockout coverage from #238 into it.

Harden the lockout from #238 while merging:
- claim each attempt with an atomic findOneAndUpdate $inc, so concurrent
  guesses cannot all read the same count and slip past the cap
- match records predating the counter, which have no attempts field
- lift MAX_PASSWORD_RESET_ATTEMPTS to module scope and trim comments

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-19 20:53:52 +03:00
vernu e9107c180f Merge pull request #238 from sebastiondev/fix/cwe640-auth-service-password-ed0b
fix(auth): lock out password-reset OTP after 5 failed attempts
2026-07-19 20:42:44 +03:00
vernu 381e8987b8 Merge pull request #243 from vernu/feat/api-hardening
test(api): lock security-critical behaviour, then small guarded fixes
2026-07-19 20:35:37 +03:00
vernu e46b832e5a Merge pull request #242 from vernu/fix/auth-form-errors
fix(web): show auth errors instead of silently reloading
2026-07-19 20:35:11 +03:00
isra elandClaude Fable 5 8b635a2344 fix(api): keep push token and serial out of the device list
getDevicesForUser did find({ user }) with no projection, so the device
list endpoint returned the full document, including the fcmToken push
credential and the hardware serial, straight to the browser. Neither is
used by the dashboard or the Android client.

The query now projects both fields out with '-fcmToken -serial'. Only the
user-facing list is narrowed; getDeviceById, which feeds the guards and
internal logic, is untouched.

Guarded by the existing getDevicesForUser test, rewritten to assert the
projection and seen to fail against the pre-change single-argument call.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 18:37:40 +03:00
isra elandClaude Fable 5 e58412fa14 fix(api): escape the api-key lookup regex
findActiveApiKeyByClientKey builds a fallback lookup as
new RegExp(`^${prefix}`) from the client-supplied key prefix and runs it
as a Mongo $regex. A prefix containing regex metacharacters compiles to
the wrong pattern or, for something like "((((", throws SyntaxError and
fails the request. On the authentication hot path that is a denial and a
correctness hole.

The prefix is now escaped with escapeRegExp before it reaches the RegExp.
escapeRegExp already existed under gateway with its own spec; it is moved
to src/common so auth and gateway share one copy, and the gateway import
is updated. No behaviour change for the gateway caller.

Guarded by the existing lock (a legitimate key still resolves via the
masked hit and the prefix fallback) plus a new case, written first and
seen to fail against the pre-fix code (it threw "Unterminated group"):
a key with metacharacters now compiles to a literal pattern and resolves
to no match instead of throwing.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 18:35:36 +03:00
isra elandClaude Fable 5 6b66160f7f fix(api): actually enforce email and password validation
validateEmail and validatePassword are async and signal failure by
throwing (a rejected promise). Three callers invoked them without await:
register (both) and changePassword. An unawaited rejected promise is
dropped, so execution continued past the check: registration accepted a
malformed email or an out-of-range password, and a password change
accepted a too-short new password. The validators were effectively dead.

Adding await makes the existing rules take effect at their intended
point. Legitimate clients are unaffected: the web signup and
change-password forms already enforce a valid email and the length
bounds, so this only rejects input the service was always meant to
reject, with a clean 400 instead of persisting bad data.

Guards were written first and seen to fail against the pre-fix code
(register with a bad email or short password still created the user;
changePassword with a short password still saved), then pass after the
await is added. The valid-input paths are asserted to still succeed.

This is the dependency-free half of what a global ValidationPipe would
have covered. The pipe itself is deferred: class-transformer is not
installed, so enabling it now would add a dependency and risk crashing
once any DTO gains a decorator. Recommended as a separate follow-up.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 18:33:57 +03:00
isra elandClaude Fable 5 b514891cad test(api): lock support.service flows
support.service had no tests. These lock both flows against current code:

- createSupportMessage: the 24h rate limit rejects a fourth request
  without persisting or emailing; the success path saves the message,
  strips the turnstile token before persistence, emails the requester,
  and returns success.
- requestAccountDeletion: a missing or invalid user id and an unknown
  user are both NotFound; a second request is a Conflict; the success
  path records accountDeletionRequestedAt with the reason and emails the
  user.

6 tests, all passing against current code.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 18:27:32 +03:00
isra elandClaude Fable 5 78fb040c21 test(api): lock webhook.service security-critical units
webhook.service was 1039 lines with zero tests. These specs pin the
parts a regression would hurt most, without changing any code:

- validateDeliveryUrl: accepts normal https; rejects non-http(s) and
  malformed URLs; rejects loopback and private hosts including the cloud
  metadata IP. This locks the SSRF guard so it cannot silently regress.
- Signing secret validation: a secret under 20 characters is rejected on
  both create and update.
- attemptWebhookDelivery signing: the X-Signature header equals an
  independently computed HMAC-SHA256 of the JSON payload under the
  subscription secret, and the signature changes with the secret.
- Delivery abort: when the subscription is inactive or soft-deleted, the
  attempt is marked aborted and saved and no HTTP request is made.

axios is mocked so no request leaves the process; private methods are
exercised through the instance.

16 tests, all passing against current code.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 18:26:24 +03:00
isra elandClaude Fable 5 9b7a8bcc55 test(api): lock auth.service core flows
auth.service was 521 lines with only the withoutPassword helper covered.
These specs pin the flows a regression would hurt most, all against the
current unchanged code:

- validateEmail / validatePassword: boundary and malformed-input cases,
  including the 6 and 128 character password edges.
- findActiveApiKeyByClientKey: the exact-masked hit path and the legacy
  prefix-regex fallback, asserting the revoked-key exclusion is applied on
  both lookups. This is the behaviour lock for the upcoming regex-escape
  fix: it captures how a legitimate key resolves today.
- generateApiKey: the raw key is returned once, and only a masked value
  plus a bcrypt hash of the key are persisted (never the raw key).
- changePassword: a wrong old password is rejected without saving; the
  success path replaces the stored hash.
- resetPassword: a missing or non-matching OTP is rejected without saving;
  the success path updates the password and closes the reset window.

Note (to be fixed under the refactor part, not here): changePassword
calls validatePassword without await, so the length rule does not
actually block a weak password on change. Left as-is in this behaviour
-lock commit and tracked for a guarded fix.

16 tests, all passing against current code.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 18:24:31 +03:00
isra elandClaude Fable 5 68e72d8bdc test(api): lock the three authorization guards
The guards that prevent cross-tenant access had zero tests, which is the
highest-risk gap in the backend: a silent regression here would let one
account reach another account's devices or api keys.

These specs pin the current authz contract without changing any code:

- AuthGuard: valid bearer resolves the user; invalid/expired bearer is
  401; a valid x-api-key resolves via findActiveApiKeyByClientKey plus a
  matching bcrypt hash and attaches request.apiKey; a non-matching hash,
  an unknown key, and no credentials are all rejected; a token that
  resolves an id for a user that no longer exists is 401.
- CanModifyDevice / CanModifyApiKey: owner passes; non-owner is rejected
  (the cross-tenant case); admin passes regardless; an invalid ObjectId
  is 400 before any lookup; a missing record is rejected.

Guards are plain constructor-injected classes, so they are instantiated
directly with mocked services and a minimal ExecutionContext, which is
lighter than a testing module and equally faithful.

17 tests, all passing against the current unchanged guards.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 18:21:38 +03:00
isra elandClaude Fable 5 5f16ac8883 fix: show auth errors instead of silently reloading
Login showed no message on wrong credentials and just reloaded. The
handler called signIn with redirect:true, which makes next-auth navigate
on both success and failure, so the browser reloaded to /login?error=
and the `if (result?.error)` branch never ran: the error handling was
dead code. Switched to redirect:false, so the result is read here, the
message renders, and success navigates via router.push + refresh.

The password-reset (OTP) form had two related bugs, both found while
verifying the "check forgot-password too" ask:
- It set form error under `root.serverError` but the JSX rendered
  `errors.root.message`, which a nested key leaves undefined, so a failed
  reset showed an empty paragraph and no text.
- The catch swallowed the error, so react-hook-form's isSubmitSuccessful
  stayed true and the "Password reset successful" alert rendered even on
  failure. The success alert is now gated on the absence of a root error,
  and errors are cleared at the start of each submit.

register-form was already correct (redirect:false); added a regression
test so the login bug cannot reappear there unnoticed.

Tests: component specs for all three forms under vitest + RTL, with
useTurnstile and the network boundary mocked. Each new failing case was
confirmed to fail against the pre-change component before the fix. The
login signIn mock reflects real redirect semantics (redirect:true yields
no readable result), so the "shows an error" test is a faithful guard.

Verified: typecheck clean, build clean, 0 lint errors (20 warnings,
unchanged), 164 unit tests (from 155).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 18:17:47 +03:00
vernu c8358de156 Merge pull request #241 from vernu/feat/web-refactor-ui
Dashboard refactor: UI rebuild, typed data layer, strict TS, CI test gate
2026-07-19 09:49:15 +03:00
isra elandClaude Opus 4.8 670d0819b8 chore: drop an over-specific test comment
The comment named endpoints and described prior behaviour, which is more
detail than a public repo needs. The assertions are unchanged and still
enforce the same guarantee.

The remaining comments in this area state forward-looking rules ("never
return this to a client") rather than describing what used to happen,
which is what a contributor actually needs.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-19 09:46:53 +03:00
isra elandClaude Opus 4.8 dcb12c6b59 chore: delete two unused components
black-friday-modal and community-alert were imported nowhere. Both were
left in place during the feature-folder reorg rather than deleted
unsupervised; removing them now on request.

Confirmed unreferenced by name and by path across app, components, lib,
e2e and test before removal. Both were self-contained presentational
components with no side effects, so nothing else changes.

Verified: typecheck clean, build clean, 0 lint errors (20 warnings, down
from 21 since one of the deleted files carried one), 155 unit tests, 78
e2e.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-19 09:35:10 +03:00
isra elandClaude Opus 4.8 bd808333b3 refactor: split bulk-send into a state hook and per-step components
index.tsx was 732 lines, the largest file in the app by a wide margin,
holding all wizard state, the CSV parsing callbacks, the send mutation
and four steps of JSX in one component.

State and behaviour move to use-bulk-send, matching the useOnboarding
pattern the get-started folder already uses. The four steps and the
success panel become presentation components. index.tsx is now 23 lines
of composition.

Each step destructures what it needs from the hook result at the top,
which leaves the moved JSX byte-identical to what it replaced. That was
a deliberate second attempt: the first pass rewrote every reference to
bulk.<name> inline, which corrupted a string literal (an input id became
"message-bulk.template") and produced 22 false "cannot access refs
during render" warnings in the one step that receives the ref. Warnings
are back to the 21-warning baseline.

Verified: typecheck clean, build clean, 0 lint errors, 155 unit tests,
78 e2e. The seven bulk-send e2e tests walk upload, mapping, compose and
a full send, which is the evidence that behaviour did not move with the
code. No test needed editing.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-19 09:16:05 +03:00
isra elandClaude Opus 4.8 9c0826016a refactor: group dashboard components into feature folders
The (components) directory held 28 loose files alongside six feature
folders, so the convention existed but was only half applied. Every new
feature added to the pile rather than to a place.

Grouped into account, billing, devices, api-keys, alerts, search and
community. Only nav-items and overview stay at the root, since both
compose across features rather than belonging to one.

Also merged the duplicate webhooks component trees. Components lived in
both dashboard/(components)/webhooks and dashboard/webhooks/(components),
and the second tree was reached by a cross-tree import from
webhooks-history. All three of its files are delivery-history specific
and had no other consumer, so they moved into webhooks-history and that
tree is gone. webhook-table also became deliveries-table, matching what
it renders.

Moves only, no logic changes: git records all 25 as renames, and no test
needed editing, which is the check that behaviour did not move with them.

Two files turned out to be dead code and are deliberately left in place
rather than deleted: black-friday-modal (seasonal, clearly parked) and
community-alert. Nothing imports either.

Verified: typecheck clean, build clean, 0 lint errors (21 warnings,
unchanged), 155 unit tests, 78 e2e.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-19 09:08:17 +03:00
isra elandClaude Opus 4.8 82973444c2 refactor: move mutations behind the shared data hooks
Ten components hand-rolled useMutation against httpBrowserClient, each
repeating its own endpoint, error shape and cache invalidations. The four
webhook mutations wrote the same ['webhooks'] key four times, and the two
API-key generators wrote the same three invalidations twice, which is how
one of them ended up with the ['apiKeys', 'stats'] key that matched
nothing.

Invalidations now live once beside each hook, so a component cannot
forget one or spell it differently. Component-specific behaviour (toasts,
dialog closing, form resets) stays at the call site via per-call
mutate(vars, { onSuccess, onError }) callbacks.

For the two hooks that take options, the caller's onSuccess is composed
with the hook's rather than spread over it, so passing a callback cannot
silently drop the invalidation the hook exists to guarantee.

Also normalised useRevokeApiKey, useDeleteApiKey and useRenameApiKey onto
queryKeys.apiKeysAll. They previously invalidated either a bare
['apiKeys'] literal or only the 'active' list, so revoking a key left the
revoked and all lists stale.

Two behaviour details preserved deliberately: the inline register panel's
failure toast, which moved to the call site rather than being dropped,
and webhook-card's toggle, which was a raw try/catch with its own loading
state and is now the shared mutation's isPending.

The typed generate hook surfaced that the copy-key handler could pass
undefined to clipboard.writeText, now guarded.

Verified: typecheck clean, build clean, 0 lint errors (21 warnings,
unchanged), 155 unit tests, 78 e2e including the webhook create, edit,
delete and toggle paths.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-19 04:56:13 +03:00
isra elandClaude Opus 4.8 290149c8fb fix(security): stop sending the password hash to the browser
The user schema had no projection on `password` and no select: false, so
every read of a user document loaded the bcrypt hash. Three paths then
sent it to the client:

- GET /auth/who-am-i returns req.user, which the auth guard populates
  with an unprojected usersService.findOne. Every session check.
- The login response returns the whole user document.
- The register response returns the freshly created document.

The hash is bcrypt, so it is not directly usable, and this is not a
plaintext leak. It still matters: it hands an attacker unlimited offline
guessing with no rate limit, no lockout and no audit trail, against
whatever password strength the user chose. It also lands anywhere a
response goes, which for this app includes Microsoft Clarity session
recording, plus devtools, browser extensions, error reporting and any
XSS. Password reuse makes a cracked hash a problem beyond this app.

Fixed in two layers. The schema marks the hash select: false, so nothing
loads it by default and any future read is safe by construction. The two
flows that genuinely need it, login and change-password, ask for it
explicitly via usersService.findOneWithPassword.

select: false does not cover a document held in memory, so the login and
register responses also strip it through withoutPassword before
returning. Those were the two paths that had it loaded on purpose or had
just written it.

The schema guard was confirmed to fail against the pre-change schema
before being trusted.

Verified: 61 API tests pass (from 53). Typecheck introduces no new
errors (2 pre-existing before this change, 1 after).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-19 04:50:06 +03:00
isra elandClaude Opus 4.8 06295ae7f1 refactor: read the shared data hooks instead of refetching by hand
Nine components each hand-rolled a useQuery against endpoints the typed
hooks in lib/api already cover: the current user in seven places, the
subscription in four, the device list in two, and stats in one. Each
copy repeated the endpoint, the envelope unwrapping and the cache key,
which is where both of the invalidation bugs fixed in c1942a4 came from.

They now call useCurrentUser, useSubscription, useDevices and
useGatewayStats. The polling behaviour in use-onboarding is preserved by
passing refetchInterval through the hooks' options, with the difference
that its polling now refreshes the same cache entries the rest of the
dashboard reads rather than maintaining a parallel copy of them.

update-app-modal and update-app-notification-bar previously destructured
the raw { data } envelope; useDevices unwraps it, so those consumers
read the array directly now.

Added createdAt and onboarding to the User type. Both are real fields
that components were already reading, and adding them lets the shared
hook feed the onboarding checklist, which had been the reason for a
separate query in the first place.

Verified: typecheck clean, build clean, 0 lint errors (21 warnings,
unchanged), 155 unit tests, 78 e2e.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-19 04:31:07 +03:00
isra elandClaude Opus 4.8 5bae4ffd9a ci: run the web test suites instead of only building
The web job ran `pnpm install && pnpm run build` and nothing else, while
the API job right above it ran its tests. So the 155 unit tests and 78
e2e tests built up over previous rounds had never gated a merge, and a
regression in either would have reached main unremarked.

Lint, typecheck and unit tests now block. All three were confirmed to
exit 0 against the current tree first, so this does not wedge the merge
queue on day one. Typecheck earns its place here: next build does not
check test files, so type errors in them were invisible.

E2e runs but is non-blocking for now. It is fully mocked and never
contacts a backend, but it drives a real browser, so it reports for a
few merges before it is made blocking. The continue-on-error carries a
TODO saying so, so it does not quietly become permanent.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-19 04:21:01 +03:00
isra elandClaude Opus 4.8 3e4597294c refactor: turn on strict TypeScript and fix the 63 errors it found
My earlier estimate of 34 errors was wrong, and the reason is worth
recording: tsconfig carried "strictNullChecks": false as a duplicate key
AFTER the strict flag, and an explicit option beats the strict umbrella
even when --strict is passed on the CLI. So the measurement that produced
34 had silently excluded every null-safety error. The real number was 63.
Both duplicate keys are gone; strict: true now stands alone.

Most were implicit-any, but strict caught several genuine type lies:

- SendSmsPayload.deviceId and WebhookData._id were optional while both
  are interpolated into request paths, so an absent value would have hit
  /gateway/devices/undefined/send-sms or PATCH /webhooks/undefined.
- webhook-table's deviceName was typed string while buildDeviceLabel
  returns string | string[] and the Device cell already renders the
  array case. The type never described what the code produced.
- webhooks-section read `webhooks?.data?.length > 0`, comparing
  undefined against 0 while the query was still in flight.
- app-header declared a non-null Session while its own body guarded with
  session?.user throughout. Making the type honest surfaced four
  genuinely unguarded accesses.
- api-keys kept a local ApiKeyRow duplicating the shared ApiKey type, so
  the list callback annotated rows as one type while the hook returned
  the other. ApiKeyRow is now an alias and the two extra fields moved
  onto ApiKey.
- The notifications envelope typed its rows as unknown[], so the
  deliveries table's row type went entirely unchecked. Now a real
  WebhookNotification type.

The react-hook-form cluster (20 of the 63) was one root cause: zod's
.default() makes the input and output types differ, so z.infer (the
output) is not what the resolver takes. Fixed by typing the forms with
z.input and z.output separately, which changes nothing at runtime.

Also bumped target es5 to ES2017, which fixes the Set-iteration error
that made tsc --noEmit fail before any of this. Next compiles browser
output via SWC and its own browserslist, so bundle targeting is
unaffected. Added @types/papaparse and @types/react-syntax-highlighter,
and a typecheck script, since next build does not check test files.

No @ts-expect-error and no new any were used.

Verified: typecheck clean, build clean, 0 lint errors (21 warnings,
unchanged), 155 unit tests, 78 e2e.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-19 04:19:35 +03:00
isra elandClaude Opus 4.8 c1942a4ba3 fix: repair two cache invalidations that silently matched nothing
Both bugs were hand-written query keys. lib/api/query-keys.ts exists to
prevent exactly this and neither site used it.

1. /auth/who-am-i was cached under two keys. Four call sites used
   ['whoAmI'] and seven used ['currentUser'], including the typed
   useCurrentUser hook. So the endpoint was fetched twice on any page
   using both, and invalidating one never invalidated the other:
   edit-profile-form refreshed ['currentUser'] and left the email
   verification banner reading stale data under ['whoAmI'], while
   use-onboarding had the mirror-image bug.

   verify-email/page.tsx additionally cached the whole axios response
   where the others cached the unwrapped user, so unifying the key meant
   normalising that shape too. One key demands one shape.

2. generate-api-key invalidated ['apiKeys', 'stats'], which no query
   uses. react-query matches by prefix, so it matched neither the key
   list (['apiKeys', status]) nor the dashboard stats (['stats']).
   Generating an API key refreshed nothing at all. It is now three
   invalidations against queryKeys, using a new apiKeysAll prefix so
   every status filter refreshes rather than just 'active'.

Also added accountDeletionRequestedAt to the User type. It is a real
field on the user schema and the deletion banner already reads it; only
the type did not know about it.

Both bugs are invisible on inspection, which is how they shipped, so the
regression tests were each confirmed to fail against the pre-change code
before being trusted.

Verified: build clean, 0 lint errors (21 warnings, unchanged), 155 unit
tests (from 153), 78 e2e.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-19 04:02:30 +03:00
isra elandClaude Opus 4.8 366a47913b feat: redesign the billing page and stop calling free users "Unknown"
/billing/current-subscription answers in two shapes. A subscriber gets the
Subscription document, which always carries a status because the schema
requires one. A user with no subscription gets a synthesised
{ plan, isActive, usage } with no status, no amount and no dates.

The page read that second shape as a subscription with missing fields, so
every free user was told their subscription status was "Unknown" and shown
two "N/A" billing dates. Absence of a status is not an unknown status.

lib/billing.ts interprets the payload instead of guessing at it. A paid plan
arriving without a status is still genuinely unknown and is still reported
that way, so the earlier fix for fabricated "Active" is preserved.

The redesign splits one flat card into plan identity and usage. The old
inner panels were bg-card with shadow-sm sitting on a bg-card parent, so the
nesting was invisible; they are bordered muted panels now. Free accounts
drop the status pill, the always-N/A dates and the portal link they have
nothing to manage with.

CTAs are Buttons rather than hand-rolled Links with background utilities,
and the upgrade target comes from position on the plan ladder, so Scale
subscribers are no longer sold a tier above Scale and bespoke plans are not
pushed onto the self-serve ladder at all. Added a "Compare all plans" link
to textbee.dev/pricing, matching the label the onboarding plan picker
already uses for the same destination.

Also corrected the Pro fixture from 1900 to 999 cents, a price we do not
charge, and gave the portal link noopener noreferrer.

Verified: build clean, 0 lint errors (21 warnings, unchanged), 153 unit
tests (from 121), 78 e2e (from 75). The three free-account guards and the
pricing link guard were each confirmed to fail against the pre-change
component before being trusted.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-19 03:23:00 +03:00
isra elandClaude Opus 4.8 469aa0025c fix: regrade the logo to the brand orange and crop its dead space
Copying the marketing logo did not fix the colour, because the marketing
artwork does not match --primary either. Measured, the mark held two
orange families: one already near brand at hsl(19,93%,52%) and an amber
cluster at hsl(28,98%,48%). The amber is what read as the old colour.

Regraded only the saturated orange fill onto hsl(21,90%,48%), preserving
shading, and left the tan body and dark figure alone. The rendered mark
now samples at hsl(22,89%,48%) against the text-primary "bee" beside it
at hsl(21,90%,48%).

The bigger problem was not colour. The source was a 500x500 canvas whose
artwork occupied a 387x319 box and only 24.7% of the pixels, drawn at
24px in the header. Roughly three quarters of that box rendered pure
white, so the mark was a faint smudge and whatever colour it carried was
barely visible. Cropped to the content on a square canvas.

The favicon carries the same regrade across all three of its frames.

Also fixes a latent flake in the RelativeTime component tests. They fed
the component dates derived from a hardcoded NOW while the component
reads the real clock, so "7 days ago" only held on 2026-07-18 and broke
the moment the date rolled over.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-19 03:07:42 +03:00
isra elandClaude Opus 4.8 5d43866847 fix: show every plan in onboarding and let finished steps be reopened
The plan step hardcoded Free and Pro inline, so Scale never appeared no
matter what the pricing page offered. Tiers now come from lib/plans,
which mirrors the marketing pricing section, with a test pinning the
values so the two cannot drift silently.

Kept static rather than fetched from /billing/plans. That endpoint
returns Plan documents (limits and cents), not customer-facing copy, and
an environment with no plans rows left the step showing "plans could not
be loaded" with nothing to choose.

Reopening a completed step showed an empty row. Two things caused it:
the body was gated on the step not being done, and an effect cleared the
selection whenever the selected step was done, so clicking a finished
step deselected it on the next render. That effect exists to advance you
when the step you are sitting on completes underneath you, so it now
fires only on that transition. Both paths are covered: reopening the API
key step offers "Generate another API key", and a step completing while
selected still moves the selection on.

Skip is hidden on an already-finished step, where it would mean nothing.

Also corrects the Plan type, which declared amount, currency and
recurringInterval. The plans endpoint has never sent those; they belong
to Subscription. Anything reading plan.amount saw undefined and rendered
a paid plan as free. The fixture encoded the same wrong shape, so tests
would have passed while production broke.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-19 02:48:12 +03:00
isra elandClaude Opus 4.8 e75dbc7a67 feat: compact the API key rows and align the logo with the brand orange
Each key rendered a Card stripped of its border and shadow inside the
outer Card, buying markup and padding for no visual result. It is now a
plain row: the key glyph drops from 24px to 16px and reads as muted
rather than competing with the key name, the tinted code chip hugs the
key instead of stretching into a full-width bar, and the timestamps
share one line.

Removed the per-row "Active" badge. This list only ever contains active
keys, revoked ones live behind their own dialog, so the badge appeared
on every row and distinguished nothing. Say if you would rather keep it.

The loading skeleton now mirrors the loaded row, so the list no longer
resizes as it settles.

The logo and favicon were still the older lighter amber artwork while
the marketing site had been regraded to the deeper brand orange that
--primary already uses here. Same artwork, so both files are copied
across rather than regenerated, which keeps the two properties
byte-identical instead of merely similar.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-19 02:31:36 +03:00
isra elandClaude Opus 4.8 b2acba4c2c fix: tidy the community page and close mobile layout gaps
community-links was the only file in the app still wrapping a Button in
a Link, four times, producing an anchor around a button: invalid markup
and a nested interactive control that assistive tech announces twice.
Everywhere else already uses Button asChild. Two whole Card blocks sat
commented out, with an icon imported solely for that dead code.

community/page.tsx was the only dashboard section that never got the
mobile pass, keeping p-6 with no p-4 step and an unconditional text-3xl.
It was also the only section missing from the 375px overflow guard,
which is presumably how it was missed. Extracting the shared PageHeader,
which the messaging, webhooks and account layouts all repeated by hand,
fixes that outlier by construction.

Both billing limit grids and the promo modal were locked to two columns
at every width, so the meter captions had no room on a phone.

window.open kept a live opener handle back to the app in four places.
Browsers imply noopener for anchor targets but not for window.open.

The share dialog grid moves to 3 columns then 7. Worth stating plainly:
this is not an overflow fix. I expected 7 icons at grid-cols-4 to
overflow at 375px and the extended guard proved they do not. It was
only an awkward 4 + 3 split.

The overflow guard now covers 12 routes instead of 6 and opens a dialog,
since the densest layouts in the app only exist inside modals.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-19 02:20:39 +03:00
isra elandClaude Opus 4.8 2b766e8019 fix: drop the phantom device status and share one error state
The Device type declared a `status` field that the schema does not have
and the API never sends, so `device.status === 'online'` was always
false. The badge took its colour from that phantom field and its text
from `enabled`, so an enabled, working device was styled exactly like a
disabled one. Both now come from `enabled`.

Devices, API keys and webhooks each rendered a bare unstyled
"Error: {error.message}" with no way to recover, putting raw transport
strings like "Request failed with status code 500" in front of users.
formatError already handles axios rejections and rate limits and was
used by none of them.

Adds components/shared/error-state.tsx as the counterpart to the
existing EmptyState, routing all three through formatError and offering
a retry.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-19 02:10:33 +03:00
isra elandClaude Opus 4.8 022a6bd2a6 fix: repair form labelling and navigation landmarks
None of the three password fields declared autoComplete, so password
managers could not tell them apart and would fill the saved password
into the wrong box. They now declare current-password and new-password.

The delete-account dialog, the most destructive action in the app, had
a label whose htmlFor pointed at an id the textarea never had, and an
email confirmation field with no label at all. Both are now wired, and
the controls moved out of DialogDescription, which Radix targets with
aria-describedby: opening the dialog previously read the labels and
placeholders back as one long description string.

Validation errors are now tied to their inputs via aria-describedby and
announced with role=alert instead of appearing silently.

confirmPassword required 4 characters while newPassword required 8, so
a five-character confirmation reported "Passwords must match" rather
than the actual length problem.

Five icon-only buttons had no accessible name: copy API key, copy
device ID, the API key menu, and both promo code copy buttons.

Four of the five nav landmarks were unlabelled, leaving them
indistinguishable in a screen reader's landmark list. Added a
skip-to-content link, since keyboard users otherwise tab the whole
sidebar on every page.

The delete-account label guard was confirmed to fail against the
previous markup.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-19 02:00:11 +03:00
isra elandClaude Opus 4.8 ad7bcd99a5 fix: stop the billing page asserting things it does not know
The status pill hardcoded a check mark and varied only its colour, so a
past_due or canceled subscriber was shown a tick beside the bad news.
It also fell back to the label "Active" when the payload carried no
status at all, inventing a healthy state on a billing screen. The icon
now comes from the status, and an absent status reads "Unknown".

formatPrice returned "Free" whenever the currency was missing, even
with a real amount. Since the badge only renders when amount > 0, the
one case it could produce was a paying customer being shown
"Free / monthly". Default the currency instead.

The page also derived its usage numbers inline instead of calling
deriveUsage, which exists precisely so billing and the dashboard cannot
disagree. The inline copy skipped the helper's 0-100 clamp, so the
invariant was already broken.

Monthly is a rolling 30-day window server-side, not a calendar month,
which the dashboard already labels correctly. Billing said "this
month", so a user capping out late in the month would wait for a reset
that never arrives.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-19 01:50:30 +03:00
isra elandClaude Opus 4.8 f65af5003b fix: give every webhook its own signing secret
The create dialog is mounted for the whole session, so its
defaultValues, and the uuid inside them, were evaluated exactly once.
A bare form.reset() then restored that same object, so every webhook
created without reloading the page shared one signing secret.

The secret is what proves a delivery genuinely came from us, so sharing
it across endpoints means compromising any one of them compromises the
rest.

Build the defaults fresh on each use, and reset with a new secret both
when the dialog opens and after a successful create.

The e2e guard was confirmed to fail against the previous code, with
both creates submitting an identical secret.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-19 01:42:17 +03:00
isra elandClaude Opus 4.8 6da80ac4e7 fix: correct five CSV handling defects in bulk send
The bulk send rebuild shipped several parsing bugs that silently
produced wrong results rather than failing loudly.

Columns were read from the first parsed row instead of the header.
papaparse only assigns keys for values actually present in a row, so a
short first row hid every column after it: the phone column could not
be selected and the file was unusable. Read results.meta.fields.

Row-level parse errors were discarded. Only fatal stream failures were
reported, so a mis-delimited file parsed into one mangled column and
looked fine. Surface them as a warning.

The plan row cap was checked once at drop time and skipped entirely
while the subscription was still loading, so an over-cap file passed on
a limited plan and nothing revalidated it. The cap is now derived, so
it applies whenever the limit resolves.

Rejected files returned silently, leaving a dropped .xlsx with no
feedback. Report the rejection.

The preview index was left pointing into the old recipient list when
the phone column changed, hiding the preview and disabling its own Next
button. It is now clamped and reset.

Also count segments from the rendered message rather than falling back
to the raw template, which counted the placeholder literals.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-19 01:37:50 +03:00
isra elandClaude Fable 5 05143b8752 fix: give the dashboard Add device button the guided flow
The header quick action opened the API key modal directly, so a first-time
user who clicked "Add device" was handed a generated key with no mention of
installing the app or granting SMS permissions. The Registered Devices card
already had the right flow: explain the steps, then Continue into the key and
QR code.

That flow lived as local state inside device-list, so it could not be reused.
Extracted to add-device-dialog with the same imperative open() handle the
existing GenerateApiKey uses, and wired into both entry points.

"New API key" deliberately still goes straight to key generation. That button
asks for a key, so the device prerequisites would be noise there. The two
actions now do genuinely different things instead of both opening the same
modal under different labels.

E2e covers both entry points reaching the instructions first and Continue
still reaching key generation, plus New API key skipping the instructions.
Verified the guard fails against the old wiring rather than assuming it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 00:49:19 +03:00
isra elandClaude Fable 5 1349134107 fix: give every messaging tab the same content column
Switching tabs shifted the layout. The cause was broader than History being
full width: all four subroutes set their own width, so nothing lined up.

  Send    max-w-xl   576px
  Bulk    max-w-3xl  768px
  API     max-w-4xl  896px
  History none       full bleed

The header and tab strip were also full width while the view beneath them was
constrained, so on Send the tabs ran roughly 200px past the form.

The column is now defined once in the messaging layout and wraps the header,
the tabs and the children together. The per-page wrappers are gone, so a new
subroute inherits the width instead of inventing one.

Guard: an e2e visits all four tabs, measures the view rendered beneath the tab
strip, and asserts the widths match and that the column is constrained rather
than stretched.

Getting that guard to actually work took three attempts, each a real hole:
- Comparing the column against the viewport passed even with the constraint
  removed, because the sidebar and padding already made it narrower.
- Comparing against the parent's border box passed too, since that box
  includes the padding the column sits inside. It now subtracts the padding.
- Measuring the layout column could not detect a page re-constraining itself,
  because that column is identical on every tab by construction. It now
  measures the rendered view instead.
Verified by reintroducing both regressions separately and watching it fail
each one, with the offending tab named in the message.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 00:34:46 +03:00
isra elandClaude Fable 5 628e5b09d1 feat: rework the SMS detail modal around chips and per-field copy
The bottom of the modal was a label/value table, and most of it repeated the
header: Direction restated the To/From already shown with its arrow icon, and
Number restated the number in the title. What remained was two facts wrapped
in table furniture.

Those facts are now inline chips: status, device, and the gateway ID in a
mono chip. The redundant rows are gone.

Copy moved from a single footer button to one button per field, next to the
thing it copies: the number in the header, the message body, and the gateway
ID. A lone "Copy text" button left the target to be inferred, and it could
only ever copy one of the three.

Reused the existing shared CopyButton rather than adding another, which also
brought its copied-state feedback and toast. It had no accessible name
though, so every icon-only copy button was announced identically. It now
names itself after what it copies, which is what lets the new test address
them individually.

Also overrode the dialog primitive's mobile text centring in this header: it
left the title on the left and the timestamp centred beneath it.

E2e reads the clipboard after each button and asserts all three return their
own value.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 00:17:16 +03:00
isra elandClaude Fable 5 1999593e07 fix: day header overlap, mobile footer alignment, modal animation
Day headers in message history could cover message rows. Two distinct causes,
both reproduced before fixing:
- On mobile the header was pinned to top-14, the same sticky band the mobile
  search bar already occupies, so it detached and landed on top of rows.
  Being bg-muted/70 with a backdrop blur, row text bled through it, which is
  the distortion that was reported.
- On desktop it was pinned to top-0, behind the app header, and it covered
  rows scrolled beneath it and swallowed their clicks. Playwright surfaced
  this second one: a row click failed with "h3 intercepts pointer events".

Day headers are no longer sticky. A page holds 20 messages, so groups are
short and pinning bought little in exchange for those failures.

Footer links are left-aligned on mobile. Centred links in a single column had
no common edge to scan down.

Modal animation is now a quick fade. Dialogs slid in from the left and top
while zooming, which is the movement that read badly; the slide and zoom are
gone and the duration drops from 200ms to 150ms, applied to both the dialog
and alert-dialog primitives so every modal matches. Sheets still slide, since
that is what a drawer should do.

Guards: the overlap check runs at both mobile and desktop widths and also
asserts a row is still clickable after scrolling. Verified it fails against
the old markup rather than assuming a green test means coverage.

Fixture dates now anchor to local midnight instead of "N hours ago". A
2-hour-old message falls on the previous day when the suite runs shortly
after midnight, which made the Today/Yesterday assertions depend on the wall
clock. It failed exactly that way during this session.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 00:07:29 +03:00
isra elandClaude Fable 5 9913b7cbe7 feat: rebuild the send page with recipient chips and segment counting
Brings the send screen up to the standard of the rebuilt bulk send flow.

Fixes real defects:
- The single-device auto-select never worked. It was computed in
  defaultValues from devices?.data?.length === 1, but react-hook-form reads
  defaultValues once on mount, while the devices query is still pending, so
  the condition was always false. It now preselects once devices resolve.
- Submitting before devices loaded showed "Required" on the device field,
  which then auto-filled a moment later, leaving a stale error beside a
  populated field. Submit is disabled until devices load.
- Moved off the legacy raw useQuery(['devices']) plus devices.data, the shared
  cache key with a different unwrapped shape that caused the devices?.filter
  crash previously.
- Every field used a placeholder as its label. Placeholders vanish on focus
  and are not reliably announced, so all three now have real labels.

Recipients are chips instead of a growing stack of inputs. They commit on
Enter, comma or blur, so a number typed and left uncommitted is not silently
dropped at send time. Pasting a list adds several at once.

Splitting a pasted list needed care, and an e2e caught it: splitting on
whitespace shredded "+1 (415) 555-0101" into three fragments. Only unambiguous
separators split first; whitespace splitting is a fallback for tokens that
cannot be a single number.

Also adds a segment counter, inline alerts for success and failure instead of
bare text, and a form reset after sending that keeps the device selected.

Phone helpers moved to lib/sms.ts alongside the segment helpers, so the send
page and bulk send share one tested implementation.

One test correction: the desktop footer assertion required links to sit on
exactly one row, but the row layout wraps by design, so it failed whenever
font metrics pushed a link to a second line. It now asserts the property the
design actually guarantees, that links are not one-per-row.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 22:04:35 +03:00
isra elandClaude Fable 5 8c818351d6 feat: redesign message history around a scannable list
The screen opened with a tall filter card, so messages started below the fold,
and each message was a bordered card capped at max-w-sm, fitting three or four
per mobile screen.

List:
- Messages are now rows grouped under day headings (Today, Yesterday, then the
  date). Roughly triples what fits on a phone, and scanning by time is how
  people actually look for a message.
- Rows are buttons, so the list is keyboard navigable, with relative times
  that reveal the exact timestamp on hover.
- Direction now comes from the `type` the API returns. It was inferred from
  whether `sender` was present, which is only a proxy; the old check survives
  as a fallback for rows written before `type` existed.
- Pagination renders only when there is more than one page.
- A search that matches nothing is a different state from a device with no
  messages, and offers a way back.

Filter bar collapsed into one compact row: search, device, refresh, and auto
refresh moved into a dropdown instead of four inline buttons occupying a whole
row for a rarely-changed setting.

Details dialog reordered around why it is opened: the message body first, then
metadata, then errors, then actions. The exact timestamp is text here because
the list tooltip is hover-only and unreachable on touch.

Composer dialog gains real field labels (it used placeholders as labels, which
vanish on focus and are not reliably announced) and the same segment counter
bulk send has.

Two fixes found along the way:
- Reply passed message.device?._id with no fallback. The endpoint does
  populate device, but if it were ever absent the composer would open with no
  device and could not send. It now falls back to the device being viewed.
- The device-defaulting effect became derived state, so the list no longer
  renders once with no device selected before correcting itself.

Segment helpers moved to lib/sms.ts now that the send page, bulk send and the
composer all use them. Fixtures now carry the populated `device` and relative
dates, so the mocked path matches production and exercises day grouping.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 21:46:27 +03:00
isra elandClaude Fable 5 4cdcce6324 feat(api): search message history by body, recipient or sender
Message history had no way to find an old message. The endpoint only accepted
page, limit and type, so a client-side search box could only ever filter the
20 rows already loaded, which is more misleading than having no search.

getMessages takes an optional `search` and matches it against the message
body, recipient and sender. It composes with the existing type filter.

The term is escaped before it reaches RegExp. This is not theoretical: an
unescaped "(" throws a SyntaxError and fails the request, "." would match any
character instead of a dot, and a nested quantifier is a ReDoS vector. The
escaping is a separate, directly tested unit.

Note on performance: the existing { device, type, receivedAt } index does not
serve a regex $or, so a search is a scan within one device's messages. That is
fine at current per-device volumes; a text index is the follow-up if it stops
being fine.

Frontend: useDeviceMessages takes `search`, joins it to the query key so each
term caches separately, and builds the query with URLSearchParams so terms
containing & or = are encoded rather than corrupting the URL.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 21:31:32 +03:00
isra elandClaude Fable 5 ffbbabdbd3 fix: stack footer links vertically on mobile
Wrapped inline links produced a ragged two-line block with small tap targets.
Links now stack in a single column below sm and return to one row above it.

E2e asserts both directions: every link has a distinct y at 375px, and they
share a row at 1280px.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 21:27:21 +03:00
isra elandClaude Fable 5 9cb1213efc feat: show relative timestamps with the exact time on hover
Device and API key cards rendered full timestamps ("Registered at: May 18,
2023, 1:42 PM"), which wrapped and dominated the cards at 375px.

New shared RelativeTime renders "7 days ago" with the exact date and time in a
tooltip. Formatting is delegated to date-fns, already a dependency and already
used by the webhook table, so no new package and no hand-rolled date parsing.
The only local rule is a "Just now" threshold under one minute.

Details:
- formatDistanceToNowStrict, not formatDistanceToNow, so it reads "3 minutes
  ago" rather than "about 3 minutes ago".
- Renders a real <time dateTime> element, keeping the machine-readable value
  in the DOM.
- The trigger is focusable, so the exact time is reachable by keyboard and not
  hover-only.
- Invalid or missing values render a caller-supplied fallback, so a key that
  has never been used still reads "Last used never" instead of "Invalid Date".

Labels shortened to match: "Registered at:" to "Registered", "Created at:" to
"Created".

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 21:27:21 +03:00
isra elandClaude Fable 5 1979e2f0b5 feat: drop recent activity, correct usage wording, rebuild the API guide
Dashboard:
- Removed the recent activity section.
- Corrected the usage card wording. The number was labelled "sent", but
  getCurrentSubscription counts SMS documents with no type filter, and the
  same model stores both SMSType.SENT and SMSType.RECEIVED. Inbound messages
  consume the quota too, so the cards now say "messages" and carry an explicit
  note that sends and receipts both count against the limit.
- "This month" is now "Last 30 days". The backend counts from setMonth(-1), a
  rolling window, not the calendar month.

Devices:
- Removed the battery and signal indicators. Neither the device schema nor the
  Android app reports either value, so they only ever rendered "unknown" and
  "-" beside an icon that implied real telemetry. Also dropped the phantom
  batteryLevel field from the Device type so nothing is built on it later.

API guide, rebuilt:
- It rendered a Collapsible that defaulted to closed. Once it moved to its own
  route the page looked empty until you found the chevron. It is a page now,
  so nothing is collapsed.
- Samples are generated from the user's real device id, with a device picker,
  so they run after pasting one key. Every sample previously said
  YOUR_DEVICE_ID, so nothing on the page was runnable as-is.
- Code blocks follow the theme. The old ones hardcoded a slate-800 background,
  so they were the only dark element on a light page.
- Languages went from 3 to 5 (cURL, Node, Python, PHP, Go), picked once and
  applied to every endpoint, replacing a hand-rolled inline Python SVG icon.
- Endpoints now cover bulk send and received messages, not just send and two
  lookups. Response examples use the real lowercase status values from the SMS
  schema instead of invented uppercase ones, and the batch example is no
  longer invalid JSON (it had a trailing comma).
- Keys the copy state per block rather than by index arithmetic over
  Object.entries order.

The mount guard usually needed for next-themes is not used in CodeBlock:
resolvedTheme is undefined on the server and first client render, so both
produce the light style. Verified with a console sweep across nine pages in
both colour schemes, zero messages.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 21:07:38 +03:00
isra elandClaude Fable 5 99a3a79b3b feat: rebuild bulk send as a guided 4-step flow with validation
The old screen was one 425-line component with correctness bugs, not just a
weak layout.

Fixed defects:
- Empty recipients were sent. handleSendBulkSMS mapped every parsed row into
  the payload, so a blank phone cell produced a message addressed to "".
  There was no filtering, no phone validation and no dedupe, so the same
  number listed twice was texted twice.
- User-facing copy printed raw bytes: "max 1048576 bytes".
- The row limit could be wrong. maxRows fell back to 50 while the
  subscription query was still loading, so an unlimited-plan user could be
  told their file was too big. The cap is now only enforced once the real
  limit is known.
- Steps were gated with opacity-50 + pointer-events-none, which leaves
  controls in the tab order and unannounced. Locked steps now use `inert`.
- Data came from raw useQuery(['devices']) + devices?.data instead of the
  typed useDevices(), the same shared-cache-key shape hazard that caused the
  devices?.filter crash in round 1.

The new flow: upload (with parse summary and a preview table of the first 5
rows), map (auto-detected phone column plus live valid/invalid/duplicate
counts), compose (clickable variable chips, SMS segment counter, preview that
cycles through real recipients) and review (explicit count, an itemised list
of skipped rows and why, nothing sent until confirmed).

bulk-csv.ts holds the rules that decide who receives a message, as pure
functions with 24 unit tests. Those tests caught a real bug during
development: the short "to" hint substring-matched a column named "total",
which would have silently auto-selected the wrong column. Short hints are now
exact-match only.

Sample CSV at /samples/bulk-sms-sample.csv using 555 reserved-for-fiction US
and Canada numbers, so an unedited upload cannot text a real person, with an
order-confirmation template that demonstrates multi-variable interpolation.

E2e asserts the actual request payload contains exactly the two valid
recipients from a file seeded with a blank, an invalid and a duplicate row,
and that the parsed-CSV table does not reintroduce sideways scroll at 375px.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 20:42:55 +03:00
isra elandClaude Fable 5 a949729c07 feat: rebuild dashboard home around real usage and activity data
The home page was four all-time counters that could not answer either
question a user actually opens the dashboard for: how much quota is left, and
did my recent sends work.

Removed fabricated data:
- Every stat card rendered a green TrendingUp arrow whenever a value existed.
  Nothing computed a trend. The stats endpoint returns running totals with no
  time window, so there was nothing to compare against.
- "Since last year" on the sent and received counts. getStatsForUser sums
  device counters with no date filter, so these are all-time totals.
- "Active Devices" with a "Connected now" caption, over a number that counts
  every device including disabled ones. Enabled count is now derived from the
  device list we already fetch.
- "Active keys" over a count that included revoked keys; now uses the active
  API key list.

Added, all from fields the API already returns:
- Usage cards leading the page: today and this month against their limits,
  with progress bars, an amber near-limit state past 80% and an upgrade link.
  Unlimited plans (-1) show usage with no meter, since there is no ceiling to
  measure against.
- Recent activity: the last 5 messages for the connected device. Messages are
  only exposed per device, so the card names the device rather than implying
  account-wide coverage.
- All-time totals compacted from four large cards into one honest strip.

lib/usage.ts extracts the daily/monthly derivation that subscription-info
already did inline, so the dashboard and billing page cannot disagree about
remaining quota. Unit tested including the -1 sentinel, custom limit
overrides, and an over-100 percentage (possible when a limit is lowered
mid-period).

No backend changes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 20:32:59 +03:00
isra elandClaude Fable 5 02bd1d42e0 feat: make every page searchable with keyword matching
Search previously listed only the 5 sidebar routes plus Log out, so subroutes
like bulk send, message history, webhook deliveries and every account section
were unreachable by search. Typing "csv" or "invoice" or "password" returned
nothing.

New search-registry.ts is the single source of truth: every user-reachable
destination with a description and the words people actually type, rather than
the labels we happened to choose for the nav. cmdk matches those keywords in
addition to the label, so "csv" finds Bulk send and "invoice" finds Billing.
External resources (Android app, quick start, status, contribute) are included
and open in a new tab.

Mobile had no search at all: the trigger and the dialog were one component
living inside the `hidden md:flex` sidebar. Split them. The dialog now mounts
in the dashboard layout with open state lifted, the sidebar keeps its trigger,
and mobile gets a labelled sticky search bar. That matters most on mobile,
where the tab bar is capped at 4 items and search is the only path to Webhooks
and every subroute.

The palette also gains theme actions, since the theme control now lives in the
desktop-only sidebar.

search-registry.test.ts walks the App Router tree on disk and fails if any
page.tsx has no registry entry, naming the offending route. Redirect-only
routes are allowlisted. Verified it actually fails by removing an entry, rather
than assuming a green test means coverage. Plus e2e proving keyword search,
the mobile path to webhooks, and the keyboard shortcut.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 20:26:33 +03:00
isra elandClaude Fable 5 0ea85c05e0 feat: clean up app chrome, rebuild footer and fix sidebar overlap
Top bar (app-header):
- Removed the Contribute button. It stays reachable from the footer.
- Removed the theme selector; it moves to the sidebar footer.
- Removed the mobile sheet for signed-in users: it duplicated the bottom tab
  bar for navigation and the avatar menu for identity. Signed-out visitors
  keep it, since auth pages have no tab bar.
The header is now brand on the left, account on the right.

Footer:
- Rebuilt as a slim single bar. A logged-in user is already converted, so the
  app does not need the marketing site's multi-column link farm. It borrows
  that footer's visual language (muted surface, muted-to-foreground hovers,
  green status pill) so the two still read as one product.
- Fixed the "cut by the side nav" bug. The footer was rendered in
  (app)/layout.tsx as a sibling of <main>, so it spanned the full viewport
  while the dashboard's fixed sidebar painted over its left 240px, and the
  fixed mobile tab bar covered its bottom edge. It is now rendered per
  section, inside each content column, with the dashboard copy padded clear
  of the tab bar.

Theme control:
- Moved to the sidebar footer and restyled from a dropdown into a three-way
  Light/Dark/System segmented control, which now has room to show all three
  states at once.

Sidebar icon:
- Dashboard uses LayoutDashboard instead of Home, matching the icon the
  avatar menu already uses for the same destination.

E2e harness, two real fixes found while verifying:
- The suite ran against `next dev`, which compiles routes on demand, so
  parallel workers hitting cold routes timed out at random. Switched to a
  production build; the suite went from 1.3m to ~31s.
- The survey modal (a Math.random() coin flip) and the update-app prompt
  (whenever a mocked device reports an old version) could open over any page.
  A Radix dialog marks the rest of the page aria-hidden while open, so every
  getByRole query found nothing and a different test failed each run. Both are
  now suppressed deterministically in the session helper.
Full suite: 16/16 on four consecutive runs.

New chrome.spec.ts guards the footer geometry against both the sidebar and
the mobile tab bar, asserts the top bar stays reduced, and asserts the
sidebar theme control actually toggles the dark class.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 20:21:54 +03:00
isra elandClaude Fable 5 50d0902535 refactor: make transitions and entrance animations subtle
The entrance motion added in the previous rounds was too loud. Softened at
the token level and removed at the call sites where it replayed repeatedly.

Tokens (styles/main.css):
- fade-in 0.3s -> 0.15s
- fade-in-up 0.4s / 8px rise -> 0.2s / 3px rise
- check-pop 0.35s / 0.6-1.15-1 overshoot -> 0.2s / 0.92-1.02-1

Call sites, which mattered more than the token tuning:
- message-card: dropped animate-fade-in. Message history refetches on filter
  change, pagination and auto-refresh, so every row replayed its fade on
  every tick.
- get-started: dropped the per-index animationDelay stagger. The card polls
  every 10s, so any remount replayed a cascading six-row stagger.
- empty-state: dropped animate-fade-in from a static block.
- community-links: hover:scale-105 -> border and shadow change.
- onboarding progress bars: duration-500 -> duration-300.

Radix enter/exit animations on dialogs, dropdowns, selects, toasts and the
accordion are unchanged: they are short, conventional and expected. The
prefers-reduced-motion guard is unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 19:52:29 +03:00
isra elandClaude Fable 5 4ce6d00456 fix: resolve hydration, script-tag and invalid HTML nesting console errors
Three distinct issues surfaced by React 19 / Next 16:

1. next-themes script warning + hydration mismatch. ThemeProvider lived in
   the nested (app) layout, so its pre-paint <script> was re-rendered during
   client navigation ("Scripts inside React components are never executed
   when rendering on the client") and shifted the SSR/client tree. Moved it
   to the root layout via app/theme-provider.tsx, its canonical placement.

2. Invalid <p> nesting. Radix's DialogDescription and shadcn's
   CardDescription render a <p>, but call sites pass block content, giving
   "<p> cannot be a descendant of <p>" / "<div> cannot be a descendant of
   <p>". Fixed at the primitives: both now render a <div> (DialogDescription
   uses asChild so aria-describedby wiring is preserved), which fixes every
   call site at once. Also dropped a redundant wrapper in generate-api-key.

3. Nested <main>: the root layout wrapped children in <main> while the (app)
   layout renders its own. Removed the outer one.

Verified by driving the app with Playwright and capturing console output
across navigation and with the API key dialog open: zero hydration, script
tag, or nesting messages (previously several). Dark mode still applies.
Build, lint (0 errors), 28 unit tests, and 16 e2e green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 19:09:43 +03:00
isra elandClaude Opus 4.8 60e59f0221 fix(web): point Contribute links to canonical marketing page
app.textbee.dev/contribute returns 404 (flagged in Ahrefs audit). The public
footer/header Contribute links now use the absolute https://textbee.dev/contribute
page, consistent with the existing useCases/quickstart routes, fixing the broken
link and consolidating to one contribute page.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 16:02:28 +03:00
isra elandClaude Opus 4.8 f5815e1069 feat: merge account into settings layout with billing/profile/security/support
- account/layout.tsx: section header + route tabs (Billing & plan first,
  since the subscription is the most-visited account content), same RouteTabs
  interaction grammar as messaging and webhooks; active section survives
  refresh with shareable URLs
- /dashboard/account redirects to /billing (SubscriptionInfo + plan CTAs);
  /profile, /security and /support are thin pages reusing the existing forms
- Security combines password change with the delete-account flow in a
  clearly separated destructive "Danger zone" card
- legacy routes (edit-profile, change-password, delete-account, get-support)
  redirect into the merged sections so old links keep working
- account e2e: billing redirect + mocked subscription, security deep-link
  refresh survival, legacy redirects

Build, 28 unit tests, and 16 e2e green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 07:54:26 +03:00
isra elandClaude Opus 4.8 8a5f12a6eb feat: redesign onboarding into a conversion-focused guided setup
The Get Started card is the activation funnel; this makes it exceptional:

- animated progress bar + "x of 6" + momentum copy that changes with
  progress; per-step time-estimate chips; benefit-led step copy
- focus model: completed steps collapse to compact checked rows (check-pop
  animation), only the active step expands with one primary CTA; staggered
  entrance; auto-advance when the poll detects a step completed
- register_device is now self-serve inline: numbered instructions beside an
  in-place generate-key + QR panel (reuses the existing key mutation and
  react-qr-code); help dialog demoted to a "Need help?" fallback
- verify_email shows the actual email and an inline resend with a 60s
  cooldown via the existing sendEmailVerificationEmail endpoint
- completion is celebrated (one-time success state with "Send a message"
  CTA) instead of the card vanishing mid-glance
- minimize control collapses the card to a slim resumable progress pill
  (localStorage), so setup never nags

Fail-closed states (bug fixes):
- previously a backend failure rendered the checklist from missing data,
  showing verified users stuck on "Verify your email"; now a status machine
  (loading/error/ready/hidden/celebrate) only renders the checklist when all
  three queries succeeded; error shows a quiet retry row
- the full-height 6-row loading skeleton is replaced by a compact ~90px
  placeholder; background poll failures keep last-good data

Tests: MSW handlers for onboarding PATCH + resend; RTL tests for the error
policy, mid-funnel progress, and all-done progress; e2e asserts the progress
bar on Home. Build, 28 unit tests, and 13 e2e green; verified at 375px.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 07:49:28 +03:00
isra elandClaude Opus 4.8 bdae87aaf8 feat: redesign dashboard home into overview with quick actions
- personal greeting hero (first name) with a quick-actions row: Send SMS
  (primary, -> messaging), Add device and New API key (both open the
  generate-key/QR flow; "Add device" keeps the user's mental-model label),
  and Quick Start
- webhooks summary row: active-webhook count linking to /dashboard/webhooks,
  keeping a discoverable mobile path now that Webhooks left the tab bar and
  its section moved off Home
- Devices and API Keys cards show counts in their headers
- mobile-first: actions wrap under the greeting at 375px; verified visually

Build, 25 unit tests, and 13 e2e green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 07:41:22 +03:00
isra elandClaude Opus 4.8 516e626213 feat: webhooks page owns subscriptions + deliveries subroutes
- webhooks/layout.tsx: section header + route tabs (Webhooks | Deliveries),
  fixing the page that was previously titled "Webhook Notification Delivery
  History" inside a component named MessagingPage
- /dashboard/webhooks now shows subscription management (WebhooksSection,
  moved off the dashboard home); /dashboard/webhooks/deliveries shows the
  delivery history
- webhooks-section slims down: the layout owns the title and the Deliveries
  view, so its header is now just count + Create button; drops the unused
  router/icon imports
- webhooks e2e rewritten for the two subroutes including a deliveries
  deep-link refresh-survival test

Build, 25 unit tests, and 13 e2e green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 07:36:25 +03:00
isra elandClaude Opus 4.8 6c3636104e feat: messaging subroutes (send/bulk/history/api) with full-width history
- new shared RouteTabs primitive (components/shared/route-tabs.tsx): a
  link-based segmented control; tabs are real routes so the active tab
  survives refresh and deep links are shareable; aria-current on the active
  link and the active pill scrolls into view on mobile
- messaging/layout.tsx renders the section header + tabs; views become
  subroutes: / (Send, focused composer), /bulk, /history (now full width,
  previously cramped in a half column), /api-guide (the 419-line guide gets
  its own URL instead of half the page)
- delete the obsolete client-side tab wrapper (components)/messaging.tsx and
  dead main-dashboard.tsx
- messaging e2e rewritten for route tabs, including a direct-load test of
  /dashboard/messaging/history as the refresh-survival guarantee

Build, 25 unit tests, and 12 e2e green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 07:32:50 +03:00
isra elandClaude Opus 4.8 e6bb6e2385 feat: split nav config into 4-item mobile bar + 5-item desktop sidebar
The mobile tab bar caps at 4 items (5 don't fit at 375px): Dashboard,
Messaging, Community, Account. Webhooks is marked mobileHidden and remains in
the desktop sidebar and the command palette; mobile users keep a path via the
webhooks summary row added to Home in the home redesign part.

Also includes the auto-regenerated next-env.d.ts route-types path flip.

Build and 25 unit tests green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 07:29:31 +03:00
isra elandClaude Opus 4.8 dda38204fa fix: eliminate mobile horizontal overflow and add scroll-width e2e guard
Root causes of the sideways-scroll blank strip at 375px:
- fixed-width auth cards (w-[400px]/w-[450px]) -> w-full + max-w so they
  shrink on small screens
- min-w-[500px] on the community-links dialog -> removed
- webhooks section header: two w-full buttons in a non-wrapping flex row
  (403px wide) -> stacks on mobile
- devices/api-keys cards blown past the grid track by min-content sizing
  -> min-w-0 max-w-full on the cards
- api-guide SyntaxHighlighter blocks get overflowX auto + maxWidth 100%
- safety net: overflow-x clip on body

Adds e2e/mobile-overflow.spec.ts: at 375px, scrollWidth must not exceed the
viewport on home, messaging, webhooks, account, and login. This guard caught
the webhooks-header and card offenders during the fix.

Build, 25 unit tests, and 11 e2e green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 07:27:04 +03:00
isra elandClaude Opus 4.8 79e6dce8f7 fix: add rel attributes to external status link in footer
Adds rel='nofollow noopener noreferrer' to the target=_blank Status link.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 07:20:41 +03:00
isra elandClaude Opus 4.8 eba534d208 feat: shared empty states, button press micro-interaction, gradient rename
- promote the message-history EmptyState into components/shared/empty-state
  and use it for the bare "No devices found" / "No API keys found"
  placeholders (icon + title + actionable hint, fade-in)
- button primitive: subtle active:scale press state (transition covers
  transform; the global prefers-reduced-motion guard still disables it)
- finish the deferred bg-gradient-to-* -> bg-linear-to-* rename (Tailwind v4
  canonical utility) across the remaining four alert/modal components

Build, 25 unit tests, and 6 e2e green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 06:48:02 +03:00
isra elandClaude Opus 4.8 2623118df3 refactor: decompose 578-line webhooks-history into focused modules
Split app/(app)/dashboard/(components)/webhooks-history.tsx into a
webhooks-history/ folder (import path unchanged via index.tsx):

- use-filters.ts: filter state hook (device, webhook, event, status, preset
  and custom date ranges) with page reset handled in one place
- filters.tsx: the five filter selects + custom-range popover, deduplicated
  via a FilterField wrapper and option tables
- index.tsx: slim container on new useWebhooks / useWebhookNotifications
  hooks in lib/api
- promote the numbered pagination (previously duplicated verbatim between
  message-history and webhooks-history) to
  components/shared/numbered-pagination.tsx
- drop dead code: empty useEffect, never-set isLoading state, ~10 unused
  imports; replace the off-palette sky gradient with card tokens
- add a webhooks history e2e guard

Build, 25 unit tests, and 6 e2e green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 06:45:22 +03:00
isra elandClaude Opus 4.8 d1548b4001 refactor: decompose 652-line get-started into focused modules
Split app/(app)/dashboard/(components)/get-started.tsx into a get-started/
folder (import path unchanged via index.tsx):

- steps.ts: pure step definitions + completion rules, now unit-tested
- use-onboarding.ts: queries, onboarding mutation, derived step state and
  the auto-complete / legacy-account effects (query keys unchanged, still
  shared with verify-email-alert and account-deletion-alert)
- step-actions.tsx + plan-picker.tsx: per-step CTAs and the Free/Pro chooser
- register-help-dialog.tsx, skeleton.tsx: presentational pieces
- index.tsx: the timeline card view (~180 lines)
- bg-gradient-to-br -> bg-linear-to-br (Tailwind v4 canonical name)

Build, 25 unit tests (6 new), and 5 e2e green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 06:38:13 +03:00
isra elandClaude Opus 4.8 9111ca0975 refactor: decompose 1051-line message-history into focused modules
Split app/(app)/dashboard/(components)/message-history.tsx into a
message-history/ folder (import path unchanged via index.tsx):

- sms-composer-dialog: one parameterized compose dialog replacing the
  near-duplicate ReplyDialog and FollowUpDialog (~300 lines of duplication)
- sms-details-dialog, message-card (+skeleton), filters-bar, pagination,
  utils (timestamp/status badge), types
- index.tsx is a slim container owning filter/pagination state, wired to new
  useSendSms and useDeviceMessages hooks in lib/api
- design cleanups: off-palette sky gradient on the filters bar replaced with
  card tokens, bg-gray-* -> bg-muted, brand-tinted controls -> accent tokens,
  proper empty states with icons, subtle fade-in on cards

Every module is now 26-240 lines. Build, 19 unit tests, and 5 e2e green
(including the messaging history guard added before the refactor).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 06:33:38 +03:00
isra elandClaude Opus 4.8 e34dd7ac10 test: add messaging screen e2e guard (tabs + history render)
Guards the messaging screen (Send/Bulk/History tabs and the message-history
list against mocked data) before decomposing the 1051-line message-history
component.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 06:18:25 +03:00
isra elandClaude Opus 4.8 f821f480c7 chore: migrate lint to eslint 9 flat config
Next 16 removed the `next lint` command, so `pnpm lint` was broken. Replace
.eslintrc.json with an ESLint 9 flat config (eslint.config.mjs) that spreads
eslint-config-next's native core-web-vitals flat config, and point the lint
script at `eslint .`.

Keep Next 16's newer react-hooks (v6) compiler-adjacent rules advisory (warn)
rather than blocking, since they flag working-but-non-optimal patterns across
the existing codebase. `pnpm lint` now passes (0 errors, warnings only).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 02:03:14 +03:00
isra elandClaude Opus 4.8 6fe0a5dde0 refactor: sweep dashboard, auth and header off hardcoded gray/white onto tokens
Replace the common hardcoded gray/white color pairs (bg-white dark:bg-gray-800,
text-gray-900 dark:text-white, text-gray-500 dark:text-gray-400,
border-gray-200 dark:border-gray-700, etc.) with semantic tokens (bg-card,
text-foreground, text-muted-foreground, border-border, bg-muted) in the app
header, message history, verify-email and download pages, so light/dark stay
consistent and the theme is the single source of truth.

Build, 19 unit tests, and e2e green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 01:58:35 +03:00
isra elandClaude Opus 4.8 c1ac054cd6 feat: single-accent recolor and token sweep on subscription + pro alert
- recolor the Pro upsell banner's promotional state from the off-palette
  purple/pink gradient to the brand orange; normalize its raw orange CTAs to
  brand tokens, and migrate it onto useSubscription
- sweep subscription-info off hardcoded gray/white classes onto semantic
  tokens (bg-card, text-muted-foreground, text-foreground, bg-muted) and
  swap the teal "Upgrade to Scale" button to the brand ramp
- remove a stray `import { truncate } from 'fs'` in webhooks-history (a Node
  module imported into a client component; the used `truncate` is a CSS class)

Build, 19 unit tests, and e2e green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 01:52:27 +03:00
isra elandClaude Opus 4.8 d906fb1b97 feat: responsive app shell with sidebar, command palette, mobile nav
Replace the floating top-[20%] icon sidebar with a proper dashboard shell:

- a labeled, full-height desktop sidebar (below the app header) with a
  shared nav config, active states using the primary/accent tokens, and a
  quick-start footer
- a Cmd/Ctrl+K command palette (cmdk) for navigation and logout, plus a
  search trigger in the sidebar
- a refined mobile bottom tab bar, all driven by the same nav config so the
  three surfaces never drift
- everything uses semantic tokens instead of hardcoded gray/white

Build, unit tests, and e2e green; desktop + mobile shells visually verified.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 01:48:22 +03:00
isra elandClaude Opus 4.8 55ad2a6a5a feat: rebrand design tokens, dark-mode depth, typography, motion
- new primary accent: warm orange #EA580C (hsl 21 90% 48%) in light + dark,
  with the matching Tailwind-orange brand-* ramp; focus ring now uses the
  accent, and the primary-on-color foreground is white for contrast
- refine dark mode into layered neutrals (background below cards/popovers,
  clearer borders) instead of the previous flat gray
- warm the accent token and align chart-1 to the brand
- adopt Inter via next/font (--font-inter -> font-sans), antialiased body
- motion foundation: fade-in / fade-in-up keyframes as theme animations plus
  a prefers-reduced-motion guard; drop dead imports from the root layout

Build, 19 unit tests, and e2e green; dashboard visually verified in the new orange.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 01:43:16 +03:00
isra elandClaude Opus 4.8 dbea68ab30 refactor: modernize nextauth wrapper and provider setup
- extract the provider tree into app/(app)/providers.tsx and create the
  QueryClient once via useState (it was rebuilt on every render, discarding
  the cache); remove the old layout-wrapper
- replace the per-navigation whoAmI session check with a global 401 response
  interceptor in httpBrowserClient that routes expired sessions to /logout
- rename middleware.ts -> proxy.ts (Next 16 convention), clearing the
  deprecation warning; auth guard verified by e2e
- fix .env.example: NEXTAUTH_SECRET (was mislabeled AUTH_SECRET) + NEXTAUTH_URL
- add an e2e assertion for the authenticated login -> dashboard redirect

Build, 19 unit tests, and 3 e2e all green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 01:40:25 +03:00
isra elandClaude Opus 4.8 497a14074a refactor: centralize data fetching into typed react-query hooks
Introduce a typed data layer under lib/api (query-key factory, response
types, feature hooks) plus shared lib/format and lib/status helpers, and
migrate the canonical dashboard consumers (subscription-info, overview,
device-list, api-keys) onto it, deleting their duplicated inline queries,
mutations and formatters.

List hooks keep the raw { data: [] } envelope in the cache and unwrap
per-observer with react-query `select`, so shared keys like ['devices']
stay compatible with the not-yet-migrated components that still read the
raw shape (avoids a cache-shape collision surfaced by the dashboard e2e).

Adds unit tests for the formatters and the hooks (against MSW). Build,
19 unit tests, and 2 e2e all green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 01:36:16 +03:00
isra elandClaude Opus 4.8 89f2f78706 chore: bump remaining deps and remove dead prisma dependency
- remove unused deps: prisma (no schema/usage), tailwindcss-animate
  (replaced by tw-animate-css), @radix-ui/react-separator (no imports)
- drop unused DATABASE_URL from .env.example
- bump Radix, react-query, react-table, next-auth, next-themes,
  react-hook-form, react-dropzone, tailwind-merge, axios, uuid, etc.
- pin three packages off their newest majors to avoid churn outside this
  scope (revisit later):
  - lucide-react 0.577 (1.x removes brand/social icons still used in 6 files)
  - zod 3.25 (4.x changes resolver input/output typing across all forms)
  - react-day-picker 9 (10.x changes the classNames API in ui/calendar)

Build, unit tests, and e2e all green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 01:20:32 +03:00
isra elandClaude Opus 4.8 8072904ea2 chore: migrate to tailwind v4
- CSS-first config: @import "tailwindcss" + @theme inline mapping the
  shadcn HSL tokens; colors unchanged (rebrand lands in the design part)
- switch postcss to @tailwindcss/postcss, drop autoprefixer
- replace tailwindcss-animate with tw-animate-css for the animate-in /
  data-state utilities the Radix primitives rely on
- remove tailwind.config.js (v4 auto-detects content) and point
  components.json at the CSS
- suppressHydrationWarning on <html> for the next-themes class swap

Build, unit tests, and e2e all green; generated CSS verified to contain
bg-primary, the brand ramp, border-border, and the accordion/animate utilities.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 01:15:01 +03:00
isra elandClaude Opus 4.8 ea1b00ce3f chore: upgrade to next 16 + react 19
- next 14.2 -> 16.2, react/react-dom 18 -> 19, eslint 8 -> 9,
  typescript-eslint 6 -> 8, typescript 5.3 -> 5.7, @types/react* -> 19
- remove i18n config from next.config.js (unsupported in App Router; it
  forced a pages-router _document and broke the 404 prerender)
- Next 16 reconfigured tsconfig (moduleResolution bundler, jsx react-jsx)

Build, unit tests, and e2e all green on the new stack.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 01:10:35 +03:00
isra elandClaude Opus 4.8 c92740c401 test: add vitest + rtl + msw + playwright harness with mocked api
Establish the testing safety net for the web dashboard before refactoring.

- Vitest + React Testing Library + jsdom for unit/component tests
- MSW node server mocks the API; unhandled requests throw so tests can
  never reach a real backend
- Playwright e2e boots the real Next app but intercepts every /api/v1 call
  with shared fixtures, and mints a NextAuth session cookie to run authed
- Shared fixtures in test/fixtures.ts back both layers
- Seed tests: Button unit tests, MSW sanity checks, and a dashboard e2e
  happy path plus an auth-guard redirect

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 01:01:05 +03:00
vernu 7813d886d6 Merge pull request #239 from vernu/dev
show custom limit overrides and usage on account card
2026-07-10 22:17:16 +03:00
isra elandClaude Fable 5 95caebfdb9 feat(billing): surface custom limit overrides and usage on account card
The dashboard account card showed base plan limits and ignored the
per-user custom limits admins can set, so a user whose limits were
raised saw stale numbers. Surface the effective values and make the
overrides legible.

- api: include the effective bulkSendLimit in the current-subscription
  usage payload (the only effective limit it was missing)
- subscription card: render the effective limit with the standard plan
  value struck through, an amber "Custom" chip and tooltip, a "Custom
  limits" header badge, a new Devices tile, and daily/monthly usage
  meters (green under 80%, amber approaching, red at the limit; "0 left"
  when over the limit in grace)
- add a rolling-window info note: daily resets at 00:00 UTC, monthly is
  a rolling 30 days, separate from the subscription start/renewal dates
- compact the card and lay the four limits out as a 2x2 grid
- bulk send: cap CSV rows by the effective bulk limit so a raised
  override is honored instead of the base plan limit
- remove the unused account-settings.tsx duplicate

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 16:40:42 +03:00
Sebastion 1f7c1c16ec fix(api): lock out password-reset OTP after 5 failed attempts
The password-reset flow issues a 6-digit numeric OTP that lives for
20 minutes. The verification path (auth.service.resetPassword) had no
per-record failed-attempt counter and did not invalidate the reset
record on failure, so an attacker who triggered a reset email could
brute-force the OTP in-window (1e6 guesses).

Track a durable 'attempts' counter on the PasswordReset document and
invalidate the record after 5 failed submissions. A correct guess
after lockout is rejected as well.
2026-07-03 20:35:40 +01:00
vernu 92e52679fb Update LICENSE
Updated copyright year and holder in LICENSE file.
2026-07-03 17:44:25 +03:00
vernu eda97c154f Updated README.md
Updated README.md with new features, improved clarity, and added badges.
2026-07-03 14:34:20 +03:00
vernu c200ec375d Merge pull request #237 from vernu/dev
retain paid plan until period end on cancellation
2026-06-18 09:04:42 +03:00
isra elandClaude Opus 4.8 ef45421150 fix(billing): retain paid plan until period end on cancellation
Polar's subscription.canceled signals a scheduled cancellation (access
continues until the period end), while subscription.revoked is the event
that should actually remove access. The webhook handler was downgrading
immediately on cancel and never handled revoke, so a Pro user dropped to
Free the moment they canceled instead of at their real period end.

- cancelSubscription now records cancelAtPeriodEnd + currentPeriodEnd and
  keeps the subscription active instead of deactivating it
- add revokeSubscription for the real downgrade, wired to the
  subscription.revoked event in the Polar webhook handler
- add controller + service specs covering event dispatch and the
  record-vs-downgrade DB semantics
- add jest moduleNameMapper so specs can resolve src/* path aliases

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 09:02:39 +03:00
vernu d6c41a43c6 Merge pull request #236 from vernu/dev
fix(devices): register devices as enabled and surface toggle feedback
2026-06-13 19:28:27 +03:00
isra elandClaude Opus 4.8 de8df1c53c fix(devices): register devices as enabled and surface toggle feedback
The 2.8 Kotlin client stopped sending `enabled` on registration, so the
backend created devices with the schema default `enabled: false` ("Disabled"),
which users could not activate. Default new registrations to enabled on the
server (so existing 2.8 clients are fixed without an app update), still gated
by the device-limit check, and send `enabled = true` from onboarding.

Also make the gateway toggle always give feedback: show a success toast on
enable/disable and surface the server's reason (e.g. device-limit 429) on
failure instead of silently snapping back.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 19:26:35 +03:00
vernu bfc1e8c966 Merge pull request #235 from vernu/dev
feat(billing): update existing polar subscription on plan change insead of creating a new checkout
2026-06-13 15:42:10 +03:00
isra elandClaude Fable 5 164124d616 feat(billing): update existing Polar subscription on plan change instead of creating a new checkout
A paid subscriber upgrading (pro -> scale) or downgrading (scale -> pro)
previously got a brand-new Polar checkout, ending up with two live Polar
subscriptions and double billing. Now an active paid subscription is
updated in place via Polar's subscription update API.

- store polarSubscriptionId/polarCustomerId/cancelAtPeriodEnd on
  subscriptions (recovered via externalCustomerId for legacy records)
- POST /billing/checkout returns a planChange preview for paid users;
  new POST /billing/change-plan executes it (uncancels a scheduled
  cancellation first, org-default proration, idempotent with webhooks)
- allow monthly<->yearly interval switches; keep ALREADY_ON_PLAN only
  for same plan + same interval; block custom plans (CONTACT_BILLING)
- map Polar 402/403/409 errors to actionable messages; run plan-change
  detection before cached checkout-session reuse
- checkout page shows a confirmation screen before applying the change;
  account page shows a success toast

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 20:09:00 +03:00
vernu 1c4a2d6528 Merge pull request #234 from vernu/dev
fix(billing): correctly route webhook events per plan and scope cance…
2026-06-11 18:15:22 +03:00
isra elandClaude Sonnet 4.6 c846279d41 fix(billing): correctly route webhook events per plan and scope cancellations
- switchPlan now resolves plan strictly by polarProductId (no name fallback when product ID is provided); throws on unknown product ID
- cancelSubscription: new method that deactivates only the subscription matching the cancelled product, instead of wiping all active subscriptions
- Webhook handler: subscription.created/active/updated no longer hardcodes newPlanName='pro'; subscription.cancelled/canceled use cancelSubscription; subscription.revoked commented out

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-11 18:06:49 +03:00
vernu 4fdca11cde Merge pull request #233 from vernu/dev
introduce scale plan
2026-06-10 22:35:57 +03:00
isra elandClaude Sonnet 4.6 fd7e6cac3f feat(billing): introduce Scale plan tier above Pro
Add Scale plan ($29.99/mo, 25k SMS/mo, 15 devices) between Pro and
Custom in the subscription priority chain. Update dashboard upgrade
prompts to surface Scale for Pro users approaching their monthly limit,
and expose Scale upgrade links in subscription-info and account-settings.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-10 22:32:13 +03:00
vernu db7880db0d Merge pull request #232 from vernu/dev
fix billing and checkout issues
2026-06-10 18:46:44 +03:00
isra elandClaude Fable 5 3f1037373f feat(billing): enforce per-plan device limits and consolidate billingInterval naming
Add deviceLimit to plans (default -1 = unlimited) with per-subscription
customDeviceLimit override, resolved in getEffectiveLimits and exposed
via the usage object. Gateway blocks device creation and disabled to
enabled transitions with 429 once the enabled-device count reaches the
limit; already-enabled devices are never affected and the check fails
open on lookup errors. Send a throttled device_limit_reached email
notification and show approaching/reached banners with an upgrade CTA
in the dashboard device list.

Also replace the isYearly checkout field with billingInterval
('monthly' | 'yearly') across DTO, service, and checkout page (legacy
?billing= param still accepted until the marketing site redeploys).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 18:23:13 +03:00
isra elandClaude Fable 5 288ad7d15a feat(billing): respect selected billing interval in polar checkout
Order the polar products array from the isYearly flag so the chosen
interval is preselected at checkout, forward the ?billing= param from
the checkout page as isYearly, and only reuse cached checkout sessions
that match the requested plan and billing interval and are neither
completed nor abandoned.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 17:51:38 +03:00
vernu 4753d357ab Merge pull request #231 from vernu/dev
rewrite android app ui in jetpack compose with full kotlin migration
2026-06-10 09:07:11 +03:00
isra elandClaude Sonnet 4.6 6ce9fce9a2 chore(android): bump version to 2.8.0 (versionCode 18)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-10 09:00:56 +03:00
isra elandClaude Sonnet 4.6 243bbdd1d0 feat(android): complete Kotlin migration, UI overhaul, and dashboard polish
Kotlin migration (phases 3-5):
- Port all DTOs, helpers, models, workers, receivers, and services from Java to Kotlin
- Room DB files ported to Kotlin with all logic kept commented out (not yet enabled)
- Add SMSFilterScreen and SMSFilterViewModel in Compose (replaces Java SMSFilterActivity in new UI)
- All helpers exposed as Kotlin objects with @JvmStatic for Java interop

Onboarding improvements:
- Rewrote copy on all 5 onboarding screens (Welcome, Credentials, DeviceSetup, Permissions, SetupComplete)
- Added receive SMS toggle on SetupCompleteScreen (defaults on)
- Gateway now set to enabled by default after successful registration

Dashboard improvements:
- Add receive SMS toggle and SIM subscription ID display in device card
- Add permission warning card when SMS permissions are missing
- Remove all-time stats section (replaced by subscription usage bars)
- Trim Quick Actions to Dashboard + Docs; move Get Support and Share to Settings
- Merge user greeting into TopAppBar subtitle
- Device ID now has inline copy button

Dashboard UI polish (community review fixes):
- Redundant Enabled badge removed; only shows when gateway is Disabled
- Add Gateway label below the main switch for clarity
- Replace infinity symbol with Unlimited in usage display
- SIM subscription IDs use neutral color instead of primary/orange
- Status bar matches background color instead of primary orange

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-10 08:57:16 +03:00
isra elandClaude Sonnet 4.6 9b629d3291 feat(android): new Kotlin/Compose UI with onboarding, dashboard, messages, and settings
Adds a full parallel Jetpack Compose UI alongside the legacy Java/XML app.
Users can switch between UIs via Settings; SplashActivity routes on launch.

New screens:
- Onboarding wizard (QR/manual API key, device registration, permissions)
- Dashboard (device status, all-time stats, subscription card, quick actions)
- Messages tab (SMS history with filter chips, pagination, detail dialog)
- Compose screen (multi-recipient input, send with snackbar feedback)
- Settings (account, gateway, SMS, legal, system, about sections)
- Splash screen with textbee logo and routing logic

Infrastructure:
- Compose BOM 2023.08.00, Material3, Navigation Compose, Kotlin coroutines
- GatewayApiServiceKt (suspend Retrofit interface) + ApiManagerKt singleton
- Kotlin DTOs for stats, subscription, user profile, messages, send SMS
- Material3 theme with brand orange, dark mode safe (dynamicColor = false)

Also adds MIGRATION.md tracking what's done vs what remains to port.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-06 22:11:27 +03:00
vernu 954f337b2f Merge pull request #230 from vernu/dev
fix failing docker build
2026-05-29 07:30:39 +03:00
isra elandClaude Sonnet 4.6 f6a8f21e64 fix(infra): pin pnpm to v9 in Dockerfiles and update GitHub Actions
pnpm v10 is now the npm "latest" tag but generates a different lockfile
format than v9.0 used in this repo, causing --frozen-lockfile to fail.
Replace corepack pnpm@latest with npm install -g pnpm@9 in api and web
Dockerfiles. Also bump docker/* actions to v3/v6 to resolve Node.js 20
deprecation warnings ahead of the June 2026 forced migration.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-29 07:28:30 +03:00
vernu 5389117fcd Merge pull request #229 from vernu/dev
remove lifecycle email logic
2026-05-28 17:53:24 +03:00
isra elandClaude Sonnet 4.6 08d9015048 remove lifecycle email logic
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-28 17:42:27 +03:00
vernu 260cf50780 Merge pull request #228 from vernu/dev
remove abandoned checkout email logic
2026-05-28 13:42:06 +03:00
isra elandClaude Sonnet 4.6 c235526583 remove abandoned checkout email logic
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-28 13:37:45 +03:00
isra elandClaude Sonnet 4.6 3bef5f90a0 feat(billing): wire up all 6 abandoned checkout emails
- Add fourth_reminder to AbandonedEmailType and schema enum
- Replace expiry-based timing with createdAt-based timing so the
  schedule is independent of Stripe session expiry windows
- Register all 6 emails in emailSchedule with correct delays:
  10 min, 1 hr, 24 hr, 3 d, 7 d, 14 d after session creation
- Add isCompleted filter to query so paid users are never emailed

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-28 12:58:39 +03:00
isra elandClaude Sonnet 4.6 0e4c7481b9 feat(mail): rewrite abandoned checkout email sequence
Complete rewrite of all 6 abandoned checkout templates for better
conversion and deliverability:

- Remove social media icons from all templates (promotions tab signal)
- Remove fake discounts, fabricated testimonials, and unverifiable
  claims (money-back guarantee, Calendly link)
- Give each email a distinct purpose: recovery nudge (10min), feature
  comparison table (1hr), cost objection handling (24hr), personal
  founder message (3d), honest comparison + low pressure (7d),
  graceful farewell + feedback ask (14d)
- Add opt-out notice to every email footer (required for marketing emails)
- Fix spam trigger subject ' Your textbee pro upgrade is waiting!'
  to 'Your TextBee checkout is still open'
- Standardise year to 2026 and brand to on-brand orange throughout

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-28 12:53:58 +03:00
vernu 3f7864c754 Merge pull request #227 from vernu/dev
improve webhook subscription auto-disable logic
2026-05-27 16:17:42 +03:00
isra el 110bdbc3ef feat(api): enhance webhook subscription management
- Added `lastEnabledAt` property to track when a user re-enables a previously disabled webhook, preventing immediate auto-disable due to historical failures.
- Updated `WebhookService` to set `lastEnabledAt` when a webhook is re-enabled and adjusted the auto-disable logic to respect a grace period based on this new property.
2026-05-27 16:15:41 +03:00
vernu 267996ad90 Merge pull request #226 from vernu/dev
Dev
2026-05-24 18:30:34 +03:00
isra el 93ac880dd7 chore(api): update polar sdk version 2026-05-24 17:06:04 +03:00
isra el 35a98f3288 infra: update node version for github workflow 2026-05-24 16:56:47 +03:00
isra el b1c8585e11 chore(api): update polar sdk version 2026-05-24 16:48:39 +03:00
isra el f4e23ba3d3 fix(api): resolve Mongoose strict type conflict in GatewayService
- Updated device query in GatewayService to cast the filter object, addressing a type collision with the reserved `model` field in Mongoose 9.6. This change maintains the runtime behavior while ensuring type compatibility.
2026-05-24 16:39:33 +03:00
isra el 1b35e76d82 chore(web): improve webhooks section ui 2026-05-24 16:32:20 +03:00
isra el 8046324c48 fix(api): disambiguate model query in GatewayService
- Updated the model query in GatewayService to use $eq for clarity, resolving a conflict with Mongoose Query.model in TypeScript 9.6+.
2026-05-24 14:08:07 +03:00
isra el 31a69e62ee fix(web): improve date formatting and device label handling in webhook table
- Updated formatDate function to handle undefined and invalid date strings.
- Introduced buildDeviceLabel function to construct device labels more robustly.
- Refactored deviceName mapping in ProductClient to utilize the new buildDeviceLabel function.
2026-05-24 14:07:23 +03:00
isra el 336bb65e0a feat(web): allow multiple webhook subscriptions 2026-05-24 13:35:59 +03:00
isra el c952040b2b feat(api): allow multiple webhook subscriptions 2026-05-24 13:35:32 +03:00
isra el cf02c3c50d fix(android): remove unnecessary usesCleartextTraffic attribute from AndroidManifest
- Removed the usesCleartextTraffic attribute from the AndroidManifest.xml file as it is no longer needed.
2026-04-20 00:25:14 +03:00
isra el 004f50d82d refactor(android): remove unnecessary SMS permissions and adjust receiver priority
- Removed READ_SMS permission from AndroidManifest and AppConstants.
- Updated SMSBroadcastReceiver intent filter priority from maximum to a standard value.
2026-04-20 00:20:36 +03:00
Israel Abebe 4f321108ff Merge pull request #221 from vernu/dev
show past-due billing alert in dashboard
2026-04-05 20:36:15 +03:00
isra el f6556ea512 feat(web): show past-due billing alert with Polar portal CTA
Add dashboard banner when subscription status is past_due for paid plans.
Links to Polar customer portal via existing polarCustomerPortalRequestUrl.
2026-04-05 19:24:21 +03:00
Israel Abebe b57a55121f Merge pull request #220 from vernu/dev
improve onboarding ui
2026-04-05 18:06:28 +03:00
isra el 7e9c036ec1 fix(web): structural skeleton for Get Started card while whoAmI loads
Replace the monolithic h-64 skeleton with GetStartedCardSkeleton that mirrors
the card header and step list so first paint matches the loaded layout.

Made-with: Cursor
2026-04-05 18:05:05 +03:00
Israel Abebe 37f99ae662 Merge pull request #219 from vernu/dev
fix failing tests
2026-04-05 17:46:35 +03:00
isra el 1a5e095c56 fix(api): use subscription document _id in webhook updateOne filters
Mongoose 9 updateOne expects an ObjectId for _id; webhookSubscriptionId
was inferred as ObjectId | WebhookSubscription in CI, causing TS2769.
Use webhookSubscription._id after findById instead.

Made-with: Cursor
2026-04-05 17:43:18 +03:00
Israel Abebe 677b2dda64 Merge pull request #218 from vernu/dev
prefill polar customer portal url with user email
2026-04-05 17:36:56 +03:00
isra el 6f5190aade feat(web): prefill Polar customer portal with user email
Add polarCustomerPortalRequestUrl() pointing to /portal/request with a
URL-encoded email query when available. Wire Manage Subscription links in
account settings and subscription info; subscription info loads currentUser
via whoAmI (shared React Query cache with account settings).

Made-with: Cursor
2026-04-05 17:34:14 +03:00
Israel Abebe 11ddcf3bd7 Merge pull request #216 from vernu/dev
allow device deletion
2026-04-05 10:03:03 +03:00
isra el aea2d27fbb feat(web): allow device deletion 2026-04-05 10:01:25 +03:00
Israel Abebe 14b91f19d7 Merge pull request #215 from vernu/dev
optimize api key queries
2026-04-05 09:41:53 +03:00
isra el 9586712712 feat(api): enhance API key retrieval logic
- Added a new method in AuthService to find active API keys using a masked match and fallback to regex.
- Updated OptionalAuthGuard and AuthGuard to utilize the new method for improved API key validation.
- Introduced an index on the apiKey field in the ApiKey schema for optimized query performance.
2026-04-05 09:32:41 +03:00
Israel Abebe 9bf7074de3 Merge pull request #212 from vernu/dev
improve API key management
2026-04-01 09:00:05 +03:00
isra el 77f2f38686 chore: improve API key management
- Added a query parameter to filter API keys by status (active, revoked, all) in the getApiKey endpoint.
- Updated the AuthService to handle status filtering logic for API key retrieval.
- Modified the frontend to support status-based API key listing and added a button to view revoked keys.
2026-04-01 07:41:02 +03:00
Israel Abebe bd6bcfd985 Merge pull request #211 from vernu/dev
allow device deletion
2026-03-31 23:09:19 +03:00
isra el 5467a85fb0 chore(api): allow device deletion 2026-03-31 22:59:16 +03:00
Israel Abebe e3723054b6 Merge pull request #210 from vernu/dev
Revert "chore(web): update dashboard banners"
2026-03-31 22:56:30 +03:00
isra el 9b6e044aa2 Revert "chore(web): update dashboard banners"
This reverts commit f64a520b86.
2026-03-31 15:16:19 +03:00
Israel Abebe 97e60f4ff9 Merge pull request #209 from vernu/dev
improve onboarding steps
2026-03-31 05:17:22 +03:00
isra el 8286938b9e ux: improve onboarding steps 2026-03-31 05:16:13 +03:00
Israel Abebe f5985ceccb Merge pull request #208 from vernu/dev
update dashboard banners
2026-03-30 21:01:41 +03:00
isra el f64a520b86 chore(web): update dashboard banners 2026-03-30 15:02:51 +03:00
Israel Abebe c5630b30e9 Merge pull request #207 from vernu/dev
enhance sms queue processing
2026-03-24 17:55:44 +03:00
isra el 4da8570cd2 feat(api): add fallback for SMS document insertion
- Implemented a check for the existence of the insertMany method in the SMS model to enhance flexibility.
- Added a fallback mechanism for models that do not support insertMany, allowing for individual document creation.
- Improved SMS document insertion process by maintaining performance while ensuring compatibility with various model types.
2026-03-24 17:54:40 +03:00
isra el 839ad24c83 feat(api): enhance SMS processing and queue management
- Introduced batching for SMS document insertion to improve performance.
- Added metadata tracking for SMS to FCM message mapping.
- Implemented error handling for mismatched SMS records and queue payloads.
- Updated SMS queue service to support dynamic batch sizes and immediate queue delays.
2026-03-24 17:24:10 +03:00
isra el af03623396 fix(api): enhance SMS queue processing and error handling
- Refactored SMS status updates in SmsQueueProcessor to batch updates for failed and dispatched SMS records.
- Improved error handling by collecting failed SMS details and updating their status in a single operation.
- Updated the SMS queue registration to use asynchronous configuration with dynamic limits from the ConfigService.
2026-03-24 17:23:57 +03:00
Israel Abebe fb3b2c1165 Merge pull request #206 from vernu/dev
fix failing tests
2026-03-23 14:24:55 +03:00
isra el ab76237fbb test(api): fix failing tests 2026-03-23 14:22:39 +03:00
Israel Abebe 7326bd7b3b Merge pull request #205 from vernu/dev
invalidate stale fcm tokens
2026-03-23 14:18:44 +03:00
isra el 01af5a9786 chore(api): invalidate stale fcm tokens 2026-03-23 14:17:57 +03:00
Israel Abebe ef4e42a204 Merge pull request #204 from vernu/dev
logging improvements
2026-03-23 13:55:45 +03:00
isra el 7bec9e8acf chore(api): logging improvements 2026-03-23 13:54:52 +03:00
Israel Abebe cb616c6bf5 Merge pull request #203 from vernu/dev
show update app version cta in dashboard
2026-03-13 22:02:16 +03:00
isra el 002ac9c144 chore(web): show update app version cta 2026-03-13 21:54:00 +03:00
Israel Abebe ffa66bed9b Merge pull request #202 from vernu/dev
enhance SMS status processing and error handling improvements
2026-03-12 18:21:24 +03:00
isra el 87006c226f refactor(ui): enhance layout and error display in SmsDetailsDialog
- Updated the grid layout for the Info Grid to better represent label-value proportions.
- Improved error details section to display error codes and messages in a full-width format for better visibility.
- Ensured consistent styling for error messages and codes to enhance user experience.
2026-03-12 17:31:12 +03:00
isra el 9d4ec8186b fix(api): improve FCM error handling and messaging in SmsQueueProcessor
- Updated getFcmErrorCode function to remove 'messaging/' prefix from error codes.
- Introduced getFcmErrorMessage function to provide actionable feedback for invalid device tokens.
- Enhanced error messages in SMS processing to utilize the new getFcmErrorMessage function for better clarity.
2026-03-12 17:18:04 +03:00
isra el dafc433575 fix(ui): improve error display in message history component
- Enhanced error handling in the SmsDetailsDialog to display both error codes and messages.
- Added conditional rendering for error code and message sections to improve clarity for users.
2026-03-12 17:00:21 +03:00
isra el a87094498c Merge branch 'main' into dev 2026-03-12 16:39:58 +03:00
isra el c488c16f59 feat(api): enhance SMS status processing and error handling improvements
- Added `dispatchedAt` property to SMS schema and updated status options to include 'dispatched'.
- Implemented logic in SmsQueueProcessor to mark SMS as 'dispatched' upon successful FCM push.
- Enhanced error handling for SMS failures, including specific error codes for FCM delivery issues.
- Updated SmsStatusUpdateTask to handle both 'pending' and 'dispatched' statuses for timeout updates.
2026-03-12 16:38:23 +03:00
Israel Abebe 2c6e3b8e6c Merge pull request #201 from vernu/2026-03-11-yigc
implement batch processing for FCM messages in heartbeat check task
2026-03-11 19:54:04 +03:00
isra el 60808db77a refactor(api): implement batch processing for FCM messages in heartbeat check task 2026-03-11 19:49:52 +03:00
Israel Abebe a43cf11bcd Merge pull request #200 from vernu/2026-03-11-qmsu
update queue configuration to set removal age for completed and failed SMS tasks
2026-03-11 19:41:04 +03:00
isra el 7a56692586 refactor(api): update queue configuration to set removal age for completed and failed SMS tasks 2026-03-11 19:39:58 +03:00
isra el d613217efb Merge branch 'main' of github.com:vernu/textbee 2026-03-11 11:47:35 +03:00
isra el 598d697306 chore(android): bump version to 2.7.1(17) 2026-03-11 11:47:21 +03:00
Israel Abebe 5ae010f1c3 Merge pull request #199 from vernu/dev
better handling of failed sms error messaging
2026-03-11 11:45:17 +03:00
isra el 94a0cd8c0a chore(android): graceful exit when sticky notifcation cant be started 2026-03-11 11:44:46 +03:00
isra el 2ec9207779 fix(android): fix sim card info retrieval exception on older android versions 2026-03-11 11:26:32 +03:00
isra el 51cb1ca198 chore(api): track sms sending failure due to FCM issue 2026-03-11 11:09:30 +03:00
isra el e49f3954c3 chore(android): better handling of failed sms error messaging 2026-03-11 11:07:57 +03:00
Israel Abebe 17351e2148 Merge pull request #198 from vernu/dev
add sms sending delay and retry config
2026-03-11 11:07:48 +03:00
isra el 8eec17ba1b chore(android): set default sms delay to 5 sec to avoid carrier/device throttling 2026-03-11 11:06:01 +03:00
isra el 25c70eb09d feat(api): save default sms sending delay config 2026-03-11 11:04:12 +03:00
isra el acde97f080 feat(android): add app sms sending delay and retry config 2026-03-11 10:20:55 +03:00
Israel Abebe e14b71a790 Merge pull request #196 from vernu/dev
update email template
2026-03-06 14:03:52 +03:00
isra el 5023f80f0f chore(api): update email template 2026-03-06 14:03:15 +03:00
Israel Abebe 2bdcdeb9a9 Merge pull request #195 from vernu/dev
skip automatic disabling of webhook subscriptions if theres a recent successful delivery
2026-03-06 13:59:07 +03:00
isra el b42a681816 chore(api): skip automatic disabling of webhook subscriptions if theres a recent successful delivery 2026-03-06 13:42:15 +03:00
Israel Abebe 14fd2f9b03 Merge pull request #194 from vernu/dev
mark undelivered webhook notifications older than one month as aborted to prevent further retries
2026-03-05 19:53:03 +03:00
isra el 540bb9068b feat(api): mark notifications older than one month as aborted to prevent retries 2026-03-05 19:50:48 +03:00
Israel Abebe 3061bd8d66 Merge pull request #193 from vernu/dev
fix webhooksubscription save issue
2026-03-05 11:55:13 +03:00
isra el 338fb665bc fix(api): fix webhooksubscription save issue 2026-03-05 11:54:20 +03:00
Israel Abebe 71475f0db8 Merge pull request #192 from vernu/dev
loosen webhook subscription auto-disable logic
2026-03-05 11:44:07 +03:00
isra el 40b0df297c chore(api): loosen webhook subscription auto-disable logic 2026-03-05 11:43:20 +03:00
Israel Abebe 863aac04e8 Merge pull request #191 from vernu/webhooks-improvements
auto-disable webhook subscriptions with high failure rate and increase delivery request timeout
2026-03-05 11:18:35 +03:00
isra el 6e7ed42fe8 fix(api): fix failing test 2026-03-05 11:15:29 +03:00
isra el 8fd06f06d1 feat(api): auto-disable webhook ubscriptions with high failure rate 2026-03-05 11:01:51 +03:00
isra el 0144cff16e chore(api): update webhook notifications delivery check cron time and query 2026-03-05 10:33:59 +03:00
isra el 7ff6ea23fc chore(api): update webhook notification delivery timeout to 30ms 2026-03-05 10:33:20 +03:00
Israel Abebe fd4969c92c Merge pull request #190 from vernu/webhooks-improvement
improve webhook notifications delivery reliability
2026-03-04 13:32:00 +03:00
isra el 4d1b53e247 chore(api): improve webhook notifications delivery reliability 2026-03-04 13:11:24 +03:00
Israel Abebe c668530188 Merge pull request #189 from vernu/support-hq-widget
add supporthq-customer support widget
2026-03-04 13:02:31 +03:00
Israel Abebe 30591c4847 Merge pull request #186 from vernu/update-email-template
update email template
2026-02-09 10:22:34 +03:00
Israel Abebe 482049a764 Merge pull request #185 from vernu/pro-cta
improve upgrade to pro cta
2026-02-09 06:55:19 +03:00
Israel Abebe 584e2c5bec Merge pull request #184 from vernu/email-templates
update email templates
2026-02-09 06:48:09 +03:00
Israel Abebe 9685ecb7b3 Merge pull request #183 from vernu/update-discounts-logic
handle missing discountId
2026-02-09 06:47:42 +03:00
472 changed files with 46995 additions and 15102 deletions
+115
View File
@@ -0,0 +1,115 @@
# yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json
language: en-US
tone_instructions: >-
Be direct and concise. Never use em dashes or en dashes in any comment,
summary, or suggested code. Use periods, commas, colons, or parentheses.
reviews:
profile: chill
request_changes_workflow: false
high_level_summary: true
review_status: true
poem: false
auto_review:
enabled: true
drafts: false
# main is the default branch and is always reviewed implicitly.
# dev has to be listed here or feature PRs get no review at all.
base_branches:
- dev
# Label the dev to main roll-up PRs to skip a duplicate review of code
# that was already reviewed on the way into dev.
labels:
- '!skip-ai-review'
ignore_title_keywords:
- WIP
- DO NOT MERGE
path_filters:
- '!**/node_modules/**'
- '!**/pnpm-lock.yaml'
- '!**/.DS_Store'
- '!api/dist/**'
- '!api/coverage/**'
- '!web/.next/**'
- '!web/out/**'
- '!web/test-results/**'
- '!web/playwright-report/**'
- '!web/next-env.d.ts'
- '!android/build/**'
- '!android/app/build/**'
- '!android/.gradle/**'
- '!android/local.properties'
- '!**/google-services.json'
- '!**/*.apk'
path_instructions:
- path: '**'
instructions: |
Never use em dashes or en dashes anywhere, including code comments,
JSDoc, alt text, metadata, and commit messages. Use a period, comma,
colon, parentheses, or a plain hyphen instead.
This project uses pnpm only. Flag npm or yarn commands in our own
scripts, docs, CI, and Dockerfiles. Do not flag npm or yarn inside
user-facing code samples, such as the dashboard API guide snippets or
SDK install instructions: those run in the reader's project, not ours,
and npm is the right default there.
Keep functions small and single-purpose.
Never run package installs or commit pnpm-lock.yaml changes when
applying fixes. Lockfiles change only together with a package.json
change, made by a human with the pinned pnpm version.
- path: 'api/src/**/*.ts'
instructions: |
NestJS and Mongoose service. Focus on authentication, authorization,
tenant scoping, and input validation. Flag any query that can read or
write another user's data without an owner or user filter. Flag secrets,
API keys, or tokens that get logged or returned in responses.
Note that tsconfig has strictNullChecks and noImplicitAny disabled, so
null and undefined handling is not compiler-enforced here.
- path: 'web/**/*.{ts,tsx}'
instructions: |
Next.js 16 App Router with React 19. Flag server-only data or secrets
leaking into client components, missing loading and error states, and
useEffect dependency mistakes. eslint downgrades several react-hooks
rules to warn, so call those out in review instead.
- path: 'web/components/ui/**'
instructions: |
Vendored shadcn/ui primitives. Only comment on deliberate local
modifications, not on generator-produced style.
- path: 'android/**/*.{kt,java}'
instructions: |
Android SMS gateway. Focus on permission handling, leaked Context or
Activity references, work done on the main thread, and SMS send or
receive edge cases. There is no ktlint or detekt in CI, so style issues
are worth flagging here.
- path: '.github/workflows/**'
instructions: |
Flag unpinned action versions, secrets echoed into logs or files, and
steps that would run untrusted code from fork pull requests.
tools:
# Waits for ALL GitHub Checks, then comments on failures.
github-checks:
enabled: true
chat:
auto_reply: true
# Public repo, so outside contributors can spend the chat quota.
# Set to false if that becomes a problem.
allow_non_org_members: true
knowledge_base:
learnings:
scope: auto
issues:
scope: auto
pull_requests:
scope: auto
+10 -10
View File
@@ -1,14 +1,14 @@
# These are supported funding model platforms
github: [vernu] # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2]
patreon: vernu
# github: [vernu] # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2]
# patreon: vernu
# open_collective: textbee
ko_fi: # Replace with a single Ko-fi username
tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
liberapay: # Replace with a single Liberapay username
issuehunt: # Replace with a single IssueHunt username
lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry
polar: textbee
# ko_fi: # Replace with a single Ko-fi username
# tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
# community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
# liberapay: # Replace with a single Liberapay username
# issuehunt: # Replace with a single IssueHunt username
# lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry
# polar: textbee
# buy_me_a_coffee: vernu
custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2']
# custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2']
Binary file not shown.

After

Width:  |  Height:  |  Size: 484 KiB

+148
View File
@@ -0,0 +1,148 @@
name: Android
on:
push:
branches:
- main
- dev
paths:
- 'android/**'
- '.github/workflows/android.yaml'
pull_request:
branches:
- main
- dev
paths:
- 'android/**'
- '.github/workflows/android.yaml'
workflow_dispatch:
inputs:
branch:
description: 'Branch to run workflow on'
required: true
default: 'main'
type: string
permissions:
contents: read
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
jobs:
build:
name: Build Android
runs-on: ubuntu-latest
timeout-minutes: 30
# google-services.json is written from repository secrets, and GitHub
# withholds secrets from fork pull requests, so skip on forks rather than
# fail every outside contribution.
if: >-
github.event_name != 'pull_request' ||
github.event.pull_request.head.repo.full_name == github.repository
strategy:
fail-fast: false
matrix:
variant: [dev, prod]
steps:
- name: Checkout repository
uses: actions/checkout@v7
with:
ref: ${{ github.event_name == 'workflow_dispatch' && inputs.branch || github.ref }}
persist-credentials: false
- name: Set up JDK 17
uses: actions/setup-java@v5
with:
java-version: '17'
distribution: 'temurin'
- name: Setup Gradle
uses: gradle/actions/setup-gradle@v6
with:
gradle-version: '7.2'
- name: Grant execute permission for gradlew
run: chmod +x android/gradlew
- name: Create debug keystore
run: |
mkdir -p ~/.android
keytool -genkey -v -keystore ~/.android/debug.keystore -storepass android -alias androiddebugkey -keypass android -keyalg RSA -keysize 2048 -validity 10000 -dname "CN=Android Debug,O=Android,C=US"
- name: Create google-services.json
env:
GOOGLE_SERVICES_JSON: ${{ matrix.variant == 'dev' && secrets.GOOGLE_SERVICES_JSON_DEV || secrets.GOOGLE_SERVICES_JSON_PROD }}
run: |
mkdir -p android/app/src/${{ matrix.variant }}
printf '%s' "$GOOGLE_SERVICES_JSON" > android/app/src/${{ matrix.variant }}/google-services.json
- name: Build
run: |
cd android
./gradlew assemble${{ matrix.variant }}Debug
# - name: Run Android tests
# run: |
# cd android
# ./gradlew test${{ matrix.variant }}DebugUnitTest
- name: Sanitize ref name for artifact
shell: bash
run: echo "SAFE_REF_NAME=${GITHUB_REF_NAME//\//-}" >> $GITHUB_ENV
- name: Upload build artifacts
uses: actions/upload-artifact@v7
with:
name: textbee-${{ matrix.variant }}-build-${{ env.SAFE_REF_NAME }}-${{ github.sha }}.apk
path: android/app/build/outputs/apk/${{ matrix.variant }}/debug/app-${{ matrix.variant }}-debug.apk
retention-days: 7
build-fork:
name: Compile Android (fork PR)
runs-on: ubuntu-latest
timeout-minutes: 30
# The build job above needs secrets that GitHub withholds from fork pull
# requests. Compile the dev variant against the committed sample Firebase
# config instead, so outside contributions are still checked. No artifact
# is uploaded, since the resulting APK points at a placeholder project.
if: >-
github.event_name == 'pull_request' &&
github.event.pull_request.head.repo.full_name != github.repository
steps:
- name: Checkout repository
uses: actions/checkout@v7
with:
persist-credentials: false
- name: Set up JDK 17
uses: actions/setup-java@v5
with:
java-version: '17'
distribution: 'temurin'
- name: Setup Gradle
uses: gradle/actions/setup-gradle@v6
with:
gradle-version: '7.2'
- name: Grant execute permission for gradlew
run: chmod +x android/gradlew
- name: Create debug keystore
run: |
mkdir -p ~/.android
keytool -genkey -v -keystore ~/.android/debug.keystore -storepass android -alias androiddebugkey -keypass android -keyalg RSA -keysize 2048 -validity 10000 -dname "CN=Android Debug,O=Android,C=US"
- name: Use sample google-services.json
run: cp android/app/src/dev/google-services.sample.json android/app/src/dev/google-services.json
- name: Build
run: |
cd android
./gradlew assembleDevDebug
+74
View File
@@ -0,0 +1,74 @@
name: API
on:
push:
branches:
- main
- dev
paths:
- 'api/**'
- '.github/workflows/api.yaml'
pull_request:
branches:
- main
- dev
paths:
- 'api/**'
- '.github/workflows/api.yaml'
workflow_dispatch:
inputs:
branch:
description: 'Branch to run workflow on'
required: true
default: 'main'
type: string
permissions:
contents: read
# Superseded runs on the same branch or PR are cancelled.
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
jobs:
build-and-test:
name: Build and test API
runs-on: ubuntu-latest
timeout-minutes: 15
defaults:
run:
working-directory: api
steps:
- name: Checkout repository
uses: actions/checkout@v7
with:
ref: ${{ github.event_name == 'workflow_dispatch' && inputs.branch || github.ref }}
persist-credentials: false
# pnpm has to be on PATH before setup-node, or its pnpm cache resolution
# has no package manager to query.
- name: Install pnpm
uses: pnpm/action-setup@v6
with:
package_json_file: api/package.json
run_install: false
- name: Set up Node.js
uses: actions/setup-node@v7
with:
node-version: '20'
cache: pnpm
cache-dependency-path: api/pnpm-lock.yaml
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Build
run: pnpm run build
- name: Unit test
run: pnpm test
-139
View File
@@ -1,139 +0,0 @@
name: Build and Test
on:
push:
paths:
- 'api/**'
- 'web/**'
- 'android/**'
- '.github/workflows/build-and-test.yaml'
workflow_dispatch:
inputs:
branch:
description: 'Branch to run workflow on'
required: true
default: 'main'
type: string
android_variant:
description: 'Android build variant'
required: true
default: 'dev'
type: choice
options:
- dev
- prod
jobs:
build-and-test-web-and-api:
name: Build and Test web and api
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
ref: ${{ github.event_name == 'workflow_dispatch' && inputs.branch || github.ref }}
- name: Set up Node.js
uses: actions/setup-node@v3
with:
node-version: '18'
- name: Install pnpm
uses: pnpm/action-setup@v2
with:
version: 8
run_install: false
- name: Get pnpm store directory
shell: bash
run: |
echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
- name: Setup pnpm cache
uses: actions/cache@v3
with:
path: ${{ env.STORE_PATH }}
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-pnpm-store-
- name: Build and test API
run: |
cd api
pnpm install
pnpm run build
pnpm test
- name: Build web
run: |
cd web
pnpm install
pnpm run build
build-and-test-android:
name: Build and Test Android
runs-on: ubuntu-latest
strategy:
matrix:
variant: [dev, prod]
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
ref: ${{ github.event_name == 'workflow_dispatch' && inputs.branch || github.ref }}
- name: Set up JDK 17
uses: actions/setup-java@v3
with:
java-version: '17'
distribution: 'temurin'
cache: gradle
- name: Setup Gradle
uses: gradle/gradle-build-action@v2
with:
gradle-version: '7.2'
- name: Grant execute permission for gradlew
run: chmod +x android/gradlew
- name: Create debug keystore
run: |
mkdir -p ~/.android
keytool -genkey -v -keystore ~/.android/debug.keystore -storepass android -alias androiddebugkey -keypass android -keyalg RSA -keysize 2048 -validity 10000 -dname "CN=Android Debug,O=Android,C=US"
- name: Create google-services.json for dev
if: matrix.variant == 'dev'
run: |
mkdir -p android/app/src/dev
echo '${{ secrets.GOOGLE_SERVICES_JSON_DEV }}' > android/app/src/dev/google-services.json
- name: Create google-services.json for prod
if: matrix.variant == 'prod'
run: |
mkdir -p android/app/src/prod
echo '${{ secrets.GOOGLE_SERVICES_JSON_PROD }}' > android/app/src/prod/google-services.json
- name: Build Android app
run: |
cd android
./gradlew assemble${{ matrix.variant }}Debug
# - name: Run Android tests
# run: |
# cd android
# ./gradlew test${{ matrix.variant }}DebugUnitTest
- name: Sanitize ref name for artifact
shell: bash
run: echo "SAFE_REF_NAME=${GITHUB_REF_NAME//\//-}" >> $GITHUB_ENV
- name: Upload build artifacts
uses: actions/upload-artifact@v4
with:
name: textbee-${{ matrix.variant }}-build-${{ env.SAFE_REF_NAME }}-${{ github.sha }}.apk
path: android/app/build/outputs/apk/${{ matrix.variant }}/debug/app-${{ matrix.variant }}-debug.apk
retention-days: 7
+7 -7
View File
@@ -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 }}
+37
View File
@@ -0,0 +1,37 @@
# Fails any PR where a pnpm-lock.yaml changes without its sibling
# package.json. A lockfile that moves on its own means someone (or some
# bot) re-resolved dependencies wholesale, which is how PR #270 shipped a
# broken zod major bump inside @polar-sh/sdk. See issue #271.
name: Lockfile guard
on:
pull_request:
paths:
- '**/pnpm-lock.yaml'
- '**/package.json'
jobs:
guard:
name: Lockfile changes match a package.json change
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
fetch-depth: 0
persist-credentials: false
- name: Fail if a lockfile changed without its package.json
env:
BASE_REF: ${{ github.base_ref }}
run: |
changed="$(git diff --name-only "origin/${BASE_REF}"...HEAD)"
failed=0
for app in api web; do
if grep -qx "$app/pnpm-lock.yaml" <<<"$changed" \
&& ! grep -qx "$app/package.json" <<<"$changed"; then
echo "::error::$app/pnpm-lock.yaml changed without $app/package.json. Restore the lockfile, or include the package.json change that motivates it."
failed=1
fi
done
exit "$failed"
+136
View File
@@ -0,0 +1,136 @@
name: Web
on:
push:
branches:
- main
- dev
paths:
- 'web/**'
- '.github/workflows/web.yaml'
pull_request:
branches:
- main
- dev
paths:
- 'web/**'
- '.github/workflows/web.yaml'
workflow_dispatch:
inputs:
branch:
description: 'Branch to run workflow on'
required: true
default: 'main'
type: string
permissions:
contents: read
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
jobs:
checks:
name: Lint, typecheck, unit test, build
runs-on: ubuntu-latest
timeout-minutes: 15
defaults:
run:
working-directory: web
steps:
- name: Checkout repository
uses: actions/checkout@v7
with:
ref: ${{ github.event_name == 'workflow_dispatch' && inputs.branch || github.ref }}
persist-credentials: false
# pnpm has to be on PATH before setup-node, or its pnpm cache resolution
# has no package manager to query.
- name: Install pnpm
uses: pnpm/action-setup@v6
with:
package_json_file: web/package.json
run_install: false
- name: Set up Node.js
uses: actions/setup-node@v7
with:
node-version: '20'
cache: pnpm
cache-dependency-path: web/pnpm-lock.yaml
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Lint
run: pnpm lint
- name: Typecheck
run: pnpm typecheck
- name: Unit test
run: pnpm test
- name: Build
run: pnpm run build
# E2e is fully mocked (e2e/mock-api.ts intercepts every backend call), but it
# drives a real browser and playwright's webServer does its own production
# build, so it ran about three times longer than everything else combined.
# It sits in its own job so it never delays the blocking signal above.
#
# TODO: remove continue-on-error once e2e has proven stable across a few
# merges. It is here to avoid wedging on a browser flake, not because e2e
# failures are acceptable.
e2e:
name: e2e
runs-on: ubuntu-latest
timeout-minutes: 30
continue-on-error: true
defaults:
run:
working-directory: web
steps:
- name: Checkout repository
uses: actions/checkout@v7
with:
ref: ${{ github.event_name == 'workflow_dispatch' && inputs.branch || github.ref }}
persist-credentials: false
- name: Install pnpm
uses: pnpm/action-setup@v6
with:
package_json_file: web/package.json
run_install: false
- name: Set up Node.js
uses: actions/setup-node@v7
with:
node-version: '20'
cache: pnpm
cache-dependency-path: web/pnpm-lock.yaml
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Install Playwright browser
run: pnpm exec playwright install --with-deps chromium
# No build step: playwright.config.ts runs `pnpm build && pnpm start` as
# its webServer, and the checks job above already gates the build.
- name: Run e2e
run: pnpm test:e2e
- name: Upload playwright report
if: always()
uses: actions/upload-artifact@v7
with:
name: playwright-report-${{ github.run_id }}
path: web/playwright-report/
retention-days: 7
if-no-files-found: ignore
+5 -1
View File
@@ -2,4 +2,8 @@
.DS_Store
*-monitor
.cursor
.cursor
.claude/
.omc/
# Rendered email previews (pnpm preview:emails)
api/tmp/
+1 -1
View File
@@ -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
+131 -49
View File
@@ -1,97 +1,179 @@
![GitHub stars](https://img.shields.io/github/stars/textbee/textbee)
![License](https://img.shields.io/github/license/textbee/textbee)
![Release](https://img.shields.io/github/v/release/textbee/textbee)
[![Discord](https://img.shields.io/discord/1236287182940016723?label=Discord&logo=discord)](https://textbee.dev/discord)
# 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/)
The open-source SMS gateway for developers, automations, and AI agents. Manage SMS messages through a web dashboard, a REST API, or the [MCP server](#use-with-ai-agents-mcp) for AI agents. textbee is ideal for businesses, developers, and hobbyists looking for a reliable and cost-effective solution to automate SMS messaging.
![](https://ik.imagekit.io/vernu/textbee/textbee.dev-landingpage-screenshot.png?updatedAt=1749102564772)
**Website:** [https://textbee.dev](https://textbee.dev?ref=gh-readme)
![textbee.dev landing page](.github/assets/landing-page.png)
## 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
Messages go out through your default device, or otherwise the enabled device with the most recent heartbeat. Pass an optional `deviceId` in the request body to send from a specific device instead. The older `/gateway/devices/{deviceId}/send-sms` route still works but is deprecated.
```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' ],
await axios.post('https://api.textbee.dev/api/v1/gateway/send-sms', {
recipients: [ '+12025550123' ],
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'
requests.post(
'https://api.textbee.dev/api/v1/gateway/send-sms',
json={
'recipients': ['+12025550123'],
'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" \
curl -X POST "https://api.textbee.dev/api/v1/gateway/send-sms" \
-H 'x-api-key: YOUR_API_KEY' \
-H 'Content-Type: application/json' \
-d '{
"recipients": [ "+251912345678" ],
"recipients": [ "+12025550123" ],
"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. Message history is account-level: one call covers every device, no device id needed.
```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,
},
await axios.get('https://api.textbee.dev/api/v1/gateway/messages?direction=received', {
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/messages?direction=received" \
-H "x-api-key: YOUR_API_KEY"
```
</details>
### Use with AI agents (MCP)
The official [textbee MCP server](https://github.com/textbee/textbee-mcp) lets Claude Desktop, Claude Code, Cursor, and any MCP-compatible client send and read SMS through your account. Your API key stays on your machine, and plan limits apply server-side like any other send.
```json
{
"mcpServers": {
"textbee": {
"command": "npx",
"args": ["-y", "@textbee/mcp"],
"env": { "TEXTBEE_API_KEY": "YOUR_API_KEY" }
}
}
}
```
Three tools: `send_sms` (send to one or many recipients), `get_messages` (read replies and verification codes, check delivery status), and `list_devices`. Self-hosted instances work with `TEXTBEE_BASE_URL`. Details at [textbee.dev/mcp](https://textbee.dev/mcp?ref=gh-readme) and the [agent docs](https://textbee.dev/docs/agents/mcp?ref=gh-readme).
## 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.
@@ -167,7 +249,7 @@ curl -X GET "https://api.textbee.dev/api/v1/gateway/devices/YOUR_DEVICE_ID/get-r
&& cd ../api && cp .env.example .env
```
2. Navigate to root folder and execute docker-compose.yml file.
This will spin up `web` container, `api` container alongside with `MongoDB` and `MongoExpress`. `TextBee` database will be automatically created.
This will spin up `web` container, `api` container alongside with `MongoDB` and `MongoExpress`. `textbee` database will be automatically created.
```bash
docker compose up -d
```
@@ -180,16 +262,16 @@ curl -X GET "https://api.textbee.dev/api/v1/gateway/devices/YOUR_DEVICE_ID/get-r
Contributions are welcome!
1. [Fork](https://github.com/vernu/textbee/fork) the project.
1. [Fork](https://github.com/textbee/textbee/fork) the project.
2. Create a feature or bugfix branch from `main` branch.
3. Make sure your commit messages and PR comment summaries are descriptive.
4. Create a pull request to the `main` branch.
## Bug Reporting and Feature Requests
Please feel free to [create an issue](https://github.com/vernu/textbee/issues/new) in the repository for any bug reports or feature requests. Make sure to provide a detailed description of the issue or feature you are requesting and properly label whether it is a bug or a feature request.
Please feel free to [create an issue](https://github.com/textbee/textbee/issues/new) in the repository for any bug reports or feature requests. Make sure to provide a detailed description of the issue or feature you are requesting and properly label whether it is a bug or a feature request.
Please note that if you discover any vulnerability or security issue, we kindly request that you refrain from creating a public issue. Instead, send an email detailing the vulnerability to contact@textbee.dev.
## For support, feedback, and questions
Feel free to reach out to us at contact@textbee.dev or [Join our Discord server](https://discord.gg/d7vyfBpWbQ)
Feel free to reach out to us at contact@textbee.dev or [Join our Discord server](https://textbee.dev/discord)
+1 -1
View File
@@ -1 +1 @@
TextBee
textbee
+107
View File
@@ -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
+191
View File
@@ -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()`
+37 -6
View File
@@ -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 16
versionName "2.7.0"
targetSdk 34
versionCode 18
versionName "2.8.0"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
@@ -68,12 +70,25 @@ android {
sourceCompatibility JavaVersion.VERSION_1_8
targetCompatibility JavaVersion.VERSION_1_8
}
kotlinOptions {
jvmTarget = '1.8'
}
buildFeatures {
compose true
}
composeOptions {
kotlinCompilerExtensionVersion '1.4.8'
}
}
dependencies {
implementation 'androidx.appcompat:appcompat:1.3.0'
implementation 'com.google.android.material:material:1.8.0'
implementation 'androidx.recyclerview:recyclerview:1.3.2'
implementation 'androidx.constraintlayout:constraintlayout:2.0.4'
testImplementation 'junit:junit:4.13.2'
androidTestImplementation 'androidx.test.ext:junit:1.1.3'
@@ -88,13 +103,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'
+18 -8
View File
@@ -9,11 +9,10 @@
<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
@@ -23,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"
@@ -42,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>
@@ -72,15 +71,26 @@
</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"
@@ -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
};
@@ -24,4 +23,9 @@ public class AppConstants {
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";
}
@@ -21,8 +21,8 @@ import java.util.ArrayList;
import java.util.List;
import java.util.Map;
public class TextBeeUtils {
private static final String TAG = "TextBeeUtils";
public class TextbeeUtils {
private static final String TAG = "TextbeeUtils";
public static boolean isPermissionGranted(Context context, String permission) {
return ContextCompat.checkSelfPermission(context, permission) == PackageManager.PERMISSION_GRANTED;
@@ -199,9 +199,17 @@ public class TextBeeUtils {
Log.d(TAG, "Could not get SIM slot index for subscription " + subscriptionInfo.getSubscriptionId());
}
// Get MCC
// Get MCC (getMccString() is API 29+; use getMcc() on older devices)
try {
String mcc = subscriptionInfo.getMccString();
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);
}
@@ -209,9 +217,17 @@ public class TextBeeUtils {
Log.d(TAG, "Could not get MCC for subscription " + subscriptionInfo.getSubscriptionId());
}
// Get MNC
// Get MNC (getMncString() is API 29+; use getMnc() on older devices)
try {
String mnc = subscriptionInfo.getMncString();
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);
}
@@ -10,6 +10,10 @@ 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;
@@ -27,7 +31,7 @@ import com.google.zxing.integration.android.IntentResult;
import com.vernu.sms.ApiManager;
import com.vernu.sms.AppConstants;
import com.vernu.sms.BuildConfig;
import com.vernu.sms.TextBeeUtils;
import com.vernu.sms.TextbeeUtils;
import com.vernu.sms.R;
import com.vernu.sms.dtos.RegisterDeviceInputDTO;
import com.vernu.sms.dtos.RegisterDeviceResponseDTO;
@@ -49,13 +53,16 @@ public class MainActivity extends AppCompatActivity {
private Context mContext;
private Switch gatewaySwitch, receiveSMSSwitch, stickyNotificationSwitch;
private EditText apiKeyEditText, fcmTokenEditText, deviceIdEditText, deviceNameEditText;
private Button registerDeviceBtn, grantSMSPermissionBtn, scanQRBtn, checkUpdatesBtn, configureFilterBtn;
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";
@@ -84,6 +91,8 @@ public class MainActivity extends AppCompatActivity {
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);
@@ -111,7 +120,7 @@ public class MainActivity extends AppCompatActivity {
boolean gatewayEnabled = SharedPreferenceHelper.getSharedPreferenceBoolean(mContext, AppConstants.SHARED_PREFS_GATEWAY_ENABLED_KEY, false);
boolean stickyNotificationEnabled = SharedPreferenceHelper.getSharedPreferenceBoolean(mContext, AppConstants.SHARED_PREFS_STICKY_NOTIFICATION_ENABLED_KEY, false);
if (gatewayEnabled && stickyNotificationEnabled) {
TextBeeUtils.startStickyNotificationService(mContext);
TextbeeUtils.startStickyNotificationService(mContext);
Log.d(TAG, "Starting sticky notification service on app start");
}
@@ -127,7 +136,7 @@ public class MainActivity extends AppCompatActivity {
registerDeviceBtn.setText("Update");
}
String[] missingPermissions = Arrays.stream(AppConstants.requiredPermissions).filter(permission -> !TextBeeUtils.isPermissionGranted(mContext, permission)).toArray(String[]::new);
String[] missingPermissions = Arrays.stream(AppConstants.requiredPermissions).filter(permission -> !TextbeeUtils.isPermissionGranted(mContext, permission)).toArray(String[]::new);
if (missingPermissions.length == 0) {
grantSMSPermissionBtn.setEnabled(false);
grantSMSPermissionBtn.setText("Permission Granted");
@@ -138,7 +147,7 @@ public class MainActivity extends AppCompatActivity {
grantSMSPermissionBtn.setOnClickListener(this::handleRequestPermissions);
}
// TextBeeUtils.startStickyNotificationService(mContext);
// TextbeeUtils.startStickyNotificationService(mContext);
copyDeviceIdImgBtn.setOnClickListener(view -> {
ClipboardManager clipboard = (ClipboardManager) getSystemService(Context.CLIPBOARD_SERVICE);
@@ -182,12 +191,12 @@ public class MainActivity extends AppCompatActivity {
if (enabled) {
// Check if sticky notification is enabled
if (SharedPreferenceHelper.getSharedPreferenceBoolean(mContext, AppConstants.SHARED_PREFS_STICKY_NOTIFICATION_ENABLED_KEY, false)) {
TextBeeUtils.startStickyNotificationService(mContext);
TextbeeUtils.startStickyNotificationService(mContext);
}
// Schedule heartbeat
HeartbeatManager.scheduleHeartbeat(mContext);
} else {
TextBeeUtils.stopStickyNotificationService(mContext);
TextbeeUtils.stopStickyNotificationService(mContext);
// Cancel heartbeat
HeartbeatManager.cancelHeartbeat(mContext);
}
@@ -198,7 +207,7 @@ public class MainActivity extends AppCompatActivity {
Snackbar.make(view, "An error occurred :(", Snackbar.LENGTH_LONG).show();
Log.e(TAG, "API_ERROR "+ t.getMessage());
Log.e(TAG, "API_ERROR "+ t.getLocalizedMessage());
TextBeeUtils.logException(t, "Error updating device");
TextbeeUtils.logException(t, "Error updating device");
compoundButton.setEnabled(true);
}
});
@@ -219,10 +228,10 @@ public class MainActivity extends AppCompatActivity {
SharedPreferenceHelper.setSharedPreferenceBoolean(mContext, AppConstants.SHARED_PREFS_STICKY_NOTIFICATION_ENABLED_KEY, isChecked);
if (isChecked) {
TextBeeUtils.startStickyNotificationService(mContext);
TextbeeUtils.startStickyNotificationService(mContext);
Snackbar.make(view, "Background service enabled - app will be more reliable", Snackbar.LENGTH_LONG).show();
} else {
TextBeeUtils.stopStickyNotificationService(mContext);
TextbeeUtils.stopStickyNotificationService(mContext);
Snackbar.make(view, "Background service disabled - app may be killed when in background", Snackbar.LENGTH_LONG).show();
}
});
@@ -255,6 +264,70 @@ public class MainActivity extends AppCompatActivity {
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() {
@@ -273,7 +346,7 @@ public class MainActivity extends AppCompatActivity {
defaultSimSlotRadioGroup.addView(defaultSimSlotRadioBtn);
// Create radio buttons for each SIM with proper styling
TextBeeUtils.getAvailableSimSlots(mContext).forEach(subscriptionInfo -> {
TextbeeUtils.getAvailableSimSlots(mContext).forEach(subscriptionInfo -> {
String displayName = subscriptionInfo.getDisplayName() != null ? subscriptionInfo.getDisplayName().toString() : "Unknown";
String simInfo = displayName + " (Subscription ID: " + subscriptionInfo.getSubscriptionId() + ")";
RadioButton radioButton = new RadioButton(mContext);
@@ -391,7 +464,7 @@ public class MainActivity extends AppCompatActivity {
if (requestCode != PERMISSION_REQUEST_CODE) {
return;
}
boolean allPermissionsGranted = Arrays.stream(permissions).allMatch(permission -> TextBeeUtils.isPermissionGranted(mContext, permission));
boolean allPermissionsGranted = Arrays.stream(permissions).allMatch(permission -> TextbeeUtils.isPermissionGranted(mContext, permission));
if (allPermissionsGranted) {
Snackbar.make(findViewById(R.id.grantSMSPermissionBtn), "All Permissions Granted", Snackbar.LENGTH_SHORT).show();
grantSMSPermissionBtn.setEnabled(false);
@@ -428,7 +501,10 @@ public class MainActivity extends AppCompatActivity {
registerDeviceInput.setManufacturer(Build.MANUFACTURER);
registerDeviceInput.setModel(Build.MODEL);
registerDeviceInput.setBuildId(Build.ID);
registerDeviceInput.setOs(Build.VERSION.BASE_OS);
registerDeviceInput.setOs("Android");
registerDeviceInput.setOsVersion(Build.VERSION.RELEASE);
registerDeviceInput.setOsApiLevel(Build.VERSION.SDK_INT);
registerDeviceInput.setOsBuildFingerprint(Build.VERSION.BASE_OS);
registerDeviceInput.setAppVersionCode(BuildConfig.VERSION_CODE);
registerDeviceInput.setAppVersionName(BuildConfig.VERSION_NAME);
@@ -442,7 +518,7 @@ public class MainActivity extends AppCompatActivity {
// Collect SIM information
SimInfoCollectionDTO simInfoCollection = new SimInfoCollectionDTO();
simInfoCollection.setLastUpdated(System.currentTimeMillis());
simInfoCollection.setSims(TextBeeUtils.collectSimInfo(mContext));
simInfoCollection.setSims(TextbeeUtils.collectSimInfo(mContext));
registerDeviceInput.setSimInfo(simInfoCollection);
// If the user provided a device ID, use it for updating instead of creating new
@@ -507,7 +583,7 @@ public class MainActivity extends AppCompatActivity {
Snackbar.make(view, "An error occurred :(", Snackbar.LENGTH_LONG).show();
Log.e(TAG, "API_ERROR "+ t.getMessage());
Log.e(TAG, "API_ERROR "+ t.getLocalizedMessage());
TextBeeUtils.logException(t, "Error registering device");
TextbeeUtils.logException(t, "Error registering device");
registerDeviceBtn.setEnabled(true);
registerDeviceBtn.setText("Update");
}
@@ -572,7 +648,7 @@ public class MainActivity extends AppCompatActivity {
Snackbar.make(view, "An error occurred :(", Snackbar.LENGTH_LONG).show();
Log.e(TAG, "API_ERROR "+ t.getMessage());
Log.e(TAG, "API_ERROR "+ t.getLocalizedMessage());
TextBeeUtils.logException(t, "Error registering device");
TextbeeUtils.logException(t, "Error registering device");
registerDeviceBtn.setEnabled(true);
registerDeviceBtn.setText("Update");
}
@@ -607,7 +683,10 @@ public class MainActivity extends AppCompatActivity {
updateDeviceInput.setManufacturer(Build.MANUFACTURER);
updateDeviceInput.setModel(Build.MODEL);
updateDeviceInput.setBuildId(Build.ID);
updateDeviceInput.setOs(Build.VERSION.BASE_OS);
updateDeviceInput.setOs("Android");
updateDeviceInput.setOsVersion(Build.VERSION.RELEASE);
updateDeviceInput.setOsApiLevel(Build.VERSION.SDK_INT);
updateDeviceInput.setOsBuildFingerprint(Build.VERSION.BASE_OS);
updateDeviceInput.setAppVersionCode(BuildConfig.VERSION_CODE);
updateDeviceInput.setAppVersionName(BuildConfig.VERSION_NAME);
@@ -621,7 +700,7 @@ public class MainActivity extends AppCompatActivity {
// Collect SIM information
SimInfoCollectionDTO simInfoCollection = new SimInfoCollectionDTO();
simInfoCollection.setLastUpdated(System.currentTimeMillis());
simInfoCollection.setSims(TextBeeUtils.collectSimInfo(mContext));
simInfoCollection.setSims(TextbeeUtils.collectSimInfo(mContext));
updateDeviceInput.setSimInfo(simInfoCollection);
Call<RegisterDeviceResponseDTO> apiCall = ApiManager.getApiService().updateDevice(deviceIdToUse, apiKey, updateDeviceInput);
@@ -681,7 +760,7 @@ public class MainActivity extends AppCompatActivity {
Snackbar.make(view, "An error occurred :(", Snackbar.LENGTH_LONG).show();
Log.e(TAG, "API_ERROR "+ t.getMessage());
Log.e(TAG, "API_ERROR "+ t.getLocalizedMessage());
TextBeeUtils.logException(t, "Error updating device");
TextbeeUtils.logException(t, "Error updating device");
registerDeviceBtn.setEnabled(true);
registerDeviceBtn.setText("Update");
}
@@ -690,12 +769,12 @@ public class MainActivity extends AppCompatActivity {
}
private void handleRequestPermissions(View view) {
boolean allPermissionsGranted = Arrays.stream(AppConstants.requiredPermissions).allMatch(permission -> TextBeeUtils.isPermissionGranted(mContext, permission));
boolean allPermissionsGranted = Arrays.stream(AppConstants.requiredPermissions).allMatch(permission -> TextbeeUtils.isPermissionGranted(mContext, permission));
if (allPermissionsGranted) {
Snackbar.make(view, "Already got permissions", Snackbar.LENGTH_SHORT).show();
return;
}
String[] permissionsToRequest = Arrays.stream(AppConstants.requiredPermissions).filter(permission -> !TextBeeUtils.isPermissionGranted(mContext, permission)).toArray(String[]::new);
String[] permissionsToRequest = Arrays.stream(AppConstants.requiredPermissions).filter(permission -> !TextbeeUtils.isPermissionGranted(mContext, permission)).toArray(String[]::new);
Snackbar.make(view, "Please Grant Required Permissions to continue", Snackbar.LENGTH_SHORT).show();
ActivityCompat.requestPermissions(this, permissionsToRequest, PERMISSION_REQUEST_CODE);
}
@@ -238,8 +238,14 @@ public class SMSFilterActivity extends AppCompatActivity {
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));
holder.editButton.setOnClickListener(v -> {
int pos = holder.getBindingAdapterPosition();
if (pos != RecyclerView.NO_POSITION) showAddEditRuleDialog(pos);
});
holder.deleteButton.setOnClickListener(v -> {
int pos = holder.getBindingAdapterPosition();
if (pos != RecyclerView.NO_POSITION) deleteRule(pos);
});
}
@Override
@@ -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
)
@@ -1,151 +0,0 @@
package com.vernu.sms.dtos;
public class HeartbeatInputDTO {
private String fcmToken;
private Integer batteryPercentage;
private Boolean isCharging;
private String networkType;
private String appVersionName;
private Integer appVersionCode;
private Long deviceUptimeMillis;
private Long memoryFreeBytes;
private Long memoryTotalBytes;
private Long memoryMaxBytes;
private Long storageAvailableBytes;
private Long storageTotalBytes;
private String timezone;
private String locale;
private Boolean receiveSMSEnabled;
private SimInfoCollectionDTO simInfo;
public HeartbeatInputDTO() {
}
public String getFcmToken() {
return fcmToken;
}
public void setFcmToken(String fcmToken) {
this.fcmToken = fcmToken;
}
public Integer getBatteryPercentage() {
return batteryPercentage;
}
public void setBatteryPercentage(Integer batteryPercentage) {
this.batteryPercentage = batteryPercentage;
}
public Boolean getIsCharging() {
return isCharging;
}
public void setIsCharging(Boolean isCharging) {
this.isCharging = isCharging;
}
public String getNetworkType() {
return networkType;
}
public void setNetworkType(String networkType) {
this.networkType = networkType;
}
public String getAppVersionName() {
return appVersionName;
}
public void setAppVersionName(String appVersionName) {
this.appVersionName = appVersionName;
}
public Integer getAppVersionCode() {
return appVersionCode;
}
public void setAppVersionCode(Integer appVersionCode) {
this.appVersionCode = appVersionCode;
}
public Long getDeviceUptimeMillis() {
return deviceUptimeMillis;
}
public void setDeviceUptimeMillis(Long deviceUptimeMillis) {
this.deviceUptimeMillis = deviceUptimeMillis;
}
public Long getMemoryFreeBytes() {
return memoryFreeBytes;
}
public void setMemoryFreeBytes(Long memoryFreeBytes) {
this.memoryFreeBytes = memoryFreeBytes;
}
public Long getMemoryTotalBytes() {
return memoryTotalBytes;
}
public void setMemoryTotalBytes(Long memoryTotalBytes) {
this.memoryTotalBytes = memoryTotalBytes;
}
public Long getMemoryMaxBytes() {
return memoryMaxBytes;
}
public void setMemoryMaxBytes(Long memoryMaxBytes) {
this.memoryMaxBytes = memoryMaxBytes;
}
public Long getStorageAvailableBytes() {
return storageAvailableBytes;
}
public void setStorageAvailableBytes(Long storageAvailableBytes) {
this.storageAvailableBytes = storageAvailableBytes;
}
public Long getStorageTotalBytes() {
return storageTotalBytes;
}
public void setStorageTotalBytes(Long storageTotalBytes) {
this.storageTotalBytes = storageTotalBytes;
}
public String getTimezone() {
return timezone;
}
public void setTimezone(String timezone) {
this.timezone = timezone;
}
public String getLocale() {
return locale;
}
public void setLocale(String locale) {
this.locale = locale;
}
public Boolean getReceiveSMSEnabled() {
return receiveSMSEnabled;
}
public void setReceiveSMSEnabled(Boolean receiveSMSEnabled) {
this.receiveSMSEnabled = receiveSMSEnabled;
}
public SimInfoCollectionDTO getSimInfo() {
return simInfo;
}
public void setSimInfo(SimInfoCollectionDTO simInfo) {
this.simInfo = simInfo;
}
}
@@ -0,0 +1,23 @@
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 osVersion: String? = null
var osApiLevel: 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
}
@@ -1,8 +0,0 @@
package com.vernu.sms.dtos;
public class HeartbeatResponseDTO {
public boolean success;
public boolean fcmTokenUpdated;
public long lastHeartbeat;
public String name;
}
@@ -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,128 +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 name;
private String serial;
private String buildId;
private String os;
private String osVersion;
private String appVersionName;
private int appVersionCode;
private SimInfoCollectionDTO simInfo;
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 getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
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;
}
public SimInfoCollectionDTO getSimInfo() {
return simInfo;
}
public void setSimInfo(SimInfoCollectionDTO simInfo) {
this.simInfo = simInfo;
}
}
@@ -0,0 +1,19 @@
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 osApiLevel: Int? = null
var osBuildFingerprint: 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,119 +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 fingerprint;
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;
}
public String getFingerprint() {
return fingerprint;
}
public void setFingerprint(String fingerprint) {
this.fingerprint = fingerprint;
}
}
@@ -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
@@ -1,27 +0,0 @@
package com.vernu.sms.dtos;
import java.util.List;
public class SimInfoCollectionDTO {
private long lastUpdated;
private List<SimInfoDTO> sims;
public SimInfoCollectionDTO() {
}
public long getLastUpdated() {
return lastUpdated;
}
public void setLastUpdated(long lastUpdated) {
this.lastUpdated = lastUpdated;
}
public List<SimInfoDTO> getSims() {
return sims;
}
public void setSims(List<SimInfoDTO> sims) {
this.sims = sims;
}
}
@@ -0,0 +1,6 @@
package com.vernu.sms.dtos
class SimInfoCollectionDTO {
var lastUpdated: Long = 0
var sims: MutableList<SimInfoDTO>? = null
}
@@ -1,97 +0,0 @@
package com.vernu.sms.dtos;
public class SimInfoDTO {
private int subscriptionId;
private String iccId;
private Integer cardId;
private String carrierName;
private String displayName;
private Integer simSlotIndex;
private String mcc;
private String mnc;
private String countryIso;
private String subscriptionType;
public SimInfoDTO() {
}
public int getSubscriptionId() {
return subscriptionId;
}
public void setSubscriptionId(int subscriptionId) {
this.subscriptionId = subscriptionId;
}
public String getIccId() {
return iccId;
}
public void setIccId(String iccId) {
this.iccId = iccId;
}
public Integer getCardId() {
return cardId;
}
public void setCardId(Integer cardId) {
this.cardId = cardId;
}
public String getCarrierName() {
return carrierName;
}
public void setCarrierName(String carrierName) {
this.carrierName = carrierName;
}
public String getDisplayName() {
return displayName;
}
public void setDisplayName(String displayName) {
this.displayName = displayName;
}
public Integer getSimSlotIndex() {
return simSlotIndex;
}
public void setSimSlotIndex(Integer simSlotIndex) {
this.simSlotIndex = simSlotIndex;
}
public String getMcc() {
return mcc;
}
public void setMcc(String mcc) {
this.mcc = mcc;
}
public String getMnc() {
return mnc;
}
public void setMnc(String mnc) {
this.mnc = mnc;
}
public String getCountryIso() {
return countryIso;
}
public void setCountryIso(String countryIso) {
this.countryIso = countryIso;
}
public String getSubscriptionType() {
return subscriptionType;
}
public void setSubscriptionType(String subscriptionType) {
this.subscriptionType = subscriptionType;
}
}
@@ -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
}
}
@@ -1,220 +0,0 @@
package com.vernu.sms.helpers;
import android.content.Context;
import android.content.Intent;
import android.content.IntentFilter;
import android.net.ConnectivityManager;
import android.net.NetworkInfo;
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.dtos.HeartbeatInputDTO;
import com.vernu.sms.dtos.HeartbeatResponseDTO;
import com.vernu.sms.dtos.SimInfoCollectionDTO;
import com.vernu.sms.TextBeeUtils;
import java.io.File;
import java.io.IOException;
import java.util.Locale;
import java.util.TimeZone;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
import retrofit2.Call;
import retrofit2.Response;
public class HeartbeatHelper {
private static final String TAG = "HeartbeatHelper";
/**
* Collects device information and sends a heartbeat request to the API.
*
* @param context Application context
* @param deviceId Device ID
* @param apiKey API key for authentication
* @return true if heartbeat was sent successfully, false otherwise
*/
public static boolean sendHeartbeat(Context context, String deviceId, String apiKey) {
if (deviceId == null || deviceId.isEmpty()) {
Log.d(TAG, "Device not registered, skipping heartbeat");
return false;
}
if (apiKey == null || apiKey.isEmpty()) {
Log.e(TAG, "API key not available, skipping heartbeat");
return false;
}
// Collect device information
HeartbeatInputDTO heartbeatInput = new HeartbeatInputDTO();
try {
// Get FCM token (blocking wait)
try {
CountDownLatch latch = new CountDownLatch(1);
final String[] fcmToken = new String[1];
FirebaseMessaging.getInstance().getToken().addOnCompleteListener(task -> {
if (task.isSuccessful()) {
fcmToken[0] = task.getResult();
}
latch.countDown();
});
if (latch.await(5, TimeUnit.SECONDS) && fcmToken[0] != null) {
heartbeatInput.setFcmToken(fcmToken[0]);
}
} catch (Exception e) {
Log.e(TAG, "Failed to get FCM token: " + e.getMessage());
// Continue without FCM token
}
// Get battery information
IntentFilter ifilter = new IntentFilter(Intent.ACTION_BATTERY_CHANGED);
Intent batteryStatus = context.registerReceiver(null, ifilter);
if (batteryStatus != null) {
int level = batteryStatus.getIntExtra(BatteryManager.EXTRA_LEVEL, -1);
int scale = batteryStatus.getIntExtra(BatteryManager.EXTRA_SCALE, -1);
int batteryPct = (int) ((level / (float) scale) * 100);
heartbeatInput.setBatteryPercentage(batteryPct);
int status = batteryStatus.getIntExtra(BatteryManager.EXTRA_STATUS, -1);
boolean isCharging = status == BatteryManager.BATTERY_STATUS_CHARGING ||
status == BatteryManager.BATTERY_STATUS_FULL;
heartbeatInput.setIsCharging(isCharging);
}
// Get network type
ConnectivityManager cm = (ConnectivityManager) context.getSystemService(Context.CONNECTIVITY_SERVICE);
if (cm != null) {
NetworkInfo activeNetwork = cm.getActiveNetworkInfo();
if (activeNetwork != null && activeNetwork.isConnected()) {
if (activeNetwork.getType() == ConnectivityManager.TYPE_WIFI) {
heartbeatInput.setNetworkType("wifi");
} else if (activeNetwork.getType() == ConnectivityManager.TYPE_MOBILE) {
heartbeatInput.setNetworkType("cellular");
} else {
heartbeatInput.setNetworkType("none");
}
} else {
heartbeatInput.setNetworkType("none");
}
}
// Get app version
heartbeatInput.setAppVersionName(BuildConfig.VERSION_NAME);
heartbeatInput.setAppVersionCode(BuildConfig.VERSION_CODE);
// Get device uptime
heartbeatInput.setDeviceUptimeMillis(SystemClock.uptimeMillis());
// Get memory information
Runtime runtime = Runtime.getRuntime();
heartbeatInput.setMemoryFreeBytes(runtime.freeMemory());
heartbeatInput.setMemoryTotalBytes(runtime.totalMemory());
heartbeatInput.setMemoryMaxBytes(runtime.maxMemory());
// Get storage information
File internalStorage = context.getFilesDir();
StatFs statFs = new StatFs(internalStorage.getPath());
long availableBytes = statFs.getAvailableBytes();
long totalBytes = statFs.getTotalBytes();
heartbeatInput.setStorageAvailableBytes(availableBytes);
heartbeatInput.setStorageTotalBytes(totalBytes);
// Get system information
heartbeatInput.setTimezone(TimeZone.getDefault().getID());
heartbeatInput.setLocale(Locale.getDefault().toString());
// Get receive SMS enabled status
boolean receiveSMSEnabled = SharedPreferenceHelper.getSharedPreferenceBoolean(
context,
AppConstants.SHARED_PREFS_RECEIVE_SMS_ENABLED_KEY,
false
);
heartbeatInput.setReceiveSMSEnabled(receiveSMSEnabled);
// Collect SIM information
SimInfoCollectionDTO simInfoCollection = new SimInfoCollectionDTO();
simInfoCollection.setLastUpdated(System.currentTimeMillis());
simInfoCollection.setSims(TextBeeUtils.collectSimInfo(context));
heartbeatInput.setSimInfo(simInfoCollection);
// Send heartbeat request
Call<HeartbeatResponseDTO> call = ApiManager.getApiService().heartbeat(deviceId, apiKey, heartbeatInput);
Response<HeartbeatResponseDTO> response = call.execute();
if (response.isSuccessful() && response.body() != null) {
HeartbeatResponseDTO responseBody = response.body();
if (responseBody.fcmTokenUpdated) {
Log.d(TAG, "FCM token was updated during heartbeat");
}
// Sync device name from heartbeat response (ignore if blank)
if (responseBody.name != null && !responseBody.name.trim().isEmpty()) {
SharedPreferenceHelper.setSharedPreferenceString(
context,
AppConstants.SHARED_PREFS_DEVICE_NAME_KEY,
responseBody.name
);
Log.d(TAG, "Synced device name from heartbeat: " + responseBody.name);
}
Log.d(TAG, "Heartbeat sent successfully");
return true;
} else {
Log.e(TAG, "Failed to send heartbeat. Response code: " + (response.code()));
return false;
}
} catch (IOException e) {
Log.e(TAG, "Heartbeat API call failed: " + e.getMessage());
return false;
} catch (Exception e) {
Log.e(TAG, "Error collecting device information: " + e.getMessage());
return false;
}
}
/**
* Checks if device is eligible to send heartbeat (registered, enabled, heartbeat enabled).
*
* @param context Application context
* @return true if device is eligible, false otherwise
*/
public static boolean isDeviceEligibleForHeartbeat(Context context) {
// Check if device is registered
String deviceId = SharedPreferenceHelper.getSharedPreferenceString(
context,
AppConstants.SHARED_PREFS_DEVICE_ID_KEY,
""
);
if (deviceId.isEmpty()) {
return false;
}
// Check if device is enabled
boolean deviceEnabled = SharedPreferenceHelper.getSharedPreferenceBoolean(
context,
AppConstants.SHARED_PREFS_GATEWAY_ENABLED_KEY,
false
);
if (!deviceEnabled) {
return false;
}
// Check if heartbeat feature is enabled
boolean heartbeatEnabled = SharedPreferenceHelper.getSharedPreferenceBoolean(
context,
AppConstants.SHARED_PREFS_HEARTBEAT_ENABLED_KEY,
true // Default to true
);
return heartbeatEnabled;
}
}
@@ -0,0 +1,167 @@
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.Build
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
// OS version, so devices backfill without needing to re-register
heartbeatInput.osVersion = Build.VERSION.RELEASE
heartbeatInput.osApiLevel = Build.VERSION.SDK_INT
// 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
)
}
}
@@ -1,88 +0,0 @@
package com.vernu.sms.helpers;
import android.content.Context;
import android.util.Log;
import androidx.work.Constraints;
import androidx.work.ExistingPeriodicWorkPolicy;
import androidx.work.NetworkType;
import androidx.work.PeriodicWorkRequest;
import androidx.work.WorkManager;
import com.vernu.sms.AppConstants;
import com.vernu.sms.workers.HeartbeatWorker;
import java.util.concurrent.TimeUnit;
public class HeartbeatManager {
private static final String TAG = "HeartbeatManager";
private static final int MIN_INTERVAL_MINUTES = 15; // Android WorkManager minimum
private static final String UNIQUE_WORK_NAME = "heartbeat_unique_work";
public static void scheduleHeartbeat(Context context) {
// Use application context to ensure WorkManager works even when app is closed
Context appContext = context.getApplicationContext();
// Get interval from shared preferences (default 30 minutes)
int intervalMinutes = SharedPreferenceHelper.getSharedPreferenceInt(
appContext,
AppConstants.SHARED_PREFS_HEARTBEAT_INTERVAL_MINUTES_KEY,
30
);
// Enforce minimum interval
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");
// Create constraints
Constraints constraints = new Constraints.Builder()
.setRequiredNetworkType(NetworkType.CONNECTED)
.build();
// Create periodic work request
PeriodicWorkRequest heartbeatWork = new PeriodicWorkRequest.Builder(
HeartbeatWorker.class,
intervalMinutes,
TimeUnit.MINUTES
)
.setConstraints(constraints)
.addTag(AppConstants.HEARTBEAT_WORK_TAG)
.build();
// Use enqueueUniquePeriodicWork to ensure only one periodic work exists
// This ensures the work persists across app restarts and device reboots
WorkManager.getInstance(appContext)
.enqueueUniquePeriodicWork(
UNIQUE_WORK_NAME,
ExistingPeriodicWorkPolicy.REPLACE,
heartbeatWork
);
Log.d(TAG, "Heartbeat scheduled successfully with unique work name: " + UNIQUE_WORK_NAME);
}
public static void cancelHeartbeat(Context context) {
Log.d(TAG, "Cancelling heartbeat work");
Context appContext = context.getApplicationContext();
// Cancel by unique work name (more reliable)
WorkManager.getInstance(appContext)
.cancelUniqueWork(UNIQUE_WORK_NAME);
// Also cancel by tag as fallback
WorkManager.getInstance(appContext)
.cancelAllWorkByTag(AppConstants.HEARTBEAT_WORK_TAG);
}
public static void triggerHeartbeat(Context context) {
// This can be used for testing - trigger immediate heartbeat
Log.d(TAG, "Triggering immediate heartbeat");
// For immediate execution, we could create a OneTimeWorkRequest
// but for now, just reschedule which will run soon
scheduleHeartbeat(context);
}
}
@@ -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)
}
}
@@ -1,142 +0,0 @@
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;
import java.lang.reflect.Type;
import java.util.ArrayList;
import java.util.List;
public class SMSFilterHelper {
private static final String TAG = "SMSFilterHelper";
public enum FilterMode {
ALLOW_LIST,
BLOCK_LIST
}
public static class FilterConfig {
private boolean enabled = false;
private FilterMode mode = FilterMode.BLOCK_LIST; // Default to block list
private List<SMSFilterRule> rules = new ArrayList<>();
public FilterConfig() {
}
public boolean isEnabled() {
return enabled;
}
public void setEnabled(boolean enabled) {
this.enabled = enabled;
}
public FilterMode getMode() {
return mode;
}
public void setMode(FilterMode mode) {
this.mode = mode;
}
public List<SMSFilterRule> getRules() {
return rules;
}
public void setRules(List<SMSFilterRule> rules) {
this.rules = rules != null ? rules : new ArrayList<>();
}
}
/**
* Load filter configuration from SharedPreferences
*/
public static FilterConfig loadFilterConfig(Context context) {
String json = SharedPreferenceHelper.getSharedPreferenceString(
context,
AppConstants.SHARED_PREFS_SMS_FILTER_CONFIG_KEY,
null
);
if (json == null || json.isEmpty()) {
return new FilterConfig();
}
try {
Gson gson = new Gson();
Type type = new TypeToken<FilterConfig>() {}.getType();
FilterConfig config = gson.fromJson(json, type);
return config != null ? config : new FilterConfig();
} catch (Exception e) {
Log.e(TAG, "Error loading filter config: " + e.getMessage());
return new FilterConfig();
}
}
/**
* Save filter configuration to SharedPreferences
*/
public static void saveFilterConfig(Context context, FilterConfig config) {
try {
Gson gson = new Gson();
String json = gson.toJson(config);
SharedPreferenceHelper.setSharedPreferenceString(
context,
AppConstants.SHARED_PREFS_SMS_FILTER_CONFIG_KEY,
json
);
} catch (Exception e) {
Log.e(TAG, "Error saving filter config: " + e.getMessage());
}
}
/**
* Check if an SMS should be processed based on filter configuration
* @param sender The sender phone number
* @param message The message content
* @param context Application context
* @return true if SMS should be processed, false if it should be filtered out
*/
public static boolean shouldProcessSMS(String sender, String message, Context context) {
FilterConfig config = loadFilterConfig(context);
// If filter is disabled, process all SMS
if (!config.isEnabled()) {
return true;
}
// If no rules, process all SMS (empty filter doesn't block anything)
if (config.getRules() == null || config.getRules().isEmpty()) {
return true;
}
// Check if sender and/or message matches any rule
boolean matchesAnyRule = false;
for (SMSFilterRule rule : config.getRules()) {
if (rule.matches(sender, message)) {
matchesAnyRule = true;
break;
}
}
// Apply filter mode
if (config.getMode() == FilterMode.ALLOW_LIST) {
// Only process if matches a rule
return matchesAnyRule;
} else {
// Block list: process if it does NOT match any rule
return !matchesAnyRule;
}
}
/**
* Legacy method for backward compatibility - checks sender only
*/
public static boolean shouldProcessSMS(String sender, Context context) {
return shouldProcessSMS(sender, null, 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()
}
}
@@ -1,135 +0,0 @@
package com.vernu.sms.models;
public class SMSFilterRule {
public enum MatchType {
EXACT,
STARTS_WITH,
ENDS_WITH,
CONTAINS
}
public enum FilterTarget {
SENDER,
MESSAGE,
BOTH
}
private String pattern;
private MatchType matchType;
private FilterTarget filterTarget = FilterTarget.SENDER; // Default to sender for backward compatibility
private boolean caseSensitive = false; // Default to case insensitive
public SMSFilterRule() {
}
public SMSFilterRule(String pattern, MatchType matchType) {
this.pattern = pattern;
this.matchType = matchType;
this.filterTarget = FilterTarget.SENDER;
this.caseSensitive = false;
}
public SMSFilterRule(String pattern, MatchType matchType, FilterTarget filterTarget) {
this.pattern = pattern;
this.matchType = matchType;
this.filterTarget = filterTarget;
this.caseSensitive = false;
}
public SMSFilterRule(String pattern, MatchType matchType, FilterTarget filterTarget, boolean caseSensitive) {
this.pattern = pattern;
this.matchType = matchType;
this.filterTarget = filterTarget;
this.caseSensitive = caseSensitive;
}
public String getPattern() {
return pattern;
}
public void setPattern(String pattern) {
this.pattern = pattern;
}
public MatchType getMatchType() {
return matchType;
}
public void setMatchType(MatchType matchType) {
this.matchType = matchType;
}
public FilterTarget getFilterTarget() {
return filterTarget;
}
public void setFilterTarget(FilterTarget filterTarget) {
this.filterTarget = filterTarget != null ? filterTarget : FilterTarget.SENDER;
}
public boolean isCaseSensitive() {
return caseSensitive;
}
public void setCaseSensitive(boolean caseSensitive) {
this.caseSensitive = caseSensitive;
}
/**
* Check if a string matches this filter rule based on match type
*/
private boolean matchesString(String text) {
if (pattern == null || text == null) {
return false;
}
String patternToMatch = pattern;
String textToMatch = text;
// Apply case sensitivity
if (!caseSensitive) {
patternToMatch = patternToMatch.toLowerCase();
textToMatch = textToMatch.toLowerCase();
}
switch (matchType) {
case EXACT:
return textToMatch.equals(patternToMatch);
case STARTS_WITH:
return textToMatch.startsWith(patternToMatch);
case ENDS_WITH:
return textToMatch.endsWith(patternToMatch);
case CONTAINS:
return textToMatch.contains(patternToMatch);
default:
return false;
}
}
/**
* Check if the given sender and/or message matches this filter rule
*/
public boolean matches(String sender, String message) {
if (pattern == null) {
return false;
}
switch (filterTarget) {
case SENDER:
return matchesString(sender);
case MESSAGE:
return matchesString(message);
case BOTH:
return matchesString(sender) || matchesString(message);
default:
return matchesString(sender);
}
}
/**
* Legacy method for backward compatibility - checks sender only
*/
public boolean matches(String sender) {
return matches(sender, null);
}
}
@@ -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,57 +0,0 @@
package com.vernu.sms.models;
public class SMSPayload {
private String[] recipients;
private String message;
private String smsId;
private String smsBatchId;
private Integer simSubscriptionId;
// 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;
}
public Integer getSimSubscriptionId() {
return simSubscriptionId;
}
public void setSimSubscriptionId(Integer simSubscriptionId) {
this.simSubscriptionId = simSubscriptionId;
}
}
@@ -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,124 +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.helpers.HeartbeatManager;
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);
// Schedule heartbeat if device is enabled
boolean deviceEnabled = SharedPreferenceHelper.getSharedPreferenceBoolean(
context,
AppConstants.SHARED_PREFS_GATEWAY_ENABLED_KEY,
false
);
if (deviceEnabled) {
Log.i(TAG, "Device booted, scheduling heartbeat");
HeartbeatManager.scheduleHeartbeat(context);
}
}
}
}
/**
* 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");
// Sync heartbeatIntervalMinutes from server response
if (response.body() != null && response.body().data != null) {
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(context, AppConstants.SHARED_PREFS_HEARTBEAT_INTERVAL_MINUTES_KEY, intervalMinutes);
Log.d(TAG, "Synced heartbeat interval from server: " + intervalMinutes + " minutes");
}
}
}
} 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,157 +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.helpers.SMSFilterHelper;
import com.vernu.sms.workers.SMSReceivedWorker;
import java.security.MessageDigest;
import java.util.HashSet;
import java.util.Objects;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
public class SMSBroadcastReceiver extends BroadcastReceiver {
private static final String TAG = "SMSBroadcastReceiver";
// In-memory cache to prevent rapid duplicate processing (5 seconds TTL)
private static final ConcurrentHashMap<String, Long> processedFingerprints = new ConcurrentHashMap<>();
private static final long CACHE_TTL_MS = 5000; // 5 seconds
@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());
// Apply SMS filter
String sender = receivedSMSDTO.getSender();
String message = receivedSMSDTO.getMessage();
if (sender != null && !SMSFilterHelper.shouldProcessSMS(sender, message, context)) {
Log.d(TAG, "SMS from " + sender + " filtered out by filter rules");
return;
}
// Generate fingerprint for deduplication
String fingerprint = generateFingerprint(
receivedSMSDTO.getSender(),
receivedSMSDTO.getMessage(),
receivedSMSDTO.getReceivedAtInMillis()
);
receivedSMSDTO.setFingerprint(fingerprint);
// Check in-memory cache to prevent rapid duplicate processing
long currentTime = System.currentTimeMillis();
Long lastProcessedTime = processedFingerprints.get(fingerprint);
if (lastProcessedTime != null && (currentTime - lastProcessedTime) < CACHE_TTL_MS) {
Log.d(TAG, "Duplicate SMS detected in cache, skipping: " + fingerprint);
return;
}
// Update cache
processedFingerprints.put(fingerprint, currentTime);
// Clean up old cache entries periodically
cleanupCache(currentTime);
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);
// });
// }
/**
* Generate a unique fingerprint for an SMS message based on sender, message content, and timestamp
*/
private String generateFingerprint(String sender, String message, long timestamp) {
try {
String data = (sender != null ? sender : "") + "|" +
(message != null ? message : "") + "|" +
timestamp;
MessageDigest md = MessageDigest.getInstance("MD5");
byte[] hashBytes = md.digest(data.getBytes("UTF-8"));
StringBuilder sb = new StringBuilder();
for (byte b : hashBytes) {
sb.append(String.format("%02x", b));
}
return sb.toString();
} catch (Exception e) {
Log.e(TAG, "Error generating fingerprint: " + e.getMessage());
// Fallback to simple string concatenation if MD5 fails
return (sender != null ? sender : "") + "_" +
(message != null ? message : "") + "_" +
timestamp;
}
}
/**
* Clean up old cache entries to prevent memory leaks
*/
private void cleanupCache(long currentTime) {
// Only cleanup occasionally (every 100 entries processed)
if (processedFingerprints.size() > 100) {
Set<String> keysToRemove = new HashSet<>();
for (String key : processedFingerprints.keySet()) {
Long timestamp = processedFingerprints.get(key);
if (timestamp != null && (currentTime - timestamp) > CACHE_TTL_MS) {
keysToRemove.add(key);
}
}
for (String key : keysToRemove) {
processedFingerprints.remove(key);
}
Log.d(TAG, "Cleaned up " + keysToRemove.size() + " expired cache entries");
}
}
}
@@ -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,166 +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;
case SmsManager.RESULT_NETWORK_ERROR:
errorMessage = "Network 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() + ", 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,276 +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.helpers.HeartbeatHelper;
import com.vernu.sms.helpers.HeartbeatManager;
import com.vernu.sms.models.SMSPayload;
import com.vernu.sms.TextBeeUtils;
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 {
// Check message type first
String messageType = remoteMessage.getData().get("type");
if ("heartbeat_check".equals(messageType)) {
// Handle heartbeat check request from backend
handleHeartbeatCheck();
return;
}
// Parse SMS payload data (legacy handling)
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());
}
}
/**
* Handle heartbeat check request from backend
*/
private void handleHeartbeatCheck() {
Log.d(TAG, "Received heartbeat check request from backend");
// Check if device is eligible for heartbeat
if (!HeartbeatHelper.isDeviceEligibleForHeartbeat(this)) {
Log.d(TAG, "Device not eligible for heartbeat, skipping heartbeat check");
return;
}
// Get device ID and API key
String deviceId = SharedPreferenceHelper.getSharedPreferenceString(
this,
AppConstants.SHARED_PREFS_DEVICE_ID_KEY,
""
);
String apiKey = SharedPreferenceHelper.getSharedPreferenceString(
this,
AppConstants.SHARED_PREFS_API_KEY_KEY,
""
);
// Send heartbeat using shared helper
boolean success = HeartbeatHelper.sendHeartbeat(this, deviceId, apiKey);
if (success) {
Log.d(TAG, "Heartbeat sent successfully in response to backend check");
// Ensure scheduled work is added if missing
HeartbeatManager.scheduleHeartbeat(this);
} else {
Log.e(TAG, "Failed to send heartbeat in response to backend check");
// Still try to ensure scheduled work is added
HeartbeatManager.scheduleHeartbeat(this);
}
}
/**
* Send SMS to recipients using the provided payload
*/
private void sendSMS(SMSPayload smsPayload) {
if (smsPayload == null) {
Log.e(TAG, "SMS payload is null");
return;
}
// Determine which SIM to use (priority: backend-provided > app preference > device default)
Integer simSubscriptionId = null;
// First, check if backend provided a SIM subscription ID
if (smsPayload.getSimSubscriptionId() != null) {
int backendSimId = smsPayload.getSimSubscriptionId();
// Validate that the subscription ID exists
if (TextBeeUtils.isValidSubscriptionId(this, backendSimId)) {
simSubscriptionId = backendSimId;
Log.d(TAG, "Using backend-provided SIM subscription ID: " + backendSimId);
} else {
Log.w(TAG, "Backend-provided SIM subscription ID " + backendSimId + " is not valid, falling back to app preference");
}
}
// If backend didn't provide a valid SIM, check app preference
if (simSubscriptionId == null) {
int preferredSim = SharedPreferenceHelper.getSharedPreferenceInt(
this, AppConstants.SHARED_PREFS_PREFERRED_SIM_KEY, -1);
if (preferredSim != -1) {
// Validate that the preferred SIM still exists
if (TextBeeUtils.isValidSubscriptionId(this, preferredSim)) {
simSubscriptionId = preferredSim;
Log.d(TAG, "Using app-preferred SIM subscription ID: " + preferredSim);
} else {
Log.w(TAG, "App-preferred SIM subscription ID " + preferredSim + " is no longer valid, using device default");
}
}
}
// 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;
// Send using determined SIM (or device default if simSubscriptionId is null)
if (simSubscriptionId == null) {
// Use default SIM
Log.d(TAG, "Using device default SIM");
smsSent = SMSHelper.sendSMS(
recipient,
smsPayload.getMessage(),
smsPayload.getSmsId(),
smsPayload.getSmsBatchId(),
this
);
} else {
// Use specific SIM
try {
smsSent = SMSHelper.sendSMSFromSpecificSim(
recipient,
smsPayload.getMessage(),
simSubscriptionId,
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())
}
}
@@ -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,192 @@
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 = "Android"
osVersion = Build.VERSION.RELEASE
osApiLevel = Build.VERSION.SDK_INT
osBuildFingerprint = 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,597 @@
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.Forum,
title = "Join our Discord",
subtitle = "Get help from the community",
onClick = {
context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse("https://textbee.dev/discord")))
},
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 (03600)",
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/textbee/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
)
)
@@ -1,55 +0,0 @@
package com.vernu.sms.workers;
import android.content.Context;
import android.util.Log;
import androidx.annotation.NonNull;
import androidx.work.Worker;
import androidx.work.WorkerParameters;
import com.vernu.sms.AppConstants;
import com.vernu.sms.helpers.SharedPreferenceHelper;
import com.vernu.sms.helpers.HeartbeatHelper;
public class HeartbeatWorker extends Worker {
private static final String TAG = "HeartbeatWorker";
public HeartbeatWorker(@NonNull Context context, @NonNull WorkerParameters workerParams) {
super(context, workerParams);
}
@NonNull
@Override
public Result doWork() {
Context context = getApplicationContext();
// Check if device is eligible for heartbeat
if (!HeartbeatHelper.isDeviceEligibleForHeartbeat(context)) {
Log.d(TAG, "Device not eligible for heartbeat, skipping");
return Result.success(); // Not a failure, just skip
}
// Get device ID and API key
String deviceId = SharedPreferenceHelper.getSharedPreferenceString(
context,
AppConstants.SHARED_PREFS_DEVICE_ID_KEY,
""
);
String apiKey = SharedPreferenceHelper.getSharedPreferenceString(
context,
AppConstants.SHARED_PREFS_API_KEY_KEY,
""
);
// Send heartbeat using shared helper
boolean success = HeartbeatHelper.sendHeartbeat(context, deviceId, apiKey);
if (success) {
return Result.success();
} else {
Log.e(TAG, "Failed to send heartbeat, will retry");
return Result.retry();
}
}
}

Some files were not shown because too many files have changed in this diff Show More