Compare commits

...
701 Commits
Author SHA1 Message Date
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
isra el 3cd1d94b20 feat(web): add supporthq-customer support widget 2026-03-04 13:01:24 +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
isra el f5bef39cec chore(api): update email template 2026-02-09 10:21:48 +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
isra el ac65907453 improve upgrade to pro cta 2026-02-09 06:54:35 +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
isra el d8460bcb8b chore(api): update email templates 2026-02-09 06:39:21 +03:00
isra el eb94085f6e chore(api): handle missing discountId 2026-02-09 06:32:58 +03:00
Israel Abebe b1281d4ec1 Merge pull request #182 from vernu/bump-version
bump android version to 2.7.0(15)
2026-02-06 20:22:37 +03:00
isra el 46fa5cda2e chore(android): bump version to 2.7.0(15) 2026-02-06 20:19:17 +03:00
Israel Abebe 709f05f33d Merge pull request #178 from vernu/dev
Dev
2026-02-06 20:17:07 +03:00
Israel Abebe 844a815813 Merge pull request #181 from vernu/feat/sender-sim-selection
fix sim selection dropdown bug
2026-02-06 19:09:26 +03:00
isra el b8d277a563 fix(web): fix sim selection dropdown bug 2026-02-06 19:08:42 +03:00
Israel Abebe d335f76088 Merge pull request #180 from vernu/feat/sender-sim-selection
allow sim-selection when sending sms from dashboard
2026-02-06 19:01:51 +03:00
isra el 42abcec94b feat(web): allow sim-selection when sending sms from dashboard 2026-02-06 18:57:22 +03:00
Israel Abebe 014ae354c6 Merge pull request #179 from vernu/device-custom-name
allow setting custom device name
2026-02-06 18:34:46 +03:00
isra el 2692d4a154 feat(web): display custom device name in dashboard 2026-02-06 18:34:16 +03:00
isra el 1b24cd9754 feat(api): set custom device name 2026-02-06 18:33:55 +03:00
isra el e6ad6a18dc feat(android): allow setting custom device name 2026-02-06 18:29:18 +03:00
Israel Abebe d51693d26d Merge pull request #177 from vernu/android-ui-enhancement
Android UI enhancement
2026-02-06 17:46:35 +03:00
Israel Abebe 07f80fdf84 Merge pull request #176 from vernu/filter-received-sms
support filters for received sms
2026-02-06 17:45:59 +03:00
isra el b762c6e2ef ui(android): enhance android app main activity ui 2026-02-06 17:44:38 +03:00
isra el c1952d2030 feat(android): support filters for received sms 2026-02-06 15:31:17 +03:00
Israel Abebe ebc90d8105 Merge pull request #175 from vernu/android-app-ui-improvement
enhance app ui
2026-02-06 10:18:31 +03:00
isra el fd0d86efab ui(android): more app ui enhancement 2026-02-06 10:07:30 +03:00
isra el 32b463a10c ui(android): enhance app ui 2026-02-05 22:24:10 +03:00
Israel Abebe ed55406687 Merge pull request #173 from vernu/heartbeatfallback
heartbeat fallback
2026-01-29 19:28:31 +03:00
isra el b6dde1a096 fix failing test 2026-01-29 19:25:14 +03:00
isra el 312e3a4fd4 chore(api): remove unnecessary fields from heartbeat data 2026-01-29 19:16:42 +03:00
Israel Abebe 3939502db7 Merge pull request #172 from vernu/feat/delayedsms
delayed sms sending feature
2026-01-29 19:05:57 +03:00
Israel Abebe 9ae9f5978b Merge pull request #171 from vernu/fix/heartbeat
fix device heartbeat
2026-01-29 19:04:22 +03:00
Israel Abebe 58e6c73a72 Merge pull request #170 from vernu/feat/simselection
sim selection via api
2026-01-29 19:02:06 +03:00
Israel Abebe f1c8530257 Merge pull request #169 from vernu/feat/heartbeat
device heartbeat
2026-01-29 18:59:48 +03:00
isra el ede8e4c210 chore: add fallback heartbeat check using fcm 2026-01-29 18:47:51 +03:00
isra el a6df612bfa feat(api): implement delayed sms sending 2026-01-29 18:37:44 +03:00
isra el 4b4f3bace9 fix(android): fix heartbeat background work issue 2026-01-28 18:41:14 +03:00
isra el 8a42b95e8f fix(api): fix heartbeat info saving issue 2026-01-28 17:42:32 +03:00
isra el e0e6be9379 fix(android): fix android build issue 2026-01-27 19:49:59 +03:00
isra el 0665aa5432 feat: support sim card selection via api 2026-01-27 19:46:09 +03:00
isra el dc8ca9d9a7 feat(android): implement heartbeat check 2026-01-27 17:38:15 +03:00
isra el 541f32406e feat(api): create heart beat endpoint 2026-01-27 15:30:06 +03:00
isra el 141b8b334a fix: handle duplicate received sms issue 2026-01-26 14:17:08 +03:00
Israel Abebe f9fed4d3d8 Merge pull request #168 from vernu/improve-error-messaging
improve error message ui
2026-01-20 20:34:19 +03:00
isra el 4ef65b08ff chore(web): improve error message ui 2026-01-20 20:32:24 +03:00
isra el 86060456fa fix(api): fix build issues 2025-12-14 15:01:26 +03:00
isra el ca0ac8cf6e fix(api): fix build issue 2025-12-14 14:42:41 +03:00
isra el ba0437ff01 chore(api): bump dependencies 2025-12-14 14:28:29 +03:00
isra el 99a9ee3cc1 chore(web): unhide survey modal 2025-12-07 21:05:20 +03:00
isra el f2ca0cecf6 chore(web): hide blackfriday discount cta 2025-12-07 21:04:42 +03:00
isra el 0ff96f4857 fix(web): fix cloudflare turnstile widget for modals 2025-12-07 21:03:23 +03:00
isra el e2cf5d2e37 chore(api): ensure uncaught exceptions dont crash server 2025-12-07 20:57:14 +03:00
Israel Abebe d6d52e80cf Merge pull request #164 from vernu/cloudflare-turnstile
add cloudflare turnstile protection to support and account deletion forms
2025-12-07 20:52:24 +03:00
isra el 30691e2e0d chore: add cloudflare turnstile protection to support and account deletion forms 2025-12-07 20:47:48 +03:00
isra el 7308ab6721 chore(web): remove bot verification from form titles 2025-12-07 20:11:09 +03:00
Israel Abebe fd184a4700 Merge pull request #163 from vernu/cloudflare-turnstile
prevent bot form submissions with cloudflare turnstile
2025-12-07 19:09:45 +03:00
isra el af19f6c75a chore: prevent bot form submissions with cloudflare turnstile 2025-12-07 19:05:19 +03:00
Israel Abebe 10ead48014 Merge pull request #162 from vernu/fix-webhook-docs
fix webhook signature verification docs
2025-12-04 22:25:42 +03:00
isra el 463747b82f fix(web): fix webhook signature verification docs 2025-12-04 22:24:20 +03:00
Israel Abebe d99b53f84c Merge pull request #160 from vernu/black-friday-discount-notice
blackfriday discount notice
2025-11-26 10:22:01 +03:00
isra el bbbe85e0fd chore(web): add blackfriday discount notice 2025-11-26 10:20:56 +03:00
Israel Abebe df0ef06495 Merge pull request #157 from vernu/billing-notification
billing notifications and limit overrides
2025-11-16 07:06:04 +03:00
isra el 130a6934d6 feat(api): support billing limits override 2025-11-16 07:02:47 +03:00
isra el 3ea8c0d5a0 chore(web): improve checkout error message display 2025-11-16 07:01:06 +03:00
isra el fd5ba1c538 chore(api): update billing notification queue config 2025-10-29 10:36:24 +03:00
isra el 91ac0b3eb0 fix(api): fix billing email notification bug 2025-10-29 10:08:25 +03:00
isra el c26ac378f6 fix(api): fix duplicate billing notification emails 2025-10-29 07:22:21 +03:00
isra el bf66e94e6d chore(api): improve billing threshold calculation 2025-10-29 06:56:49 +03:00
isra el 552a90685a chore(api): improve billing notifications 2025-10-29 06:48:03 +03:00
isra el e2246cf8e8 improve billing notification emails 2025-10-29 06:43:51 +03:00
isra el 455696275b feat(api): billing notifications 2025-10-29 06:18:30 +03:00
Israel Abebe 0ffa945668 Merge pull request #152 from vernu/dev
Dev
2025-10-13 06:59:34 +03:00
isra el 6c40e2311e chore(web): improve social share copy and ui 2025-10-13 06:58:04 +03:00
isra el fbac1fae0b chore(web): temporarily disable unknown state and sms status updated webhook events 2025-10-13 06:35:15 +03:00
Israel Abebe 127d1f4127 Merge pull request #151 from yetmgetaewunetu/social-links
feat: added social media share links(community page)
2025-10-13 06:31:11 +03:00
isra el 7ce4efff70 refactor(web): cleanup and refactor dashboard layout components 2025-10-13 06:29:49 +03:00
isra el dd9ec0aacc chore(web): remove unused sentry config 2025-10-13 06:28:45 +03:00
isra el 6d081c7489 chore(api): track password reset expiresAt timestamp 2025-10-13 06:24:37 +03:00
isra el 500d880aea chore(api): improve error handling in billing service 2025-10-13 06:22:52 +03:00
samlay77 4995b6023b fix: refactor array 2025-10-12 22:22:46 +03:00
samlay77 ea8c21ff21 feat: added social media share links(community page) 2025-10-12 12:48:16 +03:00
Israel Abebe a9b1cd6549 Merge pull request #149 from yetmgetaewunetu/bug-fix-payload
fix: updated update sms status to use the new updated sms in the payload
2025-10-06 08:13:00 +03:00
yetmgetaewunetu e3745d5071 ISO date for payload demo 2025-10-05 21:20:53 +03:00
yetmgetaewunetu 82aa3cb425 payload details fix backend 2025-10-05 16:33:19 +03:00
Israel Abebe b57ebc7500 Merge pull request #148 from yetmgetaewunetu/bug-fix-payload
added webhookStatus on smsData type
2025-10-05 15:18:15 +03:00
yetmgeta 740b691f67 added webhookStatus on smsData type 2025-10-04 14:22:44 +03:00
Yetmgeta Ewunetu 5e509b1b57 Merge pull request #146 from yetmgetaewunetu/bug-fix-payload
payload status fix and ui update on webhook notifications details
2025-10-03 23:49:40 +03:00
Israel Abebe 111dbc43f2 Merge pull request #147 from vernu/survey-modal
show survey modal in dashboard
2025-09-30 20:09:16 +03:00
isra el d704b04e3d chore(web): show survey modal in dashboard 2025-09-30 19:56:40 +03:00
Yetmgeta EwunetuandIsrael Abebe 0d5f9a7cec webhook notifications for sent sms events (#145)
* webhook notifications for sms sent, sms delivered, sms failed, and unknown state, events

* updated deliverNotification function to deliver notifications for all events

* removed sms-status-updated filter

* updated payloads based on event type

* updated payloads based on event type

---------

Co-authored-by: Israel Abebe <vernu1997@gmail.com>
2025-09-29 19:43:24 +03:00
Israel Abebe f66d2855a7 Merge pull request #140 from vernu/dev
Dev
2025-09-21 23:57:45 +03:00
Israel Abebe fe588ea05a Webhook deliveries UI improvement (#139)
* chore(api): improve webhook delivery history aggregation query

* ui(web): improve webhook delivery history page ui

* chore(web): clean up unnecessary comments
2025-09-21 23:56:06 +03:00
isra el 704a8dbf95 chore(web): clean up unnecessary comments 2025-09-21 23:55:35 +03:00
isra el 4f64880cf8 ui(web): improve webhook delivery history page ui 2025-09-21 23:52:00 +03:00
isra el 63733651d5 chore(api): improve webhook delivery history aggregation query 2025-09-21 23:43:32 +03:00
Yetmgeta Ewunetu 88e0b3df63 webhook notification history
* created the webhook notification history page and backend end point

* updated implementation and resolved comments

* updated sms-modal

* updated sms-modal
2025-09-18 23:07:53 +03:00
isra el 9b98a9e509 fix build issue 2025-09-17 08:40:19 +03:00
Israel Abebe 52565cc55c Merge pull request #131 from selamawitk/feature/web-sms-card
Enhance SMS card UI from web folder
2025-09-17 08:22:28 +03:00
Selamawitk 023df71d3c Enhance SMS card UI from web folder 2025-09-07 18:15:31 +03:00
Israel Abebe bce6b3be94 Merge pull request #128 from vernu/dev
Dev
2025-09-01 12:23:41 +03:00
Israel Abebe 3227fb6696 Merge pull request #127 from yetmgetaewunetu/dev
removed white spaces from recepient
2025-09-01 11:31:52 +03:00
yetmgeta e5b63dbb6a removed white spaces from recepient 2025-08-25 22:38:22 +03:00
Israel Abebe 183dbeb29a Merge pull request #126 from vernu/improve-reminder-emails
improve checkout reminder email
2025-08-25 12:19:38 +03:00
isra el da79575db6 chore(api): improve checkout reminder email 2025-08-25 12:14:12 +03:00
Israel Abebe b08b732b61 Merge pull request #125 from vernu/dev
checkout reminder emails
2025-08-24 13:49:03 +03:00
isra el 3f838ce253 feat(api): send checkout reminder emails 2025-08-24 13:47:03 +03:00
Israel Abebe 938d3d6f17 Merge pull request #124 from vernu/dev
improve android app build config
2025-08-24 08:11:07 +03:00
isra el 321842a021 infra: fix android build workflow 2025-08-24 08:07:14 +03:00
isra el db640de638 infra: update build and test workflow 2025-08-24 07:42:51 +03:00
isra el 3c511a0f53 refactor(android): improve android app build config 2025-08-24 07:41:30 +03:00
Israel Abebe cf80be9611 Merge pull request #121 from vernu/get-sms-bugs
fix get sms and sms-batch  by id endpoint bugs
2025-08-21 07:18:50 +03:00
isra el 38dd56df26 fix(api): fix get sms and sms-batch by id endpoint bugs 2025-08-21 07:14:49 +03:00
Israel Abebe 97dd062477 Merge pull request #118 from vernu/dev
fix sms status and error message display issues
2025-08-13 13:38:08 +03:00
isra el 771282187d fix(web): fix sms status and error message display issues 2025-08-13 13:34:35 +03:00
Israel Abebe 445de6b8fc Merge pull request #117 from vernu/improve-support-form
Improve customer-support form
2025-08-07 20:56:13 +03:00
isra el d1b311a7c5 chore(web): remove unused customer-support component 2025-08-07 20:33:42 +03:00
isra el 9f5b858ffb chore(web): improve support form 2025-08-07 20:33:20 +03:00
Israel Abebe 1e5c4de4ed Merge pull request #116 from vernu/fix-broken-links
fix broken links
2025-08-05 19:35:02 +03:00
isra el 1f0db4c4d2 fix(web): fix broken links 2025-08-05 18:33:20 +03:00
Israel Abebe 7a24c0cd2b Merge pull request #114 from vernu/improve-billing
prevent duplicate checkout sessions per user
2025-08-04 08:50:23 +03:00
isra el 4c164e6bad test(api): fix failing test 2025-08-04 08:44:58 +03:00
Israel Abebe 7fc521023f Merge pull request #113 from vernu/improve-session-caching
add session caching to improve performance
2025-08-04 08:40:19 +03:00
isra el 99efd20e7a fix(api): prevent duplicate checkout sessions per user 2025-08-04 08:40:03 +03:00
Israel Abebe 2e8403a056 Merge pull request #112 from vernu/improve-contribute-page
improve contribute page
2025-08-04 08:33:02 +03:00
isra el 8914cc2b0b chore(web): add session caching to improve performance 2025-08-04 08:32:46 +03:00
isra el 364b876c8c chore(web): improve contribute page 2025-08-04 08:23:18 +03:00
Israel Abebe 453087b3ce Merge pull request #108 from cedricbahirwe/fix-downloadpage-theme
fix: correct dark theme background colors in download page
2025-07-23 16:31:34 +03:00
Cédric Bahirwe d023fc385f fix: correct dark theme background colors in download page 2025-07-23 13:18:15 +02:00
Israel Abebe 8e81c6d7b0 Merge pull request #106 from vernu/dev
add linkedin and x links to community page
2025-07-14 21:50:10 +03:00
isra el b1b87e1acc add linkedin and x links to community page 2025-07-14 21:48:55 +03:00
Israel Abebe 68593dff9e Merge pull request #105 from vernu/dev
fix redirect url after checkout
2025-07-12 20:57:05 +03:00
isra el 49d65b87cd fix(api): fix redirect url after checkout 2025-07-12 20:56:19 +03:00
Israel Abebe 0f6c9eff32 Merge pull request #104 from vernu/improve-password-validation
improve password validation logic
2025-07-12 18:27:42 +03:00
isra el e29d145397 chore(api): improve password validation logic 2025-07-12 18:26:59 +03:00
Israel Abebe 0fa930a0ff Merge pull request #103 from vernu/limit-email-verification
limit max number of email verification requests per day
2025-07-12 18:19:24 +03:00
isra el 400bda9e72 chore(api): limit max number of email verification requests 2025-07-12 18:18:33 +03:00
Israel Abebe dd14c6ba4e Merge pull request #102 from vernu/dashboard-ui-ux-improvement
dashboard ui/ux improvement
2025-07-12 18:09:52 +03:00
isra el f9005e6063 chore(web): improve upgrade to pro dashboard cta 2025-07-12 18:09:11 +03:00
isra el 58b8eef92f chore(web): fix api key and device list key issue 2025-07-12 18:06:11 +03:00
isra el 08afce15b0 chore(api): include usage details in get current subscription endpoint 2025-07-12 18:04:11 +03:00
Israel Abebe 6ac8361aa6 Merge pull request #101 from vernu/improve-billing-logic
fix billing issues
2025-07-12 16:32:54 +03:00
isra el 014590fb9e fix(api): fix billing issues 2025-07-12 16:32:05 +03:00
Israel Abebe 217c068bfb Merge pull request #100 from vernu/improve-billing-logic
improve billing service
2025-07-11 21:05:05 +03:00
isra el fb06cfd33f chore(api): improve billing service 2025-07-11 21:02:12 +03:00
Israel Abebe aac621bd16 Merge pull request #98 from vernu/limit-pw-reset
restrict number of pw reset requests per day
2025-06-20 20:19:19 +03:00
isra el 6252e4f04e chore(api): limit max 5 pw reset requests per day 2025-06-20 19:45:01 +03:00
Israel Abebe 0fe0f48ef9 Merge pull request #97 from vernu/improve-status-and-error-tracking
Improve sms status and error tracking logic
2025-06-16 08:21:08 +03:00
Israel Abebe f520f378d0 Merge pull request #96 from vernu/improve-call-reliability
Improve received sms and status tracking reliability using work manager
2025-06-16 08:17:53 +03:00
isra el b0ce11e392 chore(api): improve sms status and error tracking logic 2025-06-16 08:17:16 +03:00
Israel Abebe 7012b04439 Merge pull request #95 from vernu/fix-version-update-disabling-device-issue
fix device status getting disabled issue on new version update
2025-06-16 08:13:07 +03:00
isra el 375cf63adb chore(android): bump android version to 15(2.6.2) 2025-06-16 08:12:53 +03:00
isra el 6886dd2f1a feat(android): improve received sms and status tracking reliability 2025-06-16 08:12:15 +03:00
isra el 5acec3f04a fix(android): ensure device status does not get disabled on new version update 2025-06-14 17:23:57 +03:00
Israel Abebe 6861a4d36b Merge pull request #94 from vernu/refactor-delete-account
refactor delete account component
2025-06-11 11:56:39 +03:00
isra el 54fb586d9b refactor(web): refactor delete account component 2025-06-11 11:50:39 +03:00
Israel Abebe 60bcd691c8 Merge pull request #93 from vernu/fix-status-display-cutoff-date
update message status display cut-off date to Jun 5
2025-06-11 11:47:41 +03:00
isra el dd2c01678f chore(web): update message status cut-off date to Jun 5 2025-06-11 11:44:54 +03:00
Israel Abebe 2633fc80cc Merge pull request #92 from vernu/fix-bulk-sms-limits
raise request body limit from 100kb to 2mb
2025-06-11 11:31:24 +03:00
Israel Abebe da9efc036b Merge pull request #91 from vernu/fix-message-status
only show message status if device is using latest version and message is sent after v2.6 release
2025-06-11 11:30:53 +03:00
isra el 7d2b8093ae chore(api): raise request body limit from 100kb to 2mb 2025-06-11 11:30:31 +03:00
isra el 15a67446ea chore(web): only show message status if device is using latest version and message is sent after v2.6 release 2025-06-11 11:27:55 +03:00
Israel Abebe 50e7b7536d Merge pull request #90 from vernu/hot-fix-jun-8
prevent device deactivation during new version update
2025-06-08 21:56:14 +03:00
isra el d868b43d52 fix(api): fix device deactivation issue during new version update 2025-06-08 21:55:20 +03:00
Israel Abebe c4dc4bcb88 Merge pull request #89 from vernu/improve-dashboard-navigation
prefetch dashboard nav links for better performance
2025-06-05 14:25:53 +03:00
isra el c7b206be91 chore(web): prefetch dashboard nav links for better performance 2025-06-05 14:25:12 +03:00
Israel Abebe f88c94fbbd Update README.md 2025-06-05 08:50:24 +03:00
Israel Abebe 59525cb126 Merge pull request #88 from vernu/login-redirect-hotfix
fix(web): fix redirection issues after login
2025-06-04 22:36:33 +03:00
Israel Abebe 383cf213d8 Merge pull request #87 from vernu/fix-duplicate-received-sms
fix duplicate received sms issue
2025-06-04 22:34:10 +03:00
isra el 9e15483752 fix(web): fix redirection issues after login 2025-06-04 22:32:31 +03:00
isra el ea49398e4d fix(android): fix duplicate received sms issue 2025-06-04 22:30:11 +03:00
Israel Abebe cde19e01e0 Merge pull request #86 from vernu/fix-docker-build2
fix dockerfile
2025-06-04 21:40:24 +03:00
isra el ef59f86071 fix(infra): fix dockerfile 2025-06-04 21:36:14 +03:00
Israel Abebe 1e602b85e1 Merge pull request #85 from vernu/fix-docker-build
fix docker-build issue for web
2025-06-04 21:23:10 +03:00
isra el 2b110b6fc5 fix(infra): fix docker-build for web 2025-06-04 21:18:58 +03:00
Israel Abebe 89ed36849b Merge pull request #84 from vernu/bump-android-app-version
Bump android app version to 2.6.0(13)
2025-06-04 21:10:50 +03:00
Israel Abebe 0ac3ccb144 Merge pull request #83 from vernu/dev
Dev
2025-06-04 21:09:34 +03:00
isra el 9b9d92592c chore(android): bump android app version to 2.6.0(13) 2025-06-04 21:08:24 +03:00
Israel Abebe 7fc7fbf86f Merge pull request #82 from vernu/fix-cases
ensure status is saved in lowercases
2025-06-04 20:57:11 +03:00
isra el cbffeca689 chore(api): ensure status is saved in lowercases 2025-06-04 20:52:59 +03:00
Israel Abebe dfd6c5c3d2 Merge pull request #81 from vernu/web-dashboard-ui
UI/UX redesign of web dashboard
2025-06-04 20:47:12 +03:00
isra el c1af584093 fix(infra): fix failing build action 2025-06-04 20:41:45 +03:00
isra el db3ac97af8 ui(web): rebuild web dashboard ui/ux 2025-06-04 20:37:23 +03:00
Israel Abebe 25ca1f7be0 Merge pull request #80 from vernu/track-sms-status
Track sms status
2025-06-04 20:24:25 +03:00
isra el 2be8b3e8c7 chore(android): sync app and device info and restart sticky notification on restart 2025-06-03 05:20:45 +03:00
isra el 7694f3f60a feat(api): create endpoints for getting a specific sms and sms batch by id 2025-06-03 05:19:47 +03:00
isra el 52e88e7e36 feat(api): track sms status 2025-06-02 20:47:15 +03:00
isra el 64859b38ad feat(android): track sms sent, delivered, and failed status 2025-06-02 20:42:14 +03:00
Israel Abebe 601f33d95e Merge pull request #79 from vernu/android-ui-ux
improve android app ui/ux
2025-06-02 18:17:57 +03:00
isra el f355065bfb ui(android): update logo and brand colors 2025-06-02 13:02:37 +03:00
isra el 0bd1818678 chore(android): make link to dashboard clickable 2025-05-31 14:02:19 +03:00
isra el cdf3b7bb51 feat(android): enable sticky notification service to prevent app from getting killed 2025-05-31 13:59:20 +03:00
isra el 61a666da35 chore(android): track android app version better. 2025-05-31 13:43:36 +03:00
isra el fa81292bd8 ui(android): major ui/ux improvement on the android app and add crashlytics 2025-05-31 13:19:45 +03:00
Israel Abebe b63d61baa5 Merge pull request #78 from vernu/hotfix-m28
Hotfix m28
2025-05-28 12:52:40 +03:00
isra el 3aa6740ffe fix(api): fix failing tests 2025-05-28 12:43:49 +03:00
isra el ce4ec6820c chore(android): bump android version 2025-05-28 12:39:08 +03:00
isra el 2809d2bd51 chore(api): disable updating existing device with same model and buildId in register device api 2025-05-28 12:38:33 +03:00
isra el 56fb4b2945 fix(android): improve device registration and update logic 2025-05-28 12:36:24 +03:00
Israel Abebe e13f8fa4e9 Merge pull request #72 from vernu/dev
add quick start and use-cases pages
2025-04-14 07:17:26 +03:00
isra el fe63728387 fix(web): add missing routes 2025-04-14 07:16:03 +03:00
isra el 543507abd5 chore(web): update sitemap 2025-04-14 07:11:51 +03:00
isra el 56b0f8f582 feat(web): add quick start and use-cases pages 2025-04-14 07:11:06 +03:00
isra el 1f8893d3c5 chore(api): update email templates 2025-04-14 07:09:28 +03:00
Israel Abebe 2828de3b12 Merge pull request #71 from vernu/dependabot/npm_and_yarn/web/axios-1.8.2
chore(deps): bump axios from 1.6.5 to 1.8.2 in /web
2025-04-07 08:23:14 +03:00
dependabot[bot] 40aa7e6662 chore(deps): bump axios from 1.6.5 to 1.8.2 in /web
Bumps [axios](https://github.com/axios/axios) from 1.6.5 to 1.8.2.
- [Release notes](https://github.com/axios/axios/releases)
- [Changelog](https://github.com/axios/axios/blob/v1.x/CHANGELOG.md)
- [Commits](https://github.com/axios/axios/compare/v1.6.5...v1.8.2)

---
updated-dependencies:
- dependency-name: axios
  dependency-version: 1.8.2
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-04-06 15:08:38 +00:00
Israel Abebe 9569c2b74a Merge pull request #70 from vernu/dev
remove unused dependencies
2025-04-05 16:37:31 +03:00
isra el 1aeba8d769 Merge branch 'main' into dev 2025-04-05 16:35:33 +03:00
isra el 0d83e9039f chore(web): remove unused dependencies 2025-04-05 16:33:58 +03:00
Israel Abebe b57bcede62 Merge pull request #69 from vernu/build-android
build android debug artifacts on push
2025-04-05 15:00:52 +03:00
isra el 80354f7d3f infra: update android apk artifact naming 2025-04-05 14:57:06 +03:00
isra el c1e7a9c166 infra: build android debug artifacts on push 2025-04-05 14:50:36 +03:00
Israel Abebe 0952c84d37 Merge pull request #68 from vernu/fix-github-workflow
fix github workflow
2025-04-05 11:47:06 +03:00
isra el 4ccd1ffb4e infra: fix github workflow 2025-04-05 11:43:35 +03:00
isra el 3add5c0421 fix mail service 2025-04-04 23:28:41 +03:00
isra el 50f19ebf1f update support email to cc admin 2025-04-04 23:24:28 +03:00
isra el fb9440eaee chore: update support email 2025-04-04 23:15:19 +03:00
isra el 27aaaa7c8a chore(api): add missing dto and schema 2025-04-04 23:08:32 +03:00
Israel Abebe 225d00de5b Merge pull request #67 from vernu/improve-customer-support
improve customer support and account deletion request flows
2025-04-04 23:04:05 +03:00
isra el 791babe430 chore: improve customer support and account deletion request flows 2025-04-04 22:59:15 +03:00
isra el c77e1abab3 fix billing issue 2025-04-02 13:27:11 +03:00
isra el a01cf9bbaa fix custom plan subscription issues 2025-03-31 09:02:18 +03:00
isra el 71905a147a chore(web): remove unused duplicate import from layout file 2025-03-30 14:37:54 +03:00
Israel Abebe 756bcd0fc1 Merge pull request #66 from vernu/update-polar-sdk
update polar-sdk version
2025-03-30 14:29:10 +03:00
isra el 677a7b3554 update polar-sdk version 2025-03-30 14:28:27 +03:00
Israel Abebe a20da1cf23 Merge pull request #65 from vernu/update-packages
update polar-sdk package
2025-03-30 14:20:56 +03:00
isra el 26b5ee3107 update polar-sdk package 2025-03-30 14:17:57 +03:00
Israel Abebe d300bce99b Update SECURITY.md 2025-03-30 13:06:10 +03:00
Israel Abebe 0e9290c0cb Create SECURITY.md 2025-03-30 13:05:39 +03:00
isra el 55f098a27e chore: clean up docker compose and update .env.example file 2025-03-30 12:56:15 +03:00
Israel Abebe 5d00b56ea1 Merge pull request #64 from vernu/fix-docker2
infra: fix Dockerfile for web
2025-03-30 12:02:07 +03:00
isra el 46da871864 infra: fix Dockerfile for web 2025-03-30 11:52:13 +03:00
Israel Abebe fed99855e3 Merge pull request #63 from vernu/fix-docker
fix docker issues and improve test coverage
2025-03-30 11:01:50 +03:00
isra el 34e3abb680 infra: update docker publish workflow 2025-03-30 11:00:44 +03:00
isra el 4a0964dce5 infra: add workflow for building and testing 2025-03-30 10:53:26 +03:00
isra el c11fd53026 test: fix failing tests and improve coverage 2025-03-30 10:52:37 +03:00
isra el 2c419d9778 infra: update docker publish workflow 2025-03-30 10:36:41 +03:00
isra el b69852fa4c delete mongo-init script 2025-03-30 10:35:45 +03:00
isra el 4c77b967ff infra: fix docker issues 2025-03-30 10:15:32 +03:00
isra el 1abbdcd641 Update .gitignore 2025-03-30 09:54:32 +03:00
isra el e4729bc3ae Update FUNDING.yml 2025-03-30 09:54:16 +03:00
isra el 3fce2f36a4 update nextjs version 2025-03-25 08:38:26 +03:00
Israel Abebe 5233c56021 Merge pull request #61 from vernu/job-queue
Implement optional job queue for sending sms with delay
2025-03-24 03:56:16 +03:00
isra el f6763939e2 feat(api): implement optional job queue for sending sms with delay and scheduling 2025-03-24 03:51:30 +03:00
Israel Abebe ecfab77cad Merge pull request #58 from vernu/auto-refresh-history
Allow auto-refreshing of message history in the dashboard
2025-03-18 08:11:07 +03:00
Israel Abebe 2664daae88 Merge pull request #56 from markoilijoski/feature/dockerization
Feature/dockerization
2025-03-18 08:09:41 +03:00
Israel Abebe 8907380303 Update README.md 2025-03-18 08:07:56 +03:00
isra el b5e524aafd feat(web): allow auto-refreshing of message history in the dashboard 2025-03-18 08:04:24 +03:00
isra el d3269e002f fix(web): show current active subscription status in my-account correctly 2025-03-18 08:01:25 +03:00
isra el 39296aa06b chore(api): update billing permission check logic 2025-03-18 08:00:19 +03:00
Marko Ilijoski e5a93bd16a docker-compose for local env 2025-03-16 21:32:48 +01:00
Marko Ilijoski 4bc97deff1 js script to create DB and user when starting mongoDB 2025-03-16 21:32:26 +01:00
Marko Ilijoski a26d57dff3 change dockerfile base image with 18ver 2025-03-16 21:31:41 +01:00
Marko Ilijoski 2cad8dbfb2 change dockerfile base image with 18ver 2025-03-16 21:31:02 +01:00
Marko Ilijoski f060a96848 remove .env file from docker build 2025-03-16 21:28:40 +01:00
Marko Ilijoski 3f9ff8f53e update readme with 'Dockerized env' section 2025-03-16 21:28:03 +01:00
isra el 7c79c7d007 chore(web): improve my-account page ui 2025-03-16 17:57:08 +03:00
isra el 3d0ce469e9 chore(api): track more fields for polar billing 2025-03-16 17:43:43 +03:00
isra el 98d596e61b chore(api): update reminder emails 2025-03-16 17:42:00 +03:00
isra el 16b2351d9c chore(api): update polar webhook event 2025-03-15 20:25:14 +03:00
isra el 6019199055 chore(web): improve account page ui 2025-03-15 20:03:08 +03:00
isra el 4745ea0091 chore(api): update reminder email sending logic 2025-03-15 19:45:29 +03:00
isra el 3afa9b2e40 chore(api): update email template 2025-03-15 19:28:53 +03:00
isra el cce946e83a chore(web): add status page link in the footer 2025-03-15 18:57:09 +03:00
isra el 74bd7bc283 chore: delete default framework readme files 2025-03-15 17:52:02 +03:00
isra el b72e586d06 chore(api): send reminder emails to new inactive and free users 2025-03-15 17:24:02 +03:00
isra el 9f63acfe2f chore(web): update sitemap 2025-03-14 19:03:41 +03:00
isra el f0cbf92489 chore(web): improve landing page ui 2025-03-14 19:00:15 +03:00
isra el 28aee4d978 chore(web): update ToS and refund-policy 2025-03-14 18:59:34 +03:00
isra el 6ba5767aba refactor(web): refactor email verification page 2025-03-14 17:21:13 +03:00
isra el cef2c6a2fe chore(api): add email verification CTA in dashboard 2025-03-14 17:20:47 +03:00
isra el fc5a7e654d chore(web): update features section text content 2025-03-14 17:20:01 +03:00
isra el 1f90bb8255 chore(web): update ugrade to pro cta 2025-03-14 17:19:23 +03:00
isra el ebdd93c7fc chore(web): fix default device selection in send sms 2025-03-10 07:19:27 +03:00
isra el 8dec46e01d chore(web): fix react hook dependencies 2025-03-09 13:08:26 +03:00
isra el 70d35ff1a5 chore(api): update packages 2025-03-09 11:27:17 +03:00
isra el 7552fc385b chore(web): remove new badges from bulk send and webhook sections 2025-03-09 11:26:34 +03:00
isra el ca397a4554 chore(web): improve contribute modal 2025-03-09 11:25:17 +03:00
Israel Abebe 2ac20c8915 Merge pull request #55 from vernu/dev
show sent messages in dashboard
2025-03-08 23:03:06 +03:00
isra el 7876bd2f74 feat: show sent messages in dashboard 2025-03-08 22:59:24 +03:00
isra el dd142c1452 chore(web): improve upgrade to pro cta 2025-03-08 22:56:54 +03:00
Israel Abebe b324bf7715 Merge pull request #52 from vernu/dev
Dev
2025-03-08 18:56:58 +03:00
isra el 9a1fff7a5e feat: improve retrieve sms messages query performance and add pagination 2025-03-08 18:48:45 +03:00
isra el 2f0566c407 feat(web): add reply popup feature to received sms messages 2025-03-08 17:53:55 +03:00
isra el 621a516002 feat(web): auto select device if theres only 1 registered device in send sms section 2025-03-08 17:35:25 +03:00
Israel Abebe 9d748d997a Update README.md 2025-03-06 07:29:11 +03:00
isra el 3ee58b28f2 fix(web): fix redirect query not redirecting after login issue 2025-03-06 07:14:16 +03:00
isra el 2d2ffeac44 chore(web): fix next payment date formatting in my-account area 2025-03-06 07:13:17 +03:00
isra el 53f5318423 chore(web): add upgrade to pro CTA in dashboard 2025-03-06 07:12:34 +03:00
isra el 3f6aab9d17 fix(web): add missing useCallback dependency in bulk send component 2025-03-06 07:10:47 +03:00
isra el 1b129979fa fix(web): fix typo in my account page 2025-02-24 01:52:34 +03:00
isra el da6c18310b fix my-account page issues 2025-02-24 00:59:36 +03:00
isra el e262e23e2b chore(web): fix contribute modal 2025-02-24 00:20:45 +03:00
isra el f9734c417a chore(api): raise api throttling limit 2025-02-24 00:11:40 +03:00
isra el 087bdcc9d6 fix billing issues and improve ui 2025-02-24 00:09:51 +03:00
isra el dbc4e3ed4e chore(web): setup error logging with sentry 2025-02-24 00:07:30 +03:00
isra el 992c260e1a chore(api): update polar sdk and improve billing and checkout logic 2025-02-21 20:21:36 +03:00
isra el a199304e30 chore(web): improve contribute modal 2025-02-20 19:48:48 +03:00
isra el f76af5ebd3 chore(web): update pricing section ui 2025-02-20 19:28:16 +03:00
isra el bd683ca5dd chore(web): improve checkout page ui 2025-02-20 08:47:13 +03:00
isra el 107671840a chore(api): improve logging for polar webhook notifications 2025-02-20 08:46:46 +03:00
isra el 5d8dcf87dc chore(web): fix billing portal url in dashboard 2025-02-17 16:57:31 +03:00
isra el c572070270 fix(api): fix polar webhook signature verification issue 2025-02-15 19:40:14 +03:00
isra el 2e924f277a chore(api): add more logs to polar integration 2025-02-15 18:50:14 +03:00
isra el f3e999e9d9 fxi(api): fix polar webhook issue 2025-02-15 18:10:55 +03:00
isra el 6641fb83a4 chore(web): redirect unauthenticated users to signin in checkout page 2025-02-15 16:34:49 +03:00
isra el c898849ef4 chore(api): update checkout success and cancel urls 2025-02-15 16:26:54 +03:00
isra el fe1d4d3f1d chore(web): update current plan ui 2025-02-15 16:13:45 +03:00
isra el 078208c0fc feat: show current plan in dashboard 2025-02-15 15:48:05 +03:00
isra el fa45dcb0d1 chore(api): add more logs to billing webhook 2025-02-15 14:28:54 +03:00
isra el 9c2c70270f update checkout page 2025-02-15 13:10:35 +03:00
Israel Abebe a9327c5bb9 Merge pull request #48 from vernu/billing
new pricing and billing
2025-02-15 12:56:31 +03:00
isra el 014ca7cb52 new pricing and billing 2025-02-15 12:54:16 +03:00
isra el d3e0bb5885 fix(api): fix email verification endpoint not working when not authenticated 2025-02-11 19:39:08 +03:00
isra el 0be27d021d config(web): update eslint config 2025-01-20 18:26:40 +03:00
isra el ecaa24c0f6 chore(web): redirect to email verification page after signup 2025-01-20 18:23:04 +03:00
isra el b41262f5d3 chore(web): update crypto addresses 2025-01-20 18:20:19 +03:00
isra el e53b65ee24 chore(api): update welcome email template 2025-01-10 16:08:06 +03:00
isra el b663259ede chore(web): update sitemap 2025-01-06 20:37:48 +03:00
isra el 31ba75a816 refactor(web): cleanup dashboard components 2025-01-06 20:36:38 +03:00
isra el 2bd94474cd chore(web): add robots and sitemap 2025-01-06 06:18:05 +03:00
isra el 1dcde4b345 chore(web): update label for webhook 2025-01-06 06:17:46 +03:00
isra el 85dddc61fb feat(web): implement email verification ui 2025-01-06 06:17:19 +03:00
isra el 53a46bd2c4 feat(api): implement email verification 2025-01-06 06:16:46 +03:00
isra el 9409d162ce chore(web): add contribute page 2025-01-06 06:15:13 +03:00
isra el 9f3b257588 chore(api): better track webhook delivery failures 2024-12-28 10:36:04 +03:00
isra el 3a2b714fdd fix(infra): fix docker-compose.yml 2024-12-28 10:32:34 +03:00
isra el b390d4d11d chore(api): improve logging for webhook notifications 2024-12-22 17:14:41 +03:00
isra el 3122e34797 chore(api): improve error logging for failed webhook notification deliveries 2024-12-22 17:04:42 +03:00
isra el 114db66621 chore(web): remove unused imports 2024-12-22 12:11:27 +03:00
isra el 379e8e70e4 chore(web): improve webhooks section mobile responsiveness 2024-12-22 12:08:27 +03:00
isra el 7c7d632949 chore(web): improve received sms section ui 2024-12-22 12:07:03 +03:00
isra el 46c9c623c3 chore(web): prevent account deletion request if user has already requested one 2024-12-22 11:30:11 +03:00
isra el 87ee347a9e ui(web): improve dashboard ui 2024-12-22 02:33:47 +03:00
Israel Abebe 9b7d5cea4b Merge pull request #37 from vernu/webhooks
Webhooks implementation v1
2024-12-22 01:30:20 +03:00
isra el 409ac5f5cd chore(api): handle webhook related edgecases and cleanup 2024-12-22 01:06:34 +03:00
isra el 717e4fb1a1 chore(api): make user field required in webhook subscription schema 2024-12-22 01:03:57 +03:00
isra el a61c1c4ce8 fix(api): fix webhook notification schema type issue 2024-12-22 01:03:24 +03:00
isra el f04fc47a66 feat(web): build webhook ui 2024-12-22 01:02:12 +03:00
isra el cd31316c86 feat(api): webhooks implementation v1 2024-12-21 14:55:06 +03:00
Israel Abebe 3e053c6b51 Merge pull request #35 from mms-gianni/feature/add-build-job
Adds a container build job
2024-12-21 02:28:02 +03:00
Israel Abebe 45ff1b2b4f Merge pull request #34 from mms-gianni/feature/add-docker-components
Add Docker Compose setup
2024-12-21 02:26:28 +03:00
Israel Abebe 48b3ddc24f Update .env.example 2024-12-21 02:25:54 +03:00
Israel Abebe dd8fcd26f1 chore(infra): update db container name 2024-12-20 06:03:39 +03:00
Israel Abebe 3670415ea7 chore(infra): add volume to mongo container 2024-12-19 22:10:58 +03:00
Israel Abebe cae4240afb Merge pull request #31 from eltociear/patch-1
fix typo
2024-12-19 22:06:22 +03:00
Gianni Carafa 3c01873fb0 Update Docker image references to use the latest version from the Github Container Registry. 2024-12-19 12:14:59 +01:00
Gianni Carafa f0d0a80868 Create a workflow to build and push Docker images for different project components. 2024-12-19 11:57:07 +01:00
Gianni Carafa a794408ccb Update Docker setup with LTS Node version, define environment variables and services in docker-compose, and configure web application Dockerfile with multi-stage builds. 2024-12-19 10:54:01 +01:00
Ikko Eltociear Ashimine 6136034774 chore: update MainActivity.java
occured -> occurred
2024-12-19 00:52:26 +09:00
isra el 216bffcb28 chore(web): update legacy endpoints in landing page code snippet section 2024-12-14 08:55:02 +03:00
isra el f37001afff fix(web): fix logout page issues 2024-12-10 11:41:16 +03:00
isra el 2e82e8ff6f chore(web): minor ui updates 2024-12-10 11:40:52 +03:00
isra el 685ddd133c fix(web): delete unused page 2024-12-07 10:13:42 +03:00
Israel Abebe 979aa9d9d1 Merge pull request #29 from vernu/bulk-messaging
Bulk messaging
2024-12-07 10:04:48 +03:00
isra el a3bfb7ca3b feat(web): bulk messaging feature 2024-12-07 10:04:02 +03:00
isra el 13192aceb3 feat(api): bulk messaging feature 2024-12-07 10:02:34 +03:00
isra el 57934046ee fix(web): fix header color in dark mode 2024-12-06 23:53:49 +03:00
Israel Abebe 4786feaf80 Merge pull request #28 from vernu/dark-mode
dark mode support
2024-12-06 23:44:12 +03:00
isra el 6b2fbb574a ui(web): dark mode support 2024-12-06 23:43:15 +03:00
isra el d485219c68 fix(api): fix phone no not getting saved during signup 2024-12-02 08:56:04 +03:00
isra el 7f31a36631 chore(web): minor ui improvements in dashboard 2024-12-02 08:46:26 +03:00
isra el 1ed2f9ff59 chore(web): fix github repo url 2024-12-02 08:05:30 +03:00
Israel Abebe 6cfe7381b5 Merge pull request #27 from vernu/ui-refactor
major ui refactor
2024-12-01 02:33:43 +03:00
isra el 405f737449 refactor(web): major ui refactor 2024-12-01 02:31:46 +03:00
isra el bb2cc320ec chore(api): improve email templates 2024-12-01 02:28:39 +03:00
isra el e2acbc077d feat(api): implement update profile and change password endpoints 2024-12-01 01:51:38 +03:00
isra el efb3c03c5c chore(api): sort api keys by properly 2024-11-30 07:02:24 +03:00
isra el 07321df860 fix(api): fix can-modify-api-key-guard from preventing deletion of revoked apiKeys 2024-11-30 07:01:36 +03:00
isra el 9b8fa3ebb0 feat(api): enable renaming api keys 2024-11-29 09:18:47 +03:00
isra el 44636fe1b5 feat(api): allow revoking and deleting of unused api keys 2024-11-29 08:37:28 +03:00
isra el 8e83e0deb3 chore(android): add error logs for failed api calls 2024-11-25 10:48:41 +03:00
isra el c3a21ba69f chore(android): update receive sms endpoint 2024-11-25 10:47:22 +03:00
isra el 4faf422d3f feat(api): depricate camelCase endpoints infavor of kebab-case 2024-11-25 10:46:19 +03:00
isra el da295dfe4b chore(api): make email sendFrom optional 2024-10-27 08:39:43 +03:00
isra el f207cf2439 init migration to app router 2024-10-26 15:20:58 +03:00
isra el 6f250192da chore(api): update dependencies 2024-10-19 11:29:29 +03:00
isra el 0cee3a244c chore(api): send generic error message response in password reset 2024-10-19 11:18:16 +03:00
isra el b3d3389dca fix(api): return error message if sms is not sent due to fcm failure 2024-10-17 09:50:04 +03:00
isra el 0991db5ab8 chore(api): update welcome email template 2024-10-17 09:47:15 +03:00
isra el 7bc2d38204 chore(web): fix typo 2024-10-17 09:36:26 +03:00
Israel Abebe 3c8a505f4b Update README.md 2024-10-04 09:37:52 +03:00
isra el 1ad80eb6a7 docs: add discord link in readme 2024-09-24 12:47:02 +03:00
isra el 2bf69e1b5e docs: update readme 2024-09-23 08:46:10 +03:00
isra el 2398c50fdf chore(web): add CTA in dashboard 2024-09-15 09:10:42 +03:00
isra el 8ae764f8c6 chore(api): update mail sending options 2024-09-14 10:04:13 +03:00
Israel Abebe 7dcefba229 Merge pull request #19 from vernu/fcm-hotfix
hotfix(api): update deprecated firebase-admin fcm send api
2024-09-12 23:07:28 +03:00
isra el a3eeb6da9a hotfix(api): update deprecated firebase-admin fcm send api 2024-09-12 23:06:56 +03:00
isra el 110401a3b8 chore(web): fix typo 2024-09-09 19:47:44 +03:00
isra el bfb08ea86f chore(web): update welcome email template 2024-09-02 07:03:16 +03:00
isra el d2ab8944e7 feat(api): send welcome emails on signup 2024-08-31 16:07:09 +03:00
isra el ac693b5163 chore(web): update navbar 2024-08-31 16:05:50 +03:00
isra el 6cf6492cb4 chore(web): include ids in analytics 2024-08-31 16:04:41 +03:00
isra el c0a4f2673a chore(web): add live support widget 2024-06-17 10:59:13 +03:00
isra el 33324f5515 chore(web): fix navbar and footer mobile responsiveness 2024-06-03 07:44:38 +03:00
isra el f81961c5b6 chore(web): add support and contact sections 2024-06-03 07:24:05 +03:00
isra el 93c8e3887a chore(web): add cURL example in code snippets section 2024-05-28 11:18:20 +03:00
isra el dd6bd9d3ad chore(api): update error message 2024-05-14 04:57:58 +03:00
isra el 3ce839bbd5 fix(api): throw exception if message is blank or invalid recipients provided 2024-05-14 04:44:59 +03:00
isra el 38652c3a82 chore(api): throttle by ip 2024-05-13 09:08:28 +03:00
isra el 11653896a5 chore(android): bump version to 2.3.1 (11) 2024-05-06 05:12:28 +03:00
isra el 42ca8e7256 chore(android): bump version to 1.3.1 (11) 2024-05-06 04:48:28 +03:00
isra el c2e7711167 fix: resolve timezone issues for received messages 2024-05-06 04:39:13 +03:00
isra el 0bc7350cf4 chore(api): log errors 2024-04-29 12:59:24 +03:00
isra el 476d6ddbfd chore(api): update sms schema 2024-04-29 12:58:21 +03:00
isra el 479bede1b5 fix(api): fix signin with google not updating user data properly 2024-04-29 04:44:19 +03:00
isra el 1e7aba00f1 chore(api): update sms batch schema 2024-04-28 19:37:49 +03:00
isra el 623847dc84 Create privacy-policy 2024-04-28 18:03:22 +03:00
isra el 1497013374 Update README.md 2024-04-28 18:03:01 +03:00
isra el 8a8ae33900 chore(api): better handle batch sms 2024-04-28 18:02:36 +03:00
isra el f7fa02cddb Update README.md 2024-04-27 05:26:06 +03:00
isra el c265eb7def chore: validate email and password length on signup 2024-04-27 05:25:50 +03:00
isra el 583e184b47 Update FUNDING.yml 2024-04-24 14:12:37 +03:00
isra el d2abc28a1a chore(api): save last login at 2024-04-24 14:11:19 +03:00
Israel Abebe 28d40e05b4 Merge pull request #8 from vernu/feature/receive-sms
Receive Messages Feature
2024-04-20 15:37:57 +03:00
isra el 43f1e980f0 chore(android): bump version to 2.3.0 (10) 2024-04-20 15:36:34 +03:00
isra el c78427db7b chore(web): update app download link 2024-04-20 15:32:40 +03:00
isra el edd6885e1e ui(web): improve receive sms tab ui 2024-04-20 15:32:07 +03:00
isra el 7e6c490840 docs: improve documentation 2024-04-20 15:06:57 +03:00
isra el dd4e9f8038 chore(android): disable sticky notification 2024-04-20 14:30:33 +03:00
isra el 2481180a57 fix(web): prevent api call if there is no activedevice 2024-04-20 14:14:15 +03:00
isra el da070e0213 chore(web): display received sms count in dashboard 2024-04-20 14:08:22 +03:00
isra el 47bd11ae08 feat(web): implement received sms tab in dashboard 2024-04-20 14:07:52 +03:00
isra el 3f63005c60 feat(api): track received sms count stat 2024-04-20 14:06:40 +03:00
isra el 64e7211f28 ui(web): improve dashboard ui 2024-04-20 13:00:44 +03:00
isra el 4d1f2e144e ui(web): improve dashboard ui 2024-04-20 12:30:06 +03:00
isra el fd0bd0e472 chore(android): improve main activity layout 2024-04-20 11:44:09 +03:00
isra el 238d1830cc chore(android): temporarily disable room db usage 2024-04-20 11:25:20 +03:00
isra el 3ac80d7b12 chore(web): prevent users from selecting a disabled device in send sms modal 2024-04-20 10:38:30 +03:00
isra el b9dfa8cfd8 fix(web): null check 2024-04-20 10:34:21 +03:00
isra el be5e9b1782 chore(api): temporarily disable device and apiKey deletion 2024-04-20 10:33:37 +03:00
isra el 10b45bd92f feat(api): implement an endpoint to retrieve received messages 2024-04-20 01:05:54 +03:00
isra el d10fa350d9 feat(api): implement access logging 2024-04-20 00:45:53 +03:00
isra el db1c4475a6 chore(android): improve instructions 2024-04-20 00:44:11 +03:00
isra el aad73880f3 chore(android): receive sms feature improvements 2024-04-20 00:43:46 +03:00
isra el 31735b8d14 chore(android): refactor MainActivity 2024-04-16 08:07:08 +03:00
isra el f4f1ce41b1 chore(android): add utils class 2024-04-16 08:02:52 +03:00
isra el 9527b51cad feat(android): implement SMS Receive Broadcast Receiver 2024-04-16 08:02:30 +03:00
isra el 28aa08da88 feat(android): implement boot complete listener to start sticky notification service 2024-04-16 08:01:30 +03:00
isra el 30b005d867 feat(android): init room db 2024-04-16 08:00:48 +03:00
isra el a3882b30a0 chore(android): sticky notification service 2024-04-16 07:59:56 +03:00
isra el 24a37ccd1e chore(android): add required permissions for receiving sms 2024-04-16 07:57:46 +03:00
isra el 2b482c8801 chore(android): add new app constant 2024-04-16 07:39:58 +03:00
isra el c82f8e801c chore(android): reorganize components in main activity 2024-04-16 07:38:52 +03:00
isra el 51b1828b1d feat(api): receive and save sms 2024-04-16 07:38:19 +03:00
isra el 2013ee5f7e chore(android): update types for device version fields 2024-04-15 02:15:15 +03:00
isra el 93652cd835 chore(android): create an ApiManager class 2024-04-15 02:14:30 +03:00
isra el cd0c40bffa chore(android): add a function to clear a shared pref value 2024-04-15 02:13:27 +03:00
isra el a09510bc82 chore(android): update sms payload fields 2024-04-15 02:11:23 +03:00
isra el 646e7f11b6 chore(android): add AppConstants class 2024-04-15 02:10:12 +03:00
isra el ef49ac1624 chore(android): main activity minor ui change 2024-04-15 02:07:41 +03:00
isra el cdb1a0d73a chore(api): update sms payload fields 2024-04-15 02:06:34 +03:00
isra el c31763d1e4 chore(android): replace query apiKey auth by header 2024-04-15 01:51:55 +03:00
isra el 3d6f3da6d1 chore(api): update response codes to 200 instead of the default 201 2024-04-15 01:50:53 +03:00
isra el 80406ab331 chore(api): update sms schema 2024-04-15 01:43:56 +03:00
isra el 96776aa879 chore(api): support apiKey authentication via headers in addition to query params 2024-04-15 01:41:35 +03:00
isra el 583a5b8ad6 fix(web): include missing api call for checking current user 2024-04-15 01:40:44 +03:00
isra el 838dcf4248 feat(api): track apiKey usage 2024-04-15 01:35:24 +03:00
isra el fd6e06baf7 chore(web): update meta tags 2024-04-15 01:27:02 +03:00
isra el aa8201eaef chore(web): check for token expiration on launch and redirect to login 2024-04-15 01:26:26 +03:00
isra el c866d0acec chore(api): update jwt expiration 2024-04-15 01:22:13 +03:00
isra el ec4a92e555 chore(web): display device id and copy feature in dashboard 2024-04-13 16:05:55 +03:00
isra el ce1aa2f942 chore: update .gitignore 2024-04-13 04:49:44 +03:00
Israel Abebe 04a2577acc Create LICENSE 2024-04-13 04:43:22 +03:00
Israel Abebe f241b7f67d Create FUNDING.yml 2024-04-13 04:41:23 +03:00
548 changed files with 65395 additions and 13680 deletions
+112
View File
@@ -0,0 +1,112 @@
# 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 scripts,
docs, CI, and Dockerfiles.
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
+14
View File
@@ -0,0 +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
# 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
# buy_me_a_coffee: vernu
custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2']
+103
View File
@@ -0,0 +1,103 @@
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
+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
+124
View File
@@ -0,0 +1,124 @@
name: Build and push a Docker image
on:
push:
branches: ["main"]
tags: ["v*.*.*"]
workflow_dispatch:
inputs:
git_ref:
description: 'Git Ref (Branch, Tag or Release)'
required: true
default: 'main'
type: string
image_name:
description: 'Docker image name (api, web, or both)'
required: true
default: 'both'
type: choice
options:
- api
- web
- both
env:
# Use docker.io for Docker Hub if empty
REGISTRY: ghcr.io
# github.repository as <account>/<repo>
#IMAGE_NAME: ${{ github.repository }}
jobs:
build:
name: Build and push Docker image
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
# This is used to complete the identity challenge
# with sigstore/fulcio when running outside of PRs.
id-token: write
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
ref: ${{ github.event.inputs.git_ref || github.ref }}
- name: Set up QEMU
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@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@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
# Extract metadata (tags, labels) for Docker
# https://github.com/docker/metadata-action
- 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@v6
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
images: ${{ env.REGISTRY }}/${{ github.repository }}/api
tags: |
type=raw,value=latest,enable={{is_default_branch}}
type=semver,pattern=v{{version}}
type=semver,pattern=v{{major}}.{{minor}}
type=semver,pattern=v{{major}}
type=raw,value=${{ github.event.inputs.git_ref }},enable=${{ github.event_name == 'workflow_dispatch' }}
# Build and push Docker image with Buildx (don't push on PR)
# https://github.com/docker/build-push-action
- 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@v6
with:
context: api
build-args: VERSION=${{ github.event.inputs.git_ref || github.ref_name }}
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta-api.outputs.tags }}
platforms: linux/amd64,linux/arm64
labels: ${{ steps.meta-api.outputs.labels }}
# Extract metadata (tags, labels) for Docker
# https://github.com/docker/metadata-action
- 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@v6
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
images: ${{ env.REGISTRY }}/${{ github.repository }}/web
tags: |
type=raw,value=latest,enable={{is_default_branch}}
type=semver,pattern=v{{version}}
type=semver,pattern=v{{major}}.{{minor}}
type=semver,pattern=v{{major}}
type=raw,value=${{ github.event.inputs.git_ref }},enable=${{ github.event_name == 'workflow_dispatch' }}
# Build and push Docker image with Buildx (don't push on PR)
# https://github.com/docker/build-push-action
- 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@v6
with:
context: web
build-args: VERSION=${{ github.event.inputs.git_ref || github.ref_name }}
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta-web.outputs.tags }}
platforms: linux/amd64,linux/arm64
labels: ${{ steps.meta-web.outputs.labels }}
+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
+7
View File
@@ -0,0 +1,7 @@
.vercel
.DS_Store
*-monitor
.cursor
.claude/
.omc/
+21
View File
@@ -0,0 +1,21 @@
MIT License
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
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+237 -18
View File
@@ -1,40 +1,259 @@
# TextBee - Android SMS Gateway
![GitHub stars](https://img.shields.io/github/stars/vernu/textbee)
![License](https://img.shields.io/github/license/vernu/textbee)
![Release](https://img.shields.io/github/v/release/vernu/textbee)
[![Discord](https://img.shields.io/discord/1236287182940016723?label=Discord&logo=discord)](https://discord.gg/d7vyfBpWbQ)
A simple SMS gateway that allows users to send SMS messages from a web interface or
from their application via a REST API. It utilizes android phones as SMS gateways.
# textbee.dev - android sms gateway
- **Technology stack**: React, Next.js, Node.js, NestJs, MongoDB, Android, Java
- **Link**: [https://textbee.dev](https://textbee.dev/)
Send and receive SMS messages using your own Android phone - no Twilio, no per-message fees. Free, open-source, and self-hostable.
![](https://ik.imagekit.io/vernu/textbee/texbee-landing-light.png?updatedAt=1687076964687)
Manage SMS messages through a web dashboard or a REST API. textbee is ideal for businesses, developers, and hobbyists looking for a reliable and cost-effective solution to automate SMS messaging.
## Usage
**Website:** [https://textbee.dev](https://textbee.dev?ref=gh-readme)
![](https://ik.imagekit.io/vernu/textbee/textbee.dev-landingpage-screenshot.png?updatedAt=1749102564772)
## 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
- Multi-device support for higher SMS throughput
- Secure API authentication with API keys
- 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/android](https://textbee.dev/android)
2. Install the app on your Android phone from [textbee.dev/download](https://textbee.dev/download)
3. Open the app and grant the permissions for SMS
4. Go to [textbee.dev/dashboard](https://textbee.dev/dashboard) and click register device/ generate API Key
4. Go to [textbee.dev/dashboard](https://textbee.dev/dashboard) and click register device / generate API key
5. Scan the QR code with the app or enter the API key manually
6. You are ready to send SMS messages from the dashboard or from your application via the REST API
6. You're ready to send SMS from the dashboard or from your application via the REST API
**Code Snippet**: Few lines of code showing how to send an SMS message via the REST API
### Sending an SMS
```javascript
const API_KEY = 'YOUR_API_KEY';
const DEVICE_ID = 'YOUR_DEVICE_ID';
await axios.post(`https://api.textbee.dev/api/v1/gateway/devices/${DEVICE_ID}/sendSMS?apiKey=${API_KEY}`, {
receivers: [ '+251912345678' ],
smsBody: 'Hello World!',
})
await axios.post(`https://api.textbee.dev/api/v1/gateway/devices/${DEVICE_ID}/send-sms`, {
recipients: [ '+251912345678' ],
message: 'Hello World!',
}, {
headers: { 'x-api-key': API_KEY },
});
```
<details>
<summary><b>Python</b></summary>
```python
import requests
API_KEY = 'YOUR_API_KEY'
DEVICE_ID = 'YOUR_DEVICE_ID'
requests.post(
f'https://api.textbee.dev/api/v1/gateway/devices/{DEVICE_ID}/send-sms',
json={
'recipients': ['+251912345678'],
'message': 'Hello World!',
},
headers={'x-api-key': API_KEY},
)
```
</details>
<details>
<summary><b>curl</b></summary>
```bash
curl -X POST "https://api.textbee.dev/api/v1/gateway/devices/YOUR_DEVICE_ID/send-sms" \
-H 'x-api-key: YOUR_API_KEY' \
-H 'Content-Type: application/json' \
-d '{
"recipients": [ "+251912345678" ],
"message": "Hello World!"
}'
```
</details>
### Receiving SMS
Enable SMS receiving in the mobile app, then access incoming messages via the REST API, the dashboard, or webhook notifications delivered to your preferred URL.
```javascript
const API_KEY = 'YOUR_API_KEY';
const DEVICE_ID = 'YOUR_DEVICE_ID';
await axios.get(`https://api.textbee.dev/api/v1/gateway/devices/${DEVICE_ID}/get-received-sms`, {
headers: { 'x-api-key': API_KEY },
});
```
<details>
<summary><b>curl</b></summary>
```bash
curl -X GET "https://api.textbee.dev/api/v1/gateway/devices/YOUR_DEVICE_ID/get-received-sms" \
-H "x-api-key: YOUR_API_KEY"
```
</details>
## Use Cases
- OTP / 2FA delivery for your app
- Order and appointment notifications
- Alerts from servers, cron jobs, and home automation
- Form-to-SMS and lead follow-ups
- Bulk announcements to a contact list (CSV upload)
## FAQ
<details>
<summary><b>Will my carrier block my number for sending too many messages?</b></summary>
Carriers apply their own rate limits and anti-spam policies, which vary by country and plan. For personal and low-volume use this is rarely an issue. For higher throughput, use multiple devices/SIMs and keep sending rates reasonable. You are responsible for staying within your carrier's terms.
</details>
<details>
<summary><b>Is it legal to send marketing SMS this way?</b></summary>
SMS marketing is regulated in most countries (e.g., TCPA in the US, GDPR/ePrivacy in the EU). textbee is a tool — you are responsible for obtaining consent and complying with the laws that apply to you and your recipients.
</details>
<details>
<summary><b>Does my phone need to stay on?</b></summary>
Yes — messages are sent through your phone, so it needs to be powered on with the app running and connected to the internet. A spare Android phone plugged into a charger works great as a dedicated gateway.
</details>
<details>
<summary><b>Is there a limit on the cloud-hosted version?</b></summary>
See [textbee.dev](https://textbee.dev) for current plans and limits. You can always self-host for full control.
</details>
## Self-Hosting
**Technology stack**: React, Next.js, Node.js, NestJS, MongoDB, Android, Kotlin, Jetpack Compose, Java (legacy)
### Setting Up Database
1. **Install MongoDB on Your Server**: Follow the official MongoDB installation guide for your operating system.
2. **Using MongoDB Atlas**: Alternatively, you can create a free database on MongoDB Atlas. Sign up at [MongoDB Atlas](https://www.mongodb.com/cloud/atlas) and follow the instructions to set up your database.
### Firebase Setup
1. Create a Firebase project.
2. Enable Firebase Cloud Messaging (FCM) in your Firebase project.
3. Obtain the Firebase credentials for backend use and the Android app.
### Building the Android App
1. Clone the repository and navigate to the Android project directory.
2. Update the `google-services.json` file with your Firebase project configuration.
3. Update every occurrence of `textbee.dev` with your own domain in the project.
4. Build the app using Android Studio or the command line:
```bash
./gradlew assembleRelease
```
### Building the Web
1. Navigate to the `web` directory.
2. Copy the `.env.example` file to `.env`:
```bash
cp .env.example .env
```
3. Update the `.env` file with your own credentials.
4. Install dependencies:
```bash
pnpm install
```
5. Build the web application:
```bash
pnpm build
```
### Building the API
1. Navigate to the `api` directory.
2. Copy the `.env.example` file to `.env`:
```bash
cp .env.example .env
```
3. Update the `.env` file with your own credentials.
4. Install dependencies:
```bash
pnpm install
```
5. Build the API:
```bash
pnpm build
```
### Hosting on a VPS
1. Install `pnpm`, `pm2`, and `Caddy` on your VPS.
2. Use `pm2` to manage your Node.js processes:
```bash
pm2 start dist/main.js --name textbee-api
```
3. Configure `Caddy` to serve your web application and API. Example Caddyfile:
```
textbee.dev {
reverse_proxy /api/* localhost:3000
reverse_proxy /* localhost:3001
}
```
4. Ensure your domain points to your VPS and Caddy is configured properly.
### Dockerized env
#### Requirements:
- Docker installed
1. After setting up Firebase, update your `.env` in `web` && `api` folder.
```bash
cd web && cp .env.example .env \
&& 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.
```bash
docker compose up -d
```
To stop the containers simply type
```bash
docker compose down
```
## Contributing
Contributions are welcome!
1. Fork the project.
1. [Fork](https://github.com/vernu/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 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)
+5
View File
@@ -0,0 +1,5 @@
# Security Policy
## Reporting a Vulnerability
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.
+8 -1
View File
@@ -13,4 +13,11 @@
.externalNativeBuild
.cxx
local.properties
/app/release/*
/app/release/*
*.apk
# Firebase configuration files
/app/src/dev/google-services.json
/app/src/prod/google-services.json
/app/google-services.json
+1 -1
View File
@@ -1,6 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="CompilerConfiguration">
<bytecodeTargetLevel target="11" />
<bytecodeTargetLevel target="17" />
</component>
</project>
+23
View File
@@ -0,0 +1,23 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="deploymentTargetDropDown">
<value>
<entry key="app">
<State>
<runningDeviceTargetSelectedWithDropDown>
<Target>
<type value="RUNNING_DEVICE_TARGET" />
<deviceKey>
<Key>
<type value="SERIAL_NUMBER" />
<value value="adb-RFCX417HC5V-ZWjtU2._adb-tls-connect._tcp." />
</Key>
</deviceKey>
</Target>
</runningDeviceTargetSelectedWithDropDown>
<timeTargetWasSelectedWithDropDown value="2025-08-09T15:50:54.600783Z" />
</State>
</entry>
</value>
</component>
</project>
+2 -2
View File
@@ -4,15 +4,15 @@
<component name="GradleSettings">
<option name="linkedExternalProjectsSettings">
<GradleProjectSettings>
<option name="testRunner" value="GRADLE" />
<option name="distributionType" value="DEFAULT_WRAPPED" />
<option name="externalProjectPath" value="$PROJECT_DIR$" />
<option name="gradleJvm" value="jbr-17" />
<option name="modules">
<set>
<option value="$PROJECT_DIR$" />
<option value="$PROJECT_DIR$/app" />
</set>
</option>
<option name="resolveExternalAnnotations" value="false" />
</GradleProjectSettings>
</option>
</component>
+10
View File
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectMigrations">
<option name="MigrateToGradleLocalJavaHome">
<set>
<option value="$PROJECT_DIR$" />
</set>
</option>
</component>
</project>
+1 -2
View File
@@ -1,4 +1,3 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="DesignSurface">
<option name="filePathToZoomLevelMap">
@@ -7,7 +6,7 @@
</map>
</option>
</component>
<component name="ProjectRootManager" version="2" languageLevel="JDK_11" default="true" project-jdk-name="Android Studio default JDK" project-jdk-type="JavaSDK">
<component name="ProjectRootManager" version="2" languageLevel="JDK_17" default="true" project-jdk-name="jbr-17" project-jdk-type="JavaSDK">
<output url="file://$PROJECT_DIR$/build/classes" />
</component>
<component name="ProjectType">
+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()`
+89 -8
View File
@@ -1,37 +1,93 @@
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 {
applicationId "com.vernu.sms"
minSdk 24
targetSdk 32
versionCode 9
versionName "2.2.0"
targetSdk 34
versionCode 18
versionName "2.8.0"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
// javaCompileOptions {
// annotationProcessorOptions {
// arguments["room.schemaLocation"] = "$projectDir/schemas"
// }
// }
}
signingConfigs {
debug {
storeFile file("${System.getenv("HOME")}/.android/debug.keystore")
storePassword "android"
keyAlias "androiddebugkey"
keyPassword "android"
}
}
buildTypes {
release {
minifyEnabled false
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
// TODO: Change to release signing config
signingConfig signingConfigs.debug
}
debug {
minifyEnabled false
debuggable true
signingConfig signingConfigs.debug
}
}
flavorDimensions "environment"
productFlavors {
dev {
dimension "environment"
applicationId "com.vernu.sms.dev"
versionNameSuffix "-dev"
buildConfigField "String", "API_BASE_URL", '"https://api.dev.textbee.dev/api/v1/"'
buildConfigField "String", "ENVIRONMENT", '"development"'
resValue "string", "app_name", "textbee.dev (Dev)"
}
prod {
dimension "environment"
applicationId "com.vernu.sms"
buildConfigField "String", "API_BASE_URL", '"https://api.textbee.dev/api/v1/"'
buildConfigField "String", "ENVIRONMENT", '"production"'
resValue "string", "app_name", "textbee.dev"
}
}
compileOptions {
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.4.0'
implementation 'com.google.android.material:material:1.8.0'
implementation 'androidx.constraintlayout:constraintlayout:2.0.4'
testImplementation 'junit:junit:4.13.2'
androidTestImplementation 'androidx.test.ext:junit:1.1.3'
@@ -41,9 +97,34 @@ dependencies {
implementation 'com.google.firebase:firebase-analytics'
implementation 'com.google.firebase:firebase-messaging'
implementation 'com.google.firebase:firebase-messaging-directboot'
implementation 'com.google.firebase:firebase-crashlytics'
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"
}
-39
View File
@@ -1,39 +0,0 @@
{
"project_info": {
"project_number": "14326313838",
"project_id": "vernu-sms",
"storage_bucket": "vernu-sms.appspot.com"
},
"client": [
{
"client_info": {
"mobilesdk_app_id": "1:14326313838:android:4ea4380d88b057e9eb877f",
"android_client_info": {
"package_name": "com.vernu.sms"
}
},
"oauth_client": [
{
"client_id": "14326313838-0kmpefogqk8h5ntf2mth4e9cht9pbfai.apps.googleusercontent.com",
"client_type": 3
}
],
"api_key": [
{
"current_key": "AIzaSyDNZP2HeVPZ6_V9oq2UTYWmJ4FfenrRBIQ"
}
],
"services": {
"appinvite_service": {
"other_platform_oauth_client": [
{
"client_id": "14326313838-0kmpefogqk8h5ntf2mth4e9cht9pbfai.apps.googleusercontent.com",
"client_type": 3
}
]
}
}
}
],
"configuration_version": "1"
}
@@ -0,0 +1,39 @@
{
"project_info": {
"project_number": "YOUR_DEV_PROJECT_NUMBER",
"project_id": "your-dev-project-id",
"storage_bucket": "your-dev-project-id.appspot.com"
},
"client": [
{
"client_info": {
"mobilesdk_app_id": "1:YOUR_DEV_PROJECT_NUMBER:android:YOUR_DEV_APP_ID",
"android_client_info": {
"package_name": "com.vernu.sms.dev"
}
},
"oauth_client": [
{
"client_id": "YOUR_DEV_CLIENT_ID",
"client_type": 3
}
],
"api_key": [
{
"current_key": "YOUR_DEV_API_KEY"
}
],
"services": {
"appinvite_service": {
"other_platform_oauth_client": [
{
"client_id": "YOUR_DEV_CLIENT_ID",
"client_type": 3
}
]
}
}
}
],
"configuration_version": "1"
}
+71 -5
View File
@@ -3,16 +3,32 @@
xmlns:tools="http://schemas.android.com/tools"
package="com.vernu.sms">
<uses-feature
android:name="android.hardware.telephony"
android:required="false" />
<uses-permission android:name="android.permission.READ_PHONE_STATE" />
<uses-permission android:name="android.permission.SEND_SMS" />
<uses-permission android:name="android.permission.RECEIVE_SMS" />
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED"/>
<uses-permission android:name="android.permission.FOREGROUND_SERVICE"/>
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_REMOTE_MESSAGING"/>
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<application
android:name=".SMSGatewayApplication"
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
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"
android:authorities="${applicationId}.androidx-startup"
tools:node="remove" />
<service
android:name=".services.FCMService"
android:exported="false"
@@ -21,15 +37,65 @@
<action android:name="com.google.firebase.MESSAGING_EVENT" />
</intent-filter>
</service>
<activity
android:name="com.vernu.sms.activities.MainActivity"
<service
android:name=".services.StickyNotificationService"
android:enabled="true"
android:exported="false"
android:foregroundServiceType="remoteMessaging">
</service>
<receiver
android:name=".receivers.SMSBroadcastReceiver"
android:exported="true">
<intent-filter
android:priority="1000">
<action android:name="android.provider.Telephony.SMS_RECEIVED"/>
</intent-filter>
</receiver>
<receiver
android:name=".receivers.SMSStatusReceiver"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<action android:name="SMS_SENT" />
<action android:name="SMS_DELIVERED" />
</intent-filter>
</receiver>
<receiver android:enabled="true"
android:name=".receivers.BootCompletedReceiver"
android:exported="true"
android:permission="android.permission.RECEIVE_BOOT_COMPLETED">
<intent-filter>
<action android:name="android.intent.action.BOOT_COMPLETED"/>
</intent-filter>
</receiver>
<activity
android:name=".ui.splash.SplashActivity"
android:exported="true"
android:theme="@style/Theme.SMSGateway.NoActionBar">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<activity
android:name=".ui.onboarding.OnboardingActivity"
android:exported="false"
android:theme="@style/Theme.SMSGateway.NoActionBar" />
<activity
android:name=".ui.main.NewMainActivity"
android:exported="false"
android:theme="@style/Theme.SMSGateway.NoActionBar" />
<activity
android:name=".activities.MainActivity"
android:exported="true"
android:theme="@style/Theme.SMSGateway.NoActionBar" />
<activity
android:name=".activities.SMSFilterActivity"
android:exported="false"
android:theme="@style/Theme.SMSGateway.NoActionBar"
android:parentActivityName=".activities.MainActivity" />
<activity
android:name="com.journeyapps.barcodescanner.CaptureActivity"
android:screenOrientation="portrait"
Binary file not shown.

Before

Width:  |  Height:  |  Size: 237 KiB

After

Width:  |  Height:  |  Size: 131 KiB

@@ -0,0 +1,33 @@
package com.vernu.sms;
import com.vernu.sms.services.GatewayApiService;
import retrofit2.Retrofit;
import retrofit2.converter.gson.GsonConverterFactory;
public class ApiManager {
private static GatewayApiService apiService;
public static GatewayApiService getApiService() {
if (apiService == null) {
apiService = createApiService();
}
return apiService;
}
private static GatewayApiService createApiService() {
// OkHttpClient.Builder httpClient = new OkHttpClient.Builder();
// HttpLoggingInterceptor loggingInterceptor = new HttpLoggingInterceptor();
// loggingInterceptor.setLevel(HttpLoggingInterceptor.Level.BODY);
// httpClient.addInterceptor(loggingInterceptor);
Retrofit retrofit = new Retrofit.Builder()
.baseUrl(AppConstants.API_BASE_URL)
// .client(httpClient.build())
.addConverterFactory(GsonConverterFactory.create())
.build();
apiService = retrofit.create(GatewayApiService.class);
return retrofit.create(GatewayApiService.class);
}
}
@@ -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 }
}
}
@@ -0,0 +1,31 @@
package com.vernu.sms;
import android.Manifest;
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.RECEIVE_SMS,
Manifest.permission.READ_PHONE_STATE
};
public static final String SHARED_PREFS_DEVICE_ID_KEY = "DEVICE_ID";
public static final String SHARED_PREFS_API_KEY_KEY = "API_KEY";
public static final String SHARED_PREFS_GATEWAY_ENABLED_KEY = "GATEWAY_ENABLED";
public static final String SHARED_PREFS_PREFERRED_SIM_KEY = "PREFERRED_SIM";
public static final String SHARED_PREFS_RECEIVE_SMS_ENABLED_KEY = "RECEIVE_SMS_ENABLED";
public static final String SHARED_PREFS_TRACK_SENT_SMS_STATUS_KEY = "TRACK_SENT_SMS_STATUS";
public static final String SHARED_PREFS_LAST_VERSION_CODE_KEY = "LAST_VERSION_CODE";
public static final String SHARED_PREFS_LAST_VERSION_NAME_KEY = "LAST_VERSION_NAME";
public static final String SHARED_PREFS_STICKY_NOTIFICATION_ENABLED_KEY = "STICKY_NOTIFICATION_ENABLED";
public static final String HEARTBEAT_WORK_TAG = "heartbeat";
public static final String SHARED_PREFS_HEARTBEAT_ENABLED_KEY = "HEARTBEAT_ENABLED";
public static final String SHARED_PREFS_HEARTBEAT_INTERVAL_MINUTES_KEY = "HEARTBEAT_INTERVAL_MINUTES";
public static final String SHARED_PREFS_SMS_FILTER_CONFIG_KEY = "SMS_FILTER_CONFIG";
public static final String SHARED_PREFS_DEVICE_NAME_KEY = "DEVICE_NAME";
public static final String SHARED_PREFS_SMS_SEND_DELAY_SECONDS_KEY = "SMS_SEND_DELAY_SECONDS";
/** Default delay between SMS sends (seconds). 5s helps avoid carrier/device throttling. */
public static final int DEFAULT_SMS_SEND_DELAY_SECONDS = 5;
public static final String SHARED_PREFS_USE_NEW_UI_KEY = "USE_NEW_UI";
public static final String SHARED_PREFS_LAST_HEARTBEAT_MS_KEY = "LAST_HEARTBEAT_MS";
}
@@ -0,0 +1,34 @@
package com.vernu.sms;
import android.app.Application;
import android.util.Log;
import androidx.work.Configuration;
import androidx.work.WorkManager;
public class SMSGatewayApplication extends Application implements Configuration.Provider {
private static final String TAG = "SMSGatewayApplication";
@Override
public void onCreate() {
super.onCreate();
// Initialize WorkManager early to ensure it's ready for background work
// This is important for background tasks like heartbeat
try {
WorkManager.initialize(this, getWorkManagerConfiguration());
Log.d(TAG, "WorkManager initialized successfully");
} catch (IllegalStateException e) {
// WorkManager might already be initialized (e.g., by androidx.startup)
// This is fine, we can continue
Log.d(TAG, "WorkManager already initialized or will be initialized automatically");
}
}
@Override
public Configuration getWorkManagerConfiguration() {
return new Configuration.Builder()
.setMinimumLoggingLevel(android.util.Log.INFO)
.build();
}
}
@@ -0,0 +1,305 @@
package com.vernu.sms;
import android.Manifest;
import android.content.Context;
import android.content.Intent;
import android.content.pm.PackageManager;
import android.os.Build;
import android.telephony.SubscriptionInfo;
import android.telephony.SubscriptionManager;
import android.util.Log;
import androidx.core.app.ActivityCompat;
import androidx.core.content.ContextCompat;
import com.google.firebase.crashlytics.FirebaseCrashlytics;
import com.vernu.sms.services.StickyNotificationService;
import com.vernu.sms.helpers.SharedPreferenceHelper;
import com.vernu.sms.dtos.SimInfoDTO;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
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;
}
public static List<SubscriptionInfo> getAvailableSimSlots(Context context) {
if (ActivityCompat.checkSelfPermission(context, Manifest.permission.READ_PHONE_STATE) != PackageManager.PERMISSION_GRANTED) {
return new ArrayList<>();
}
SubscriptionManager subscriptionManager = SubscriptionManager.from(context);
return subscriptionManager.getActiveSubscriptionInfoList();
}
public static void startStickyNotificationService(Context context) {
if(!isPermissionGranted(context, Manifest.permission.RECEIVE_SMS)){
return;
}
// Only start service if user has enabled sticky notification
boolean stickyNotificationEnabled = SharedPreferenceHelper.getSharedPreferenceBoolean(
context,
AppConstants.SHARED_PREFS_STICKY_NOTIFICATION_ENABLED_KEY,
false
);
if (stickyNotificationEnabled) {
Intent notificationIntent = new Intent(context, StickyNotificationService.class);
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
context.startForegroundService(notificationIntent);
} else {
context.startService(notificationIntent);
}
Log.i(TAG, "Starting sticky notification service");
} else {
Log.i(TAG, "Sticky notification disabled by user, not starting service");
}
}
public static void stopStickyNotificationService(Context context) {
Intent notificationIntent = new Intent(context, StickyNotificationService.class);
context.stopService(notificationIntent);
Log.i(TAG, "Stopping sticky notification service");
}
/**
* Log a non-fatal exception to Crashlytics with additional context information
*
* @param throwable The exception to log
* @param message A message describing what happened
* @param customData Optional map of custom key-value pairs to add as context
*/
public static void logException(Throwable throwable, String message, Map<String, Object> customData) {
try {
Log.e(TAG, message, throwable);
FirebaseCrashlytics crashlytics = FirebaseCrashlytics.getInstance();
crashlytics.log(message);
// Add any custom data as key-value pairs
if (customData != null) {
for (Map.Entry<String, Object> entry : customData.entrySet()) {
if (entry.getValue() instanceof String) {
crashlytics.setCustomKey(entry.getKey(), (String) entry.getValue());
} else if (entry.getValue() instanceof Boolean) {
crashlytics.setCustomKey(entry.getKey(), (Boolean) entry.getValue());
} else if (entry.getValue() instanceof Integer) {
crashlytics.setCustomKey(entry.getKey(), (Integer) entry.getValue());
} else if (entry.getValue() instanceof Long) {
crashlytics.setCustomKey(entry.getKey(), (Long) entry.getValue());
} else if (entry.getValue() instanceof Float) {
crashlytics.setCustomKey(entry.getKey(), (Float) entry.getValue());
} else if (entry.getValue() instanceof Double) {
crashlytics.setCustomKey(entry.getKey(), (Double) entry.getValue());
} else if (entry.getValue() != null) {
crashlytics.setCustomKey(entry.getKey(), entry.getValue().toString());
}
}
}
// Record the exception
crashlytics.recordException(throwable);
} catch (Exception e) {
Log.e(TAG, "Error logging exception to Crashlytics", e);
}
}
/**
* Simplified method to log a non-fatal exception with just a message
*/
public static void logException(Throwable throwable, String message) {
logException(throwable, message, null);
}
/**
* Collects all available SIM information (physical SIMs and eSIMs) from the device
*
* @param context The application context
* @return List of SimInfoDTO objects containing SIM information, or empty list if permission not granted
*/
public static List<SimInfoDTO> collectSimInfo(Context context) {
List<SimInfoDTO> simInfoList = new ArrayList<>();
if (ActivityCompat.checkSelfPermission(context, Manifest.permission.READ_PHONE_STATE) != PackageManager.PERMISSION_GRANTED) {
Log.w(TAG, "READ_PHONE_STATE permission not granted, cannot collect SIM info");
return simInfoList;
}
try {
SubscriptionManager subscriptionManager = SubscriptionManager.from(context);
List<SubscriptionInfo> subscriptionInfoList = subscriptionManager.getActiveSubscriptionInfoList();
if (subscriptionInfoList == null) {
Log.d(TAG, "No active subscriptions found");
return simInfoList;
}
for (SubscriptionInfo subscriptionInfo : subscriptionInfoList) {
SimInfoDTO simInfo = new SimInfoDTO();
simInfo.setSubscriptionId(subscriptionInfo.getSubscriptionId());
// Get ICCID (may be null for eSIM)
try {
String iccId = subscriptionInfo.getIccId();
if (iccId != null && !iccId.isEmpty()) {
simInfo.setIccId(iccId);
}
} catch (Exception e) {
Log.d(TAG, "Could not get ICCID for subscription " + subscriptionInfo.getSubscriptionId());
}
// Get Card ID
try {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
int cardId = subscriptionInfo.getCardId();
// INVALID_CARD_ID is -1, check for valid card ID (>= 0)
if (cardId >= 0) {
simInfo.setCardId(cardId);
}
}
} catch (Exception e) {
Log.d(TAG, "Could not get Card ID for subscription " + subscriptionInfo.getSubscriptionId());
}
// Get carrier name
try {
CharSequence carrierName = subscriptionInfo.getCarrierName();
if (carrierName != null) {
simInfo.setCarrierName(carrierName.toString());
}
} catch (Exception e) {
Log.d(TAG, "Could not get carrier name for subscription " + subscriptionInfo.getSubscriptionId());
}
// Get display name
try {
CharSequence displayName = subscriptionInfo.getDisplayName();
if (displayName != null) {
simInfo.setDisplayName(displayName.toString());
}
} catch (Exception e) {
Log.d(TAG, "Could not get display name for subscription " + subscriptionInfo.getSubscriptionId());
}
// Get SIM slot index
try {
int simSlotIndex = subscriptionInfo.getSimSlotIndex();
if (simSlotIndex >= 0) {
simInfo.setSimSlotIndex(simSlotIndex);
}
} catch (Exception e) {
Log.d(TAG, "Could not get SIM slot index for subscription " + subscriptionInfo.getSubscriptionId());
}
// Get MCC (getMccString() is API 29+; use getMcc() on older devices)
try {
String mcc = null;
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
mcc = subscriptionInfo.getMccString();
} else {
int mccInt = subscriptionInfo.getMcc();
if (mccInt != Integer.MAX_VALUE) {
mcc = String.format("%03d", mccInt);
}
}
if (mcc != null && !mcc.isEmpty()) {
simInfo.setMcc(mcc);
}
} catch (Exception e) {
Log.d(TAG, "Could not get MCC for subscription " + subscriptionInfo.getSubscriptionId());
}
// Get MNC (getMncString() is API 29+; use getMnc() on older devices)
try {
String mnc = null;
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
mnc = subscriptionInfo.getMncString();
} else {
int mncInt = subscriptionInfo.getMnc();
if (mncInt != Integer.MAX_VALUE) {
mnc = String.valueOf(mncInt);
}
}
if (mnc != null && !mnc.isEmpty()) {
simInfo.setMnc(mnc);
}
} catch (Exception e) {
Log.d(TAG, "Could not get MNC for subscription " + subscriptionInfo.getSubscriptionId());
}
// Get country ISO
try {
String countryIso = subscriptionInfo.getCountryIso();
if (countryIso != null && !countryIso.isEmpty()) {
simInfo.setCountryIso(countryIso);
}
} catch (Exception e) {
Log.d(TAG, "Could not get country ISO for subscription " + subscriptionInfo.getSubscriptionId());
}
// Get subscription type (0 = physical SIM, 1 = eSIM)
try {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
int subscriptionType = subscriptionInfo.getSubscriptionType();
if (subscriptionType == SubscriptionManager.SUBSCRIPTION_TYPE_LOCAL_SIM) {
simInfo.setSubscriptionType("PHYSICAL_SIM");
} else if (subscriptionType == SubscriptionManager.SUBSCRIPTION_TYPE_REMOTE_SIM) {
simInfo.setSubscriptionType("ESIM");
}
} else {
// For older Android versions, default to PHYSICAL_SIM
simInfo.setSubscriptionType("PHYSICAL_SIM");
}
} catch (Exception e) {
Log.d(TAG, "Could not get subscription type for subscription " + subscriptionInfo.getSubscriptionId());
}
simInfoList.add(simInfo);
}
} catch (Exception e) {
Log.e(TAG, "Error collecting SIM info: " + e.getMessage(), e);
}
return simInfoList;
}
/**
* Validates if a subscription ID exists in the active subscriptions
*
* @param context The application context
* @param subscriptionId The subscription ID to validate
* @return true if the subscription ID exists, false otherwise
*/
public static boolean isValidSubscriptionId(Context context, int subscriptionId) {
if (ActivityCompat.checkSelfPermission(context, Manifest.permission.READ_PHONE_STATE) != PackageManager.PERMISSION_GRANTED) {
return false;
}
try {
SubscriptionManager subscriptionManager = SubscriptionManager.from(context);
List<SubscriptionInfo> subscriptionInfoList = subscriptionManager.getActiveSubscriptionInfoList();
if (subscriptionInfoList == null) {
return false;
}
for (SubscriptionInfo subscriptionInfo : subscriptionInfoList) {
if (subscriptionInfo.getSubscriptionId() == subscriptionId) {
return true;
}
}
} catch (Exception e) {
Log.e(TAG, "Error validating subscription ID: " + e.getMessage(), e);
}
return false;
}
}
@@ -3,18 +3,17 @@ package com.vernu.sms.activities;
import androidx.annotation.Nullable;
import androidx.appcompat.app.AppCompatActivity;
import androidx.core.app.ActivityCompat;
import androidx.core.content.ContextCompat;
import android.Manifest;
import android.content.ClipData;
import android.content.ClipboardManager;
import android.content.Context;
import android.content.Intent;
import android.content.pm.PackageManager;
import com.vernu.sms.activities.SMSFilterActivity;
import android.os.Build;
import android.os.Bundle;
import android.telephony.SubscriptionInfo;
import android.telephony.SubscriptionManager;
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;
@@ -25,97 +24,111 @@ import android.widget.RadioGroup;
import android.widget.Switch;
import android.widget.TextView;
import android.widget.Toast;
import com.google.android.material.snackbar.Snackbar;
import com.google.firebase.messaging.FirebaseMessaging;
import com.google.zxing.integration.android.IntentIntegrator;
import com.google.zxing.integration.android.IntentResult;
import com.vernu.sms.services.GatewayApiService;
import com.vernu.sms.ApiManager;
import com.vernu.sms.AppConstants;
import com.vernu.sms.BuildConfig;
import com.vernu.sms.TextBeeUtils;
import com.vernu.sms.R;
import com.vernu.sms.dtos.RegisterDeviceInputDTO;
import com.vernu.sms.dtos.RegisterDeviceResponseDTO;
import com.vernu.sms.dtos.SimInfoCollectionDTO;
import com.vernu.sms.helpers.SharedPreferenceHelper;
import java.util.ArrayList;
import java.util.List;
import com.vernu.sms.helpers.VersionTracker;
import com.vernu.sms.helpers.HeartbeatManager;
import com.google.firebase.crashlytics.FirebaseCrashlytics;
import com.google.gson.Gson;
import okhttp3.ResponseBody;
import java.io.IOException;
import java.util.Arrays;
import java.util.Objects;
import retrofit2.Call;
import retrofit2.Callback;
import retrofit2.Response;
import retrofit2.Retrofit;
import retrofit2.converter.gson.GsonConverterFactory;
public class MainActivity extends AppCompatActivity {
private Context mContext;
private Retrofit retrofit;
private GatewayApiService gatewayApiService;
private Switch gatewaySwitch;
private EditText apiKeyEditText, fcmTokenEditText;
private Button registerDeviceBtn, grantSMSPermissionBtn, scanQRBtn;
private Switch gatewaySwitch, receiveSMSSwitch, stickyNotificationSwitch;
private EditText apiKeyEditText, fcmTokenEditText, deviceIdEditText, deviceNameEditText, smsSendDelayEditText;
private Button registerDeviceBtn, grantSMSPermissionBtn, scanQRBtn, checkUpdatesBtn, configureFilterBtn, tryNewUIBtn;
private ImageButton copyDeviceIdImgBtn;
private TextView deviceBrandAndModelTxt, deviceIdTxt;
private TextView deviceBrandAndModelTxt, deviceIdTxt, appVersionNameTxt, appVersionCodeTxt;
private RadioGroup defaultSimSlotRadioGroup;
private static final int SEND_SMS_PERMISSION_REQUEST_CODE = 0;
private static final int SCAN_QR_REQUEST_CODE = 49374;
private static final String API_BASE_URL = "https://api.textbee.dev/api/v1/";
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";
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
mContext = getApplicationContext();
retrofit = new Retrofit.Builder()
.baseUrl(API_BASE_URL)
.addConverterFactory(GsonConverterFactory.create())
.build();
gatewayApiService = retrofit.create(GatewayApiService.class);
deviceId = SharedPreferenceHelper.getSharedPreferenceString(mContext, "DEVICE_ID", "");
deviceId = SharedPreferenceHelper.getSharedPreferenceString(mContext, AppConstants.SHARED_PREFS_DEVICE_ID_KEY, "");
setContentView(R.layout.activity_main);
gatewaySwitch = findViewById(R.id.gatewaySwitch);
receiveSMSSwitch = findViewById(R.id.receiveSMSSwitch);
stickyNotificationSwitch = findViewById(R.id.stickyNotificationSwitch);
apiKeyEditText = findViewById(R.id.apiKeyEditText);
fcmTokenEditText = findViewById(R.id.fcmTokenEditText);
deviceIdEditText = findViewById(R.id.deviceIdEditText);
deviceNameEditText = findViewById(R.id.deviceNameEditText);
registerDeviceBtn = findViewById(R.id.registerDeviceBtn);
grantSMSPermissionBtn = findViewById(R.id.grantSMSPermissionBtn);
scanQRBtn = findViewById(R.id.scanQRButton);
deviceBrandAndModelTxt = findViewById(R.id.deviceBrandAndModelTxt);
deviceIdTxt = findViewById(R.id.deviceIdTxt);
copyDeviceIdImgBtn = findViewById(R.id.copyDeviceIdImgBtn);
defaultSimSlotRadioGroup = findViewById(R.id.defaultSimSlotRadioGroup);
try {
getAvailableSimSlots().forEach(subscriptionInfo -> {
RadioButton radioButton = new RadioButton(mContext);
radioButton.setText(subscriptionInfo.getDisplayName().toString());
radioButton.setId(subscriptionInfo.getSubscriptionId());
radioButton.setOnClickListener(view -> {
SharedPreferenceHelper.setSharedPreferenceInt(mContext, "PREFERED_SIM", subscriptionInfo.getSubscriptionId());
});
radioButton.setChecked(subscriptionInfo.getSubscriptionId() == SharedPreferenceHelper.getSharedPreferenceInt(mContext, "PREFERED_SIM", 0));
defaultSimSlotRadioGroup.addView(radioButton);
});
} catch (Exception e) {
Snackbar.make(defaultSimSlotRadioGroup.getRootView(), "Error: " + e.getMessage(), Snackbar.LENGTH_LONG).show();
Log.e("SIM_SLOT_ERROR", e.getMessage());
}
appVersionNameTxt = findViewById(R.id.appVersionNameTxt);
appVersionCodeTxt = findViewById(R.id.appVersionCodeTxt);
checkUpdatesBtn = findViewById(R.id.checkUpdatesBtn);
configureFilterBtn = findViewById(R.id.configureFilterBtn);
smsSendDelayEditText = findViewById(R.id.smsSendDelayEditText);
tryNewUIBtn = findViewById(R.id.tryNewUIBtn);
deviceIdTxt.setText(deviceId);
deviceIdEditText.setText(deviceId);
deviceBrandAndModelTxt.setText(Build.BRAND + " " + Build.MODEL);
// Set app version information
String versionName = BuildConfig.VERSION_NAME;
appVersionNameTxt.setText(versionName);
appVersionCodeTxt.setText(String.valueOf(BuildConfig.VERSION_CODE));
// Check for app version changes and report if needed
if (VersionTracker.hasVersionChanged(mContext)) {
Log.d(TAG, "App version changed or first launch, reporting to server");
VersionTracker.reportVersionToServer(mContext);
}
// Initialize Crashlytics with user information
FirebaseCrashlytics crashlytics = FirebaseCrashlytics.getInstance();
crashlytics.setCustomKey("device_id", deviceId != null ? deviceId : "not_registered");
crashlytics.setCustomKey("device_model", Build.MODEL);
crashlytics.setCustomKey("app_version", versionName);
crashlytics.setCustomKey("app_version_code", BuildConfig.VERSION_CODE);
// Start sticky notification service if enabled
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);
Log.d(TAG, "Starting sticky notification service on app start");
}
// Schedule heartbeat if device is enabled and registered
if (gatewayEnabled && !deviceId.isEmpty()) {
HeartbeatManager.scheduleHeartbeat(mContext);
Log.d(TAG, "Scheduling heartbeat on app start");
}
if (deviceId == null || deviceId.isEmpty()) {
registerDeviceBtn.setText("Register");
@@ -123,14 +136,19 @@ public class MainActivity extends AppCompatActivity {
registerDeviceBtn.setText("Update");
}
if (isSMSPermissionGranted(mContext) && isReadPhoneStatePermissionGranted(mContext)) {
String[] missingPermissions = Arrays.stream(AppConstants.requiredPermissions).filter(permission -> !TextBeeUtils.isPermissionGranted(mContext, permission)).toArray(String[]::new);
if (missingPermissions.length == 0) {
grantSMSPermissionBtn.setEnabled(false);
grantSMSPermissionBtn.setText("SMS Permission Granted");
grantSMSPermissionBtn.setText("Permission Granted");
renderAvailableSimOptions();
} else {
Snackbar.make(grantSMSPermissionBtn, "Please Grant Required Permissions to continue: " + Arrays.toString(missingPermissions), Snackbar.LENGTH_SHORT).show();
grantSMSPermissionBtn.setEnabled(true);
grantSMSPermissionBtn.setOnClickListener(view -> handleSMSRequestPermission(view));
grantSMSPermissionBtn.setOnClickListener(this::handleRequestPermissions);
}
// TextBeeUtils.startStickyNotificationService(mContext);
copyDeviceIdImgBtn.setOnClickListener(view -> {
ClipboardManager clipboard = (ClipboardManager) getSystemService(Context.CLIPBOARD_SERVICE);
ClipData clip = ClipData.newPlainText("Device ID", deviceId);
@@ -138,75 +156,329 @@ public class MainActivity extends AppCompatActivity {
Snackbar.make(view, "Copied", Snackbar.LENGTH_LONG).show();
});
apiKeyEditText.setText(SharedPreferenceHelper.getSharedPreferenceString(mContext, "API_KEY", ""));
gatewaySwitch.setChecked(SharedPreferenceHelper.getSharedPreferenceBoolean(mContext, "GATEWAY_ENABLED", false));
apiKeyEditText.setText(SharedPreferenceHelper.getSharedPreferenceString(mContext, AppConstants.SHARED_PREFS_API_KEY_KEY, ""));
String storedDeviceName = SharedPreferenceHelper.getSharedPreferenceString(mContext, AppConstants.SHARED_PREFS_DEVICE_NAME_KEY, "");
if (storedDeviceName.isEmpty()) {
deviceNameEditText.setText(Build.BRAND + " " + Build.MODEL);
} else {
deviceNameEditText.setText(storedDeviceName);
}
gatewaySwitch.setChecked(SharedPreferenceHelper.getSharedPreferenceBoolean(mContext, AppConstants.SHARED_PREFS_GATEWAY_ENABLED_KEY, false));
gatewaySwitch.setOnCheckedChangeListener((compoundButton, isCheked) -> {
View view = compoundButton.getRootView();
compoundButton.setEnabled(false);
String key = apiKeyEditText.getText().toString();
RegisterDeviceInputDTO registerDeviceInput = new RegisterDeviceInputDTO();
registerDeviceInput.setEnabled(isCheked);
registerDeviceInput.setAppVersionCode(BuildConfig.VERSION_CODE);
registerDeviceInput.setAppVersionName(BuildConfig.VERSION_NAME);
Call<RegisterDeviceResponseDTO> apiCall = gatewayApiService.updateDevice(deviceId, key, registerDeviceInput);
Call<RegisterDeviceResponseDTO> apiCall = ApiManager.getApiService().updateDevice(deviceId, key, registerDeviceInput);
apiCall.enqueue(new Callback<RegisterDeviceResponseDTO>() {
@Override
public void onResponse(Call<RegisterDeviceResponseDTO> call, Response<RegisterDeviceResponseDTO> response) {
if (response.isSuccessful()) {
Snackbar.make(view, "Gateway " + (isCheked ? "enabled" : "disabled"), Snackbar.LENGTH_LONG).show();
SharedPreferenceHelper.setSharedPreferenceBoolean(mContext, "GATEWAY_ENABLED", isCheked);
compoundButton.setChecked(Boolean.TRUE.equals(response.body().data.get("enabled")));
Log.d(TAG, response.toString());
if (!response.isSuccessful()) {
Snackbar.make(view, extractErrorMessage(response), Snackbar.LENGTH_LONG).show();
compoundButton.setEnabled(true);
return;
}
Snackbar.make(view, "Gateway " + (isCheked ? "enabled" : "disabled"), Snackbar.LENGTH_LONG).show();
SharedPreferenceHelper.setSharedPreferenceBoolean(mContext, AppConstants.SHARED_PREFS_GATEWAY_ENABLED_KEY, isCheked);
boolean enabled = Boolean.TRUE.equals(Objects.requireNonNull(response.body()).data.get("enabled"));
compoundButton.setChecked(enabled);
if (enabled) {
// Check if sticky notification is enabled
if (SharedPreferenceHelper.getSharedPreferenceBoolean(mContext, AppConstants.SHARED_PREFS_STICKY_NOTIFICATION_ENABLED_KEY, false)) {
TextBeeUtils.startStickyNotificationService(mContext);
}
// Schedule heartbeat
HeartbeatManager.scheduleHeartbeat(mContext);
} else {
Snackbar.make(view, response.message(), Snackbar.LENGTH_LONG).show();
TextBeeUtils.stopStickyNotificationService(mContext);
// Cancel heartbeat
HeartbeatManager.cancelHeartbeat(mContext);
}
compoundButton.setEnabled(true);
}
@Override
public void onFailure(Call<RegisterDeviceResponseDTO> call, Throwable t) {
Snackbar.make(view, "An error occured :(", Snackbar.LENGTH_LONG).show();
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");
compoundButton.setEnabled(true);
}
});
});
registerDeviceBtn.setOnClickListener(view -> handleRegisterDevice());
receiveSMSSwitch.setChecked(SharedPreferenceHelper.getSharedPreferenceBoolean(mContext, AppConstants.SHARED_PREFS_RECEIVE_SMS_ENABLED_KEY, false));
receiveSMSSwitch.setOnCheckedChangeListener((compoundButton, isCheked) -> {
View view = compoundButton.getRootView();
SharedPreferenceHelper.setSharedPreferenceBoolean(mContext, AppConstants.SHARED_PREFS_RECEIVE_SMS_ENABLED_KEY, isCheked);
compoundButton.setChecked(isCheked);
Snackbar.make(view, "Receive SMS " + (isCheked ? "enabled" : "disabled"), Snackbar.LENGTH_LONG).show();
});
// Setup sticky notification switch
stickyNotificationSwitch.setChecked(SharedPreferenceHelper.getSharedPreferenceBoolean(mContext, AppConstants.SHARED_PREFS_STICKY_NOTIFICATION_ENABLED_KEY, false));
stickyNotificationSwitch.setOnCheckedChangeListener((compoundButton, isChecked) -> {
View view = compoundButton.getRootView();
SharedPreferenceHelper.setSharedPreferenceBoolean(mContext, AppConstants.SHARED_PREFS_STICKY_NOTIFICATION_ENABLED_KEY, isChecked);
if (isChecked) {
TextBeeUtils.startStickyNotificationService(mContext);
Snackbar.make(view, "Background service enabled - app will be more reliable", Snackbar.LENGTH_LONG).show();
} else {
TextBeeUtils.stopStickyNotificationService(mContext);
Snackbar.make(view, "Background service disabled - app may be killed when in background", Snackbar.LENGTH_LONG).show();
}
});
// TODO: check gateway status/api key/device validity and update UI accordingly
registerDeviceBtn.setOnClickListener(view -> {
String _deviceId = SharedPreferenceHelper.getSharedPreferenceString(mContext, AppConstants.SHARED_PREFS_DEVICE_ID_KEY, "");
if (_deviceId == null || _deviceId.isEmpty()) {
handleRegisterDevice();
} else {
handleUpdateDevice();
}
});
scanQRBtn.setOnClickListener(view -> {
IntentIntegrator intentIntegrator = new IntentIntegrator(MainActivity.this);
intentIntegrator.setPrompt("Go to textbee.dev/dashboard and click Register Device to generate QR Code");
intentIntegrator.setRequestCode(SCAN_QR_REQUEST_CODE);
intentIntegrator.initiateScan();
});
checkUpdatesBtn.setOnClickListener(view -> {
String versionInfo = BuildConfig.VERSION_NAME + "(" + BuildConfig.VERSION_CODE + ")";
String encodedVersionInfo = android.net.Uri.encode(versionInfo);
String downloadUrl = "https://textbee.dev/download?currentVersion=" + encodedVersionInfo;
Intent browserIntent = new Intent(Intent.ACTION_VIEW, android.net.Uri.parse(downloadUrl));
startActivity(browserIntent);
});
configureFilterBtn.setOnClickListener(view -> {
Intent filterIntent = new Intent(MainActivity.this, SMSFilterActivity.class);
startActivity(filterIntent);
});
tryNewUIBtn.setOnClickListener(view -> {
SharedPreferenceHelper.setSharedPreferenceBoolean(mContext, AppConstants.SHARED_PREFS_USE_NEW_UI_KEY, true);
Intent intent = new Intent(MainActivity.this, com.vernu.sms.ui.splash.SplashActivity.class);
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TASK);
startActivity(intent);
});
// SMS Send Delay setting: save 3 seconds after user stops typing
int currentDelay = SharedPreferenceHelper.getSharedPreferenceInt(
mContext, AppConstants.SHARED_PREFS_SMS_SEND_DELAY_SECONDS_KEY, AppConstants.DEFAULT_SMS_SEND_DELAY_SECONDS);
smsSendDelayEditText.setText(String.valueOf(currentDelay));
smsDelaySaveRunnable = this::saveSendDelay;
smsSendDelayEditText.addTextChangedListener(new TextWatcher() {
@Override
public void beforeTextChanged(CharSequence s, int start, int count, int after) {}
@Override
public void onTextChanged(CharSequence s, int start, int before, int count) {}
@Override
public void afterTextChanged(Editable s) {
smsDelaySaveHandler.removeCallbacks(smsDelaySaveRunnable);
smsDelaySaveHandler.postDelayed(smsDelaySaveRunnable, SMS_DELAY_SAVE_DEBOUNCE_MS);
}
});
smsSendDelayEditText.setOnEditorActionListener((v, actionId, event) -> {
smsDelaySaveHandler.removeCallbacks(smsDelaySaveRunnable);
saveSendDelay();
return false;
});
}
private void saveSendDelay() {
String text = smsSendDelayEditText.getText().toString().trim();
if (text.isEmpty()) {
int defaultDelay = AppConstants.DEFAULT_SMS_SEND_DELAY_SECONDS;
smsSendDelayEditText.setText(String.valueOf(defaultDelay));
SharedPreferenceHelper.setSharedPreferenceInt(mContext, AppConstants.SHARED_PREFS_SMS_SEND_DELAY_SECONDS_KEY, defaultDelay);
Snackbar.make(smsSendDelayEditText, "SMS send delay saved (" + defaultDelay + " sec)", Snackbar.LENGTH_SHORT).show();
return;
}
try {
int value = Integer.parseInt(text);
if (value < 0) {
value = 0;
smsSendDelayEditText.setText("0");
SharedPreferenceHelper.setSharedPreferenceInt(mContext, AppConstants.SHARED_PREFS_SMS_SEND_DELAY_SECONDS_KEY, 0);
Snackbar.make(smsSendDelayEditText, "Minimum delay is 0 seconds. Saved.", Snackbar.LENGTH_SHORT).show();
} else if (value > 3600) {
value = 3600;
smsSendDelayEditText.setText("3600");
SharedPreferenceHelper.setSharedPreferenceInt(mContext, AppConstants.SHARED_PREFS_SMS_SEND_DELAY_SECONDS_KEY, 3600);
Snackbar.make(smsSendDelayEditText, "Maximum delay is 3600 seconds. Saved.", Snackbar.LENGTH_SHORT).show();
} else {
SharedPreferenceHelper.setSharedPreferenceInt(mContext, AppConstants.SHARED_PREFS_SMS_SEND_DELAY_SECONDS_KEY, value);
Snackbar.make(smsSendDelayEditText, "SMS send delay saved (" + value + " sec)", Snackbar.LENGTH_SHORT).show();
}
} catch (NumberFormatException e) {
int defaultDelay = AppConstants.DEFAULT_SMS_SEND_DELAY_SECONDS;
smsSendDelayEditText.setText(String.valueOf(defaultDelay));
SharedPreferenceHelper.setSharedPreferenceInt(mContext, AppConstants.SHARED_PREFS_SMS_SEND_DELAY_SECONDS_KEY, defaultDelay);
Snackbar.make(smsSendDelayEditText, "Invalid value. Reset to " + defaultDelay + " sec.", Snackbar.LENGTH_SHORT).show();
}
}
private void renderAvailableSimOptions() {
try {
defaultSimSlotRadioGroup.removeAllViews();
// Set radio group styling for dark mode compatibility
defaultSimSlotRadioGroup.setBackgroundColor(getResources().getColor(R.color.background_secondary));
defaultSimSlotRadioGroup.setPadding(16, 8, 16, 8);
// Create the default radio button with proper styling
RadioButton defaultSimSlotRadioBtn = new RadioButton(mContext);
defaultSimSlotRadioBtn.setText("Device Default");
defaultSimSlotRadioBtn.setId((int)123456);
applyRadioButtonStyle(defaultSimSlotRadioBtn);
defaultSimSlotRadioGroup.addView(defaultSimSlotRadioBtn);
// Create radio buttons for each SIM with proper styling
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);
radioButton.setText(simInfo);
radioButton.setId(subscriptionInfo.getSubscriptionId());
applyRadioButtonStyle(radioButton);
defaultSimSlotRadioGroup.addView(radioButton);
});
// Check the preferred SIM based on saved preferences
int preferredSim = SharedPreferenceHelper.getSharedPreferenceInt(mContext, AppConstants.SHARED_PREFS_PREFERRED_SIM_KEY, -1);
if (preferredSim == -1) {
defaultSimSlotRadioGroup.check(defaultSimSlotRadioBtn.getId());
} else {
defaultSimSlotRadioGroup.check(preferredSim);
}
// Set the listener for SIM selection changes
defaultSimSlotRadioGroup.setOnCheckedChangeListener((radioGroup, i) -> {
RadioButton radioButton = findViewById(i);
if (radioButton == null) {
return;
}
radioButton.setChecked(true);
if("Device Default".equals(radioButton.getText().toString())) {
SharedPreferenceHelper.clearSharedPreference(mContext, AppConstants.SHARED_PREFS_PREFERRED_SIM_KEY);
} else {
SharedPreferenceHelper.setSharedPreferenceInt(mContext, AppConstants.SHARED_PREFS_PREFERRED_SIM_KEY, radioButton.getId());
}
});
} catch (Exception e) {
Snackbar.make(defaultSimSlotRadioGroup.getRootView(), "Error: " + e.getMessage(), Snackbar.LENGTH_LONG).show();
Log.e(TAG, "SIM_SLOT_ERROR "+ e.getMessage());
}
}
/**
* Extracts error message from API response, trying multiple sources:
* 1. Error message from response body (error field)
* 2. Response message from HTTP headers
* 3. Generic error with status code as fallback
*/
private String extractErrorMessage(Response<?> response) {
// Try to parse error from response body
try {
ResponseBody errorBody = response.errorBody();
if (errorBody != null) {
String errorBodyString = errorBody.string();
if (errorBodyString != null && !errorBodyString.isEmpty()) {
try {
Gson gson = new Gson();
RegisterDeviceResponseDTO errorResponse = gson.fromJson(errorBodyString, RegisterDeviceResponseDTO.class);
if (errorResponse != null && errorResponse.error != null && !errorResponse.error.isEmpty()) {
return errorResponse.error;
}
} catch (Exception e) {
// If JSON parsing fails, try to extract message from raw string
Log.d(TAG, "Could not parse error response as JSON: " + errorBodyString);
}
}
}
} catch (IOException e) {
Log.d(TAG, "Could not read error body: " + e.getMessage());
}
// Fall back to response message
if (response.message() != null && !response.message().isEmpty()) {
return response.message();
}
// Final fallback to generic error with status code
return "An error occurred :( " + response.code();
}
/**
* Apply the custom radio button style to a programmatically created radio button
*/
private void applyRadioButtonStyle(RadioButton radioButton) {
// Set text color using the color state list for proper dark/light mode handling
setRadioButtonTextColor(radioButton);
// Set button tint for the radio circle
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
radioButton.setButtonTintList(getResources().getColorStateList(R.color.radio_button_tint, getTheme()));
} else {
radioButton.setButtonTintList(getResources().getColorStateList(R.color.radio_button_tint));
}
}
// Add proper padding for better touch experience
radioButton.setPadding(
radioButton.getPaddingLeft() + 8,
radioButton.getPaddingTop() + 12,
radioButton.getPaddingRight(),
radioButton.getPaddingBottom() + 12
);
}
/**
* Helper method to set radio button text color in a backward-compatible way
*/
private void setRadioButtonTextColor(RadioButton radioButton) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
radioButton.setTextColor(getResources().getColorStateList(R.color.radio_button_text_color, getTheme()));
} else {
radioButton.setTextColor(getResources().getColorStateList(R.color.radio_button_text_color));
}
}
@Override
public void onRequestPermissionsResult(int requestCode, String permissions[], int[] grantResults) {
super.onRequestPermissionsResult(requestCode, permissions, grantResults);
switch (requestCode) {
case SEND_SMS_PERMISSION_REQUEST_CODE: {
if (grantResults.length > 0 && grantResults[0] == PackageManager.PERMISSION_GRANTED) {
Toast.makeText(mContext, "Yay! Permission Granted.", Toast.LENGTH_LONG).show();
} else {
Toast.makeText(mContext, "Permission Denied :(", Toast.LENGTH_LONG).show();
return;
}
}
}
if (requestCode != PERMISSION_REQUEST_CODE) {
return;
}
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);
grantSMSPermissionBtn.setText("Permission Granted");
renderAvailableSimOptions();
} else {
Snackbar.make(findViewById(R.id.grantSMSPermissionBtn), "Please Grant Required Permissions to continue", Snackbar.LENGTH_SHORT).show();
}
}
private void handleRegisterDevice() {
String newKey = apiKeyEditText.getText().toString();
String deviceIdInput = deviceIdEditText.getText().toString();
registerDeviceBtn.setEnabled(false);
registerDeviceBtn.setText("Loading...");
View view = findViewById(R.id.registerDeviceBtn);
@@ -230,86 +502,294 @@ public class MainActivity extends AppCompatActivity {
registerDeviceInput.setModel(Build.MODEL);
registerDeviceInput.setBuildId(Build.ID);
registerDeviceInput.setOs(Build.VERSION.BASE_OS);
registerDeviceInput.setAppVersionCode(BuildConfig.VERSION_CODE);
registerDeviceInput.setAppVersionName(BuildConfig.VERSION_NAME);
// Get device name from input field or default to "brand model"
String deviceName = deviceNameEditText.getText().toString().trim();
if (deviceName.isEmpty()) {
deviceName = Build.BRAND + " " + Build.MODEL;
}
registerDeviceInput.setName(deviceName);
// Collect SIM information
SimInfoCollectionDTO simInfoCollection = new SimInfoCollectionDTO();
simInfoCollection.setLastUpdated(System.currentTimeMillis());
simInfoCollection.setSims(TextBeeUtils.collectSimInfo(mContext));
registerDeviceInput.setSimInfo(simInfoCollection);
// If the user provided a device ID, use it for updating instead of creating new
if (!deviceIdInput.isEmpty()) {
Log.d(TAG, "Updating device with deviceId: "+ deviceIdInput);
Call<RegisterDeviceResponseDTO> apiCall = ApiManager.getApiService().updateDevice(deviceIdInput, newKey, registerDeviceInput);
apiCall.enqueue(new Callback<RegisterDeviceResponseDTO>() {
@Override
public void onResponse(Call<RegisterDeviceResponseDTO> call, Response<RegisterDeviceResponseDTO> response) {
Log.d(TAG, response.toString());
if (!response.isSuccessful()) {
Snackbar.make(view, extractErrorMessage(response), Snackbar.LENGTH_LONG).show();
registerDeviceBtn.setEnabled(true);
registerDeviceBtn.setText("Update");
return;
}
SharedPreferenceHelper.setSharedPreferenceString(mContext, AppConstants.SHARED_PREFS_API_KEY_KEY, newKey);
Snackbar.make(view, "Device Updated Successfully :)", Snackbar.LENGTH_LONG).show();
// Update deviceId from response if available
if (response.body() != null && response.body().data != null && response.body().data.get("_id") != null) {
deviceId = response.body().data.get("_id").toString();
deviceIdTxt.setText(deviceId);
deviceIdEditText.setText(deviceId);
SharedPreferenceHelper.setSharedPreferenceString(mContext, AppConstants.SHARED_PREFS_DEVICE_ID_KEY, deviceId);
SharedPreferenceHelper.setSharedPreferenceBoolean(mContext, AppConstants.SHARED_PREFS_GATEWAY_ENABLED_KEY, registerDeviceInput.isEnabled());
gatewaySwitch.setChecked(registerDeviceInput.isEnabled());
// Sync heartbeatIntervalMinutes from server response
if (response.body().data.get("heartbeatIntervalMinutes") != null) {
Object intervalObj = response.body().data.get("heartbeatIntervalMinutes");
if (intervalObj instanceof Number) {
int intervalMinutes = ((Number) intervalObj).intValue();
SharedPreferenceHelper.setSharedPreferenceInt(mContext, AppConstants.SHARED_PREFS_HEARTBEAT_INTERVAL_MINUTES_KEY, intervalMinutes);
Log.d(TAG, "Synced heartbeat interval from server: " + intervalMinutes + " minutes");
}
}
// Sync device name from server response
if (response.body().data.get("name") != null) {
String deviceName = response.body().data.get("name").toString();
SharedPreferenceHelper.setSharedPreferenceString(mContext, AppConstants.SHARED_PREFS_DEVICE_NAME_KEY, deviceName);
deviceNameEditText.setText(deviceName);
Log.d(TAG, "Synced device name from server: " + deviceName);
}
// Schedule heartbeat if device is enabled
if (registerDeviceInput.isEnabled()) {
HeartbeatManager.scheduleHeartbeat(mContext);
}
}
// Update stored version information
VersionTracker.updateStoredVersion(mContext);
registerDeviceBtn.setEnabled(true);
registerDeviceBtn.setText("Update");
}
@Override
public void onFailure(Call<RegisterDeviceResponseDTO> call, Throwable t) {
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");
registerDeviceBtn.setEnabled(true);
registerDeviceBtn.setText("Update");
}
});
return;
}
Call<RegisterDeviceResponseDTO> apiCall = ApiManager.getApiService().registerDevice(newKey, registerDeviceInput);
apiCall.enqueue(new Callback<RegisterDeviceResponseDTO>() {
@Override
public void onResponse(Call<RegisterDeviceResponseDTO> call, Response<RegisterDeviceResponseDTO> response) {
Log.d(TAG, response.toString());
if (!response.isSuccessful()) {
Snackbar.make(view, extractErrorMessage(response), Snackbar.LENGTH_LONG).show();
registerDeviceBtn.setEnabled(true);
registerDeviceBtn.setText("Update");
return;
}
SharedPreferenceHelper.setSharedPreferenceString(mContext, AppConstants.SHARED_PREFS_API_KEY_KEY, newKey);
Snackbar.make(view, "Device Registration Successful :)", Snackbar.LENGTH_LONG).show();
if (response.body() != null && response.body().data != null && response.body().data.get("_id") != null) {
deviceId = response.body().data.get("_id").toString();
deviceIdTxt.setText(deviceId);
deviceIdEditText.setText(deviceId);
SharedPreferenceHelper.setSharedPreferenceString(mContext, AppConstants.SHARED_PREFS_DEVICE_ID_KEY, deviceId);
SharedPreferenceHelper.setSharedPreferenceBoolean(mContext, AppConstants.SHARED_PREFS_GATEWAY_ENABLED_KEY, registerDeviceInput.isEnabled());
gatewaySwitch.setChecked(registerDeviceInput.isEnabled());
// Sync heartbeatIntervalMinutes from server response
if (response.body().data.get("heartbeatIntervalMinutes") != null) {
Object intervalObj = response.body().data.get("heartbeatIntervalMinutes");
if (intervalObj instanceof Number) {
int intervalMinutes = ((Number) intervalObj).intValue();
SharedPreferenceHelper.setSharedPreferenceInt(mContext, AppConstants.SHARED_PREFS_HEARTBEAT_INTERVAL_MINUTES_KEY, intervalMinutes);
Log.d(TAG, "Synced heartbeat interval from server: " + intervalMinutes + " minutes");
}
}
// Sync device name from server response
if (response.body().data.get("name") != null) {
String deviceName = response.body().data.get("name").toString();
SharedPreferenceHelper.setSharedPreferenceString(mContext, AppConstants.SHARED_PREFS_DEVICE_NAME_KEY, deviceName);
deviceNameEditText.setText(deviceName);
Log.d(TAG, "Synced device name from server: " + deviceName);
}
// Schedule heartbeat if device is enabled
if (registerDeviceInput.isEnabled()) {
HeartbeatManager.scheduleHeartbeat(mContext);
}
}
// Update stored version information
VersionTracker.updateStoredVersion(mContext);
registerDeviceBtn.setEnabled(true);
registerDeviceBtn.setText("Update");
}
@Override
public void onFailure(Call<RegisterDeviceResponseDTO> call, Throwable t) {
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");
registerDeviceBtn.setEnabled(true);
registerDeviceBtn.setText("Update");
}
});
});
}
Call<RegisterDeviceResponseDTO> apiCall = gatewayApiService.registerDevice(newKey, registerDeviceInput);
private void handleUpdateDevice() {
String apiKey = apiKeyEditText.getText().toString();
String deviceIdInput = deviceIdEditText.getText().toString();
String deviceIdToUse = !deviceIdInput.isEmpty() ? deviceIdInput : deviceId;
registerDeviceBtn.setEnabled(false);
registerDeviceBtn.setText("Loading...");
View view = findViewById(R.id.registerDeviceBtn);
FirebaseMessaging.getInstance().getToken()
.addOnCompleteListener(task -> {
if (!task.isSuccessful()) {
Snackbar.make(view, "Failed to obtain FCM Token :(", Snackbar.LENGTH_LONG).show();
registerDeviceBtn.setEnabled(true);
registerDeviceBtn.setText("Update");
return;
}
String token = task.getResult();
fcmTokenEditText.setText(token);
RegisterDeviceInputDTO updateDeviceInput = new RegisterDeviceInputDTO();
updateDeviceInput.setEnabled(true);
updateDeviceInput.setFcmToken(token);
updateDeviceInput.setBrand(Build.BRAND);
updateDeviceInput.setManufacturer(Build.MANUFACTURER);
updateDeviceInput.setModel(Build.MODEL);
updateDeviceInput.setBuildId(Build.ID);
updateDeviceInput.setOs(Build.VERSION.BASE_OS);
updateDeviceInput.setAppVersionCode(BuildConfig.VERSION_CODE);
updateDeviceInput.setAppVersionName(BuildConfig.VERSION_NAME);
// Get device name from input field or default to "brand model"
String deviceName = deviceNameEditText.getText().toString().trim();
if (deviceName.isEmpty()) {
deviceName = Build.BRAND + " " + Build.MODEL;
}
updateDeviceInput.setName(deviceName);
// Collect SIM information
SimInfoCollectionDTO simInfoCollection = new SimInfoCollectionDTO();
simInfoCollection.setLastUpdated(System.currentTimeMillis());
simInfoCollection.setSims(TextBeeUtils.collectSimInfo(mContext));
updateDeviceInput.setSimInfo(simInfoCollection);
Call<RegisterDeviceResponseDTO> apiCall = ApiManager.getApiService().updateDevice(deviceIdToUse, apiKey, updateDeviceInput);
apiCall.enqueue(new Callback<RegisterDeviceResponseDTO>() {
@Override
public void onResponse(Call<RegisterDeviceResponseDTO> call, Response<RegisterDeviceResponseDTO> response) {
if (response.isSuccessful()) {
SharedPreferenceHelper.setSharedPreferenceString(mContext, "API_KEY", newKey);
Log.e("API_RESP", response.toString());
Snackbar.make(view, "Device Registration Successful :)", Snackbar.LENGTH_LONG).show();
deviceId = response.body().data.get("_id").toString();
deviceIdTxt.setText(deviceId);
SharedPreferenceHelper.setSharedPreferenceString(mContext, "DEVICE_ID", deviceId);
} else {
Snackbar.make(view, response.message(), Snackbar.LENGTH_LONG).show();
Log.d(TAG, response.toString());
if (!response.isSuccessful()) {
Snackbar.make(view, extractErrorMessage(response), Snackbar.LENGTH_LONG).show();
registerDeviceBtn.setEnabled(true);
registerDeviceBtn.setText("Update");
return;
}
SharedPreferenceHelper.setSharedPreferenceString(mContext, AppConstants.SHARED_PREFS_API_KEY_KEY, apiKey);
// Update deviceId from response if available
if (response.body() != null && response.body().data != null && response.body().data.get("_id") != null) {
deviceId = response.body().data.get("_id").toString();
SharedPreferenceHelper.setSharedPreferenceString(mContext, AppConstants.SHARED_PREFS_DEVICE_ID_KEY, deviceId);
deviceIdTxt.setText(deviceId);
deviceIdEditText.setText(deviceId);
// Sync heartbeatIntervalMinutes from server response
if (response.body().data.get("heartbeatIntervalMinutes") != null) {
Object intervalObj = response.body().data.get("heartbeatIntervalMinutes");
if (intervalObj instanceof Number) {
int intervalMinutes = ((Number) intervalObj).intValue();
SharedPreferenceHelper.setSharedPreferenceInt(mContext, AppConstants.SHARED_PREFS_HEARTBEAT_INTERVAL_MINUTES_KEY, intervalMinutes);
Log.d(TAG, "Synced heartbeat interval from server: " + intervalMinutes + " minutes");
}
}
// Sync device name from server response
if (response.body().data.get("name") != null) {
String deviceName = response.body().data.get("name").toString();
SharedPreferenceHelper.setSharedPreferenceString(mContext, AppConstants.SHARED_PREFS_DEVICE_NAME_KEY, deviceName);
deviceNameEditText.setText(deviceName);
Log.d(TAG, "Synced device name from server: " + deviceName);
}
// Schedule heartbeat if device is enabled
if (updateDeviceInput.isEnabled()) {
HeartbeatManager.scheduleHeartbeat(mContext);
}
}
// Update stored version information
VersionTracker.updateStoredVersion(mContext);
Snackbar.make(view, "Device Updated Successfully :)", Snackbar.LENGTH_LONG).show();
registerDeviceBtn.setEnabled(true);
registerDeviceBtn.setText("Update");
}
@Override
public void onFailure(Call<RegisterDeviceResponseDTO> call, Throwable t) {
Snackbar.make(view, "An error occured :(", Snackbar.LENGTH_LONG).show();
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");
registerDeviceBtn.setEnabled(true);
registerDeviceBtn.setText("Update");
}
});
});
}
private void handleSMSRequestPermission(View view) {
if (isSMSPermissionGranted(mContext) && isReadPhoneStatePermissionGranted(mContext)) {
private void handleRequestPermissions(View view) {
boolean allPermissionsGranted = Arrays.stream(AppConstants.requiredPermissions).allMatch(permission -> TextBeeUtils.isPermissionGranted(mContext, permission));
if (allPermissionsGranted) {
Snackbar.make(view, "Already got permissions", Snackbar.LENGTH_SHORT).show();
} else {
Snackbar.make(view, "Grant SMS Permissions to continue", Snackbar.LENGTH_SHORT).show();
ActivityCompat.requestPermissions(MainActivity.this,
new String[]{Manifest.permission.SEND_SMS, Manifest.permission.READ_PHONE_STATE
}, SEND_SMS_PERMISSION_REQUEST_CODE);
return;
}
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);
}
@Override
protected void onActivityResult(int requestCode, int resultCode, @Nullable Intent data) {
super.onActivityResult(requestCode, resultCode, data);
if (requestCode == SCAN_QR_REQUEST_CODE) {
IntentResult intentResult = IntentIntegrator.parseActivityResult(requestCode, resultCode, data);
if (intentResult != null) {
if (intentResult.getContents() == null) {
Toast.makeText(getBaseContext(), "Canceled", Toast.LENGTH_SHORT).show();
} else {
String scannedQR = intentResult.getContents();
apiKeyEditText.setText(scannedQR);
handleRegisterDevice();
}
if (intentResult == null || intentResult.getContents() == null) {
Toast.makeText(getBaseContext(), "Canceled", Toast.LENGTH_SHORT).show();
return;
}
String scannedQR = intentResult.getContents();
apiKeyEditText.setText(scannedQR);
if(deviceIdEditText.getText().toString().isEmpty()) {
handleRegisterDevice();
} else {
handleUpdateDevice();
}
}
}
private boolean isSMSPermissionGranted(Context context) {
return ContextCompat.checkSelfPermission(context, Manifest.permission.SEND_SMS) == PackageManager.PERMISSION_GRANTED;
}
private boolean isReadPhoneStatePermissionGranted(Context context) {
return ContextCompat.checkSelfPermission(context, Manifest.permission.READ_PHONE_STATE) == PackageManager.PERMISSION_GRANTED;
}
private List<SubscriptionInfo> getAvailableSimSlots() {
if (ActivityCompat.checkSelfPermission(this, Manifest.permission.READ_PHONE_STATE) != PackageManager.PERMISSION_GRANTED) {
return new ArrayList<>();
}
SubscriptionManager subscriptionManager = SubscriptionManager.from(mContext);
return subscriptionManager.getActiveSubscriptionInfoList();
}
}
}
@@ -0,0 +1,267 @@
package com.vernu.sms.activities;
import android.app.AlertDialog;
import android.content.Context;
import android.os.Bundle;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.ArrayAdapter;
import android.widget.Button;
import android.widget.ImageButton;
import android.widget.RadioButton;
import android.widget.RadioGroup;
import android.widget.Spinner;
import android.widget.Switch;
import android.widget.TextView;
import android.widget.Toast;
import androidx.annotation.NonNull;
import androidx.appcompat.app.AppCompatActivity;
import androidx.recyclerview.widget.LinearLayoutManager;
import androidx.recyclerview.widget.RecyclerView;
import com.google.android.material.floatingactionbutton.FloatingActionButton;
import com.google.android.material.textfield.TextInputEditText;
import com.vernu.sms.R;
import com.vernu.sms.helpers.SMSFilterHelper;
import com.vernu.sms.models.SMSFilterRule;
import java.util.ArrayList;
import java.util.List;
public class SMSFilterActivity extends AppCompatActivity {
private Context mContext;
private Switch filterEnabledSwitch;
private RadioGroup filterModeRadioGroup;
private RadioButton allowListRadio;
private RadioButton blockListRadio;
private RecyclerView filterRulesRecyclerView;
private FloatingActionButton addRuleFab;
private FilterRulesAdapter adapter;
private SMSFilterHelper.FilterConfig filterConfig;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_sms_filter);
mContext = getApplicationContext();
// Initialize views
ImageButton backButton = findViewById(R.id.backButton);
filterEnabledSwitch = findViewById(R.id.filterEnabledSwitch);
filterModeRadioGroup = findViewById(R.id.filterModeRadioGroup);
allowListRadio = findViewById(R.id.allowListRadio);
blockListRadio = findViewById(R.id.blockListRadio);
filterRulesRecyclerView = findViewById(R.id.filterRulesRecyclerView);
addRuleFab = findViewById(R.id.addRuleFab);
// Setup back button
backButton.setOnClickListener(v -> finish());
// Load filter config
filterConfig = SMSFilterHelper.loadFilterConfig(mContext);
// Setup RecyclerView
adapter = new FilterRulesAdapter(filterConfig.getRules());
filterRulesRecyclerView.setLayoutManager(new LinearLayoutManager(this));
filterRulesRecyclerView.setAdapter(adapter);
// Load current settings
filterEnabledSwitch.setChecked(filterConfig.isEnabled());
// Default to block list if mode is not set
if (filterConfig.getMode() == SMSFilterHelper.FilterMode.ALLOW_LIST) {
allowListRadio.setChecked(true);
} else {
blockListRadio.setChecked(true);
}
// Setup listeners
filterEnabledSwitch.setOnCheckedChangeListener((buttonView, isChecked) -> {
filterConfig.setEnabled(isChecked);
saveFilterConfig();
});
filterModeRadioGroup.setOnCheckedChangeListener((group, checkedId) -> {
if (checkedId == R.id.allowListRadio) {
filterConfig.setMode(SMSFilterHelper.FilterMode.ALLOW_LIST);
} else {
filterConfig.setMode(SMSFilterHelper.FilterMode.BLOCK_LIST);
}
saveFilterConfig();
});
addRuleFab.setOnClickListener(v -> showAddEditRuleDialog(-1));
}
private void saveFilterConfig() {
SMSFilterHelper.saveFilterConfig(mContext, filterConfig);
}
private void showAddEditRuleDialog(int position) {
AlertDialog.Builder builder = new AlertDialog.Builder(this);
View dialogView = LayoutInflater.from(this).inflate(R.layout.dialog_add_filter_rule, null);
builder.setView(dialogView);
TextInputEditText patternEditText = dialogView.findViewById(R.id.patternEditText);
Spinner filterTargetSpinner = dialogView.findViewById(R.id.filterTargetSpinner);
Spinner matchTypeSpinner = dialogView.findViewById(R.id.matchTypeSpinner);
Switch caseSensitiveSwitch = dialogView.findViewById(R.id.caseSensitiveSwitch);
Button cancelButton = dialogView.findViewById(R.id.cancelButton);
Button saveButton = dialogView.findViewById(R.id.saveButton);
// Setup filter target spinner
String[] filterTargets = {"Sender", "Message", "Both"};
ArrayAdapter<String> targetAdapter = new ArrayAdapter<>(this, android.R.layout.simple_spinner_item, filterTargets);
targetAdapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item);
filterTargetSpinner.setAdapter(targetAdapter);
// Setup match type spinner
String[] matchTypes = {"Exact Match", "Starts With", "Ends With", "Contains"};
ArrayAdapter<String> spinnerAdapter = new ArrayAdapter<>(this, android.R.layout.simple_spinner_item, matchTypes);
spinnerAdapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item);
matchTypeSpinner.setAdapter(spinnerAdapter);
// If editing, populate fields
boolean isEdit = position >= 0;
TextView dialogTitle = dialogView.findViewById(R.id.dialogTitle);
if (isEdit) {
SMSFilterRule rule = filterConfig.getRules().get(position);
patternEditText.setText(rule.getPattern());
filterTargetSpinner.setSelection(rule.getFilterTarget().ordinal());
matchTypeSpinner.setSelection(rule.getMatchType().ordinal());
caseSensitiveSwitch.setChecked(rule.isCaseSensitive());
if (dialogTitle != null) {
dialogTitle.setText("Edit Filter Rule");
}
} else {
// Default to case insensitive
caseSensitiveSwitch.setChecked(false);
}
AlertDialog dialog = builder.create();
cancelButton.setOnClickListener(v -> dialog.dismiss());
saveButton.setOnClickListener(v -> {
String pattern = patternEditText.getText() != null ? patternEditText.getText().toString().trim() : "";
if (pattern.isEmpty()) {
Toast.makeText(this, "Please enter a pattern", Toast.LENGTH_SHORT).show();
return;
}
SMSFilterRule.FilterTarget filterTarget = SMSFilterRule.FilterTarget.values()[filterTargetSpinner.getSelectedItemPosition()];
SMSFilterRule.MatchType matchType = SMSFilterRule.MatchType.values()[matchTypeSpinner.getSelectedItemPosition()];
boolean caseSensitive = caseSensitiveSwitch.isChecked();
if (isEdit) {
SMSFilterRule rule = filterConfig.getRules().get(position);
rule.setPattern(pattern);
rule.setFilterTarget(filterTarget);
rule.setMatchType(matchType);
rule.setCaseSensitive(caseSensitive);
adapter.notifyItemChanged(position);
} else {
SMSFilterRule newRule = new SMSFilterRule(pattern, matchType, filterTarget, caseSensitive);
filterConfig.getRules().add(newRule);
adapter.notifyItemInserted(filterConfig.getRules().size() - 1);
}
saveFilterConfig();
dialog.dismiss();
});
dialog.show();
}
private void deleteRule(int position) {
new AlertDialog.Builder(this)
.setTitle("Delete Rule")
.setMessage("Are you sure you want to delete this filter rule?")
.setPositiveButton("Delete", (dialog, which) -> {
filterConfig.getRules().remove(position);
adapter.notifyItemRemoved(position);
saveFilterConfig();
})
.setNegativeButton("Cancel", null)
.show();
}
private class FilterRulesAdapter extends RecyclerView.Adapter<FilterRulesAdapter.ViewHolder> {
private List<SMSFilterRule> rules;
public FilterRulesAdapter(List<SMSFilterRule> rules) {
this.rules = rules != null ? rules : new ArrayList<>();
}
@NonNull
@Override
public ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
View view = LayoutInflater.from(parent.getContext())
.inflate(R.layout.item_filter_rule, parent, false);
return new ViewHolder(view);
}
@Override
public void onBindViewHolder(@NonNull ViewHolder holder, int position) {
SMSFilterRule rule = rules.get(position);
holder.patternText.setText(rule.getPattern());
String matchTypeText = "";
switch (rule.getMatchType()) {
case EXACT:
matchTypeText = "Exact Match";
break;
case STARTS_WITH:
matchTypeText = "Starts With";
break;
case ENDS_WITH:
matchTypeText = "Ends With";
break;
case CONTAINS:
matchTypeText = "Contains";
break;
}
holder.matchTypeText.setText(matchTypeText);
String filterTargetText = "";
switch (rule.getFilterTarget()) {
case SENDER:
filterTargetText = "Filter: Sender";
break;
case MESSAGE:
filterTargetText = "Filter: Message";
break;
case BOTH:
filterTargetText = "Filter: Sender or Message";
break;
}
String caseText = rule.isCaseSensitive() ? " (Case Sensitive)" : " (Case Insensitive)";
holder.filterTargetText.setText(filterTargetText + caseText);
holder.editButton.setOnClickListener(v -> showAddEditRuleDialog(position));
holder.deleteButton.setOnClickListener(v -> deleteRule(position));
}
@Override
public int getItemCount() {
return rules.size();
}
class ViewHolder extends RecyclerView.ViewHolder {
TextView patternText;
TextView matchTypeText;
TextView filterTargetText;
ImageButton editButton;
ImageButton deleteButton;
ViewHolder(View itemView) {
super(itemView);
patternText = itemView.findViewById(R.id.patternText);
matchTypeText = itemView.findViewById(R.id.matchTypeText);
filterTargetText = itemView.findViewById(R.id.filterTargetText);
editButton = itemView.findViewById(R.id.editButton);
deleteButton = itemView.findViewById(R.id.deleteButton);
}
}
}
}
@@ -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 }
}
}
}
}
*/
@@ -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
}
*/
@@ -0,0 +1,29 @@
package com.vernu.sms.database.local
/*
import androidx.annotation.NonNull
import androidx.room.*
import java.util.Date
@Entity(tableName = "sms")
@TypeConverters(DateConverter::class)
data class Sms(
@PrimaryKey(autoGenerate = true) val id: Int = 0,
@ColumnInfo(name = "_id") val serverId: String? = null,
@ColumnInfo(name = "message") val message: String = "",
@ColumnInfo(name = "encrypted_message") val encryptedMessage: String = "",
@ColumnInfo(name = "is_encrypted", defaultValue = "0") val isEncrypted: Boolean = false,
@ColumnInfo(name = "sender") val sender: String? = null,
@ColumnInfo(name = "recipient") val recipient: String? = null,
@ColumnInfo(name = "requested_at") val requestedAt: Date? = null,
@ColumnInfo(name = "sent_at") val sentAt: Date? = null,
@ColumnInfo(name = "delivered_at") val deliveredAt: Date? = null,
@ColumnInfo(name = "received_at") val receivedAt: Date? = null,
@ColumnInfo(name = "type") @field:NonNull val type: String = "",
@ColumnInfo(name = "server_acknowledged_at") val serverAcknowledgedAt: Date? = null,
@ColumnInfo(name = "last_acknowledged_request_at") val lastAcknowledgedRequestAt: Date? = null,
@ColumnInfo(name = "retry_count", defaultValue = "0") val retryCount: Int = 0
) {
fun hasServerAcknowledged(): Boolean = serverAcknowledgedAt != null
}
*/
@@ -0,0 +1,20 @@
package com.vernu.sms.database.local
/*
import androidx.room.*
@Dao
interface SmsDao {
@Query("SELECT * FROM sms")
suspend fun getAll(): List<Sms>
@Query("SELECT * FROM sms WHERE id IN (:smsIds)")
suspend fun loadAllByIds(smsIds: IntArray): List<Sms>
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertAll(vararg sms: Sms)
@Delete
suspend fun delete(sms: Sms)
}
*/
@@ -0,0 +1,14 @@
package com.vernu.sms.dtos
import com.google.gson.annotations.SerializedName
data class GatewayStatsResponse(
@SerializedName("data") val data: GatewayStatsData? = null
)
data class GatewayStatsData(
@SerializedName("totalSentSMSCount") val totalSentSMSCount: Int? = null,
@SerializedName("totalReceivedSMSCount") val totalReceivedSMSCount: Int? = null,
@SerializedName("totalDeviceCount") val totalDeviceCount: Int? = null,
@SerializedName("totalApiKeyCount") val totalApiKeyCount: Int? = null
)
@@ -0,0 +1,21 @@
package com.vernu.sms.dtos
class HeartbeatInputDTO {
var fcmToken: String? = null
var batteryPercentage: Int? = null
var isCharging: Boolean? = null
var networkType: String? = null
var appVersionName: String? = null
var appVersionCode: Int? = null
var deviceUptimeMillis: Long? = null
var memoryFreeBytes: Long? = null
var memoryTotalBytes: Long? = null
var memoryMaxBytes: Long? = null
var storageAvailableBytes: Long? = null
var storageTotalBytes: Long? = null
var timezone: String? = null
var locale: String? = null
var receiveSMSEnabled: Boolean? = null
var smsSendDelaySeconds: Int? = null
var simInfo: SimInfoCollectionDTO? = null
}
@@ -0,0 +1,8 @@
package com.vernu.sms.dtos
class HeartbeatResponseDTO {
@JvmField var success: Boolean = false
@JvmField var fcmTokenUpdated: Boolean = false
@JvmField var lastHeartbeat: Long = 0
@JvmField var name: String? = null
}
@@ -0,0 +1,38 @@
package com.vernu.sms.dtos
import com.google.gson.annotations.SerializedName
data class MessagesResponse(
@SerializedName("data") val data: List<SmsMessage>? = null,
@SerializedName("meta") val meta: PaginationMeta? = null
)
data class SmsMessage(
@SerializedName("_id") val id: String? = null,
@SerializedName("message") val message: String? = null,
@SerializedName("sender") val sender: String? = null,
@SerializedName("recipient") val recipient: String? = null,
@SerializedName("recipients") val recipients: List<String>? = null,
@SerializedName("requestedAt") val requestedAt: String? = null,
@SerializedName("receivedAt") val receivedAt: String? = null,
@SerializedName("createdAt") val createdAt: String? = null,
@SerializedName("status") val status: String? = null,
@SerializedName("errorCode") val errorCode: String? = null,
@SerializedName("errorMessage") val errorMessage: String? = null
) {
val isReceived: Boolean get() = sender != null
val counterparty: String get() = if (isReceived) sender ?: "Unknown"
else recipient ?: recipients?.firstOrNull() ?: "Unknown"
}
data class PaginationMeta(
@SerializedName("page") val page: Int? = null,
@SerializedName("limit") val limit: Int? = null,
@SerializedName("total") val total: Int? = null,
@SerializedName("totalPages") val totalPages: Int? = null
)
data class SendSmsRequest(
@SerializedName("message") val message: String,
@SerializedName("recipients") val recipients: List<String>
)
@@ -1,110 +0,0 @@
package com.vernu.sms.dtos;
public class RegisterDeviceInputDTO {
private String fcmToken;
private boolean enabled;
private String brand;
private String manufacturer;
private String model;
private String serial;
private String buildId;
private String os;
private String osVersion;
private String appVersionName;
private String appVersionCode;
public RegisterDeviceInputDTO() {
}
public RegisterDeviceInputDTO(String fcmToken) {
this.fcmToken = fcmToken;
}
public String getFcmToken() {
return fcmToken;
}
public void setFcmToken(String fcmToken) {
this.fcmToken = fcmToken;
}
public boolean isEnabled() {
return enabled;
}
public void setEnabled(boolean enabled) {
this.enabled = enabled;
}
public String getBrand() {
return brand;
}
public void setBrand(String brand) {
this.brand = brand;
}
public String getManufacturer() {
return manufacturer;
}
public void setManufacturer(String manufacturer) {
this.manufacturer = manufacturer;
}
public String getModel() {
return model;
}
public void setModel(String model) {
this.model = model;
}
public String getSerial() {
return serial;
}
public void setSerial(String serial) {
this.serial = serial;
}
public String getBuildId() {
return buildId;
}
public void setBuildId(String buildId) {
this.buildId = buildId;
}
public String getOs() {
return os;
}
public void setOs(String os) {
this.os = os;
}
public String getOsVersion() {
return osVersion;
}
public void setOsVersion(String osVersion) {
this.osVersion = osVersion;
}
public String getAppVersionName() {
return appVersionName;
}
public void setAppVersionName(String appVersionName) {
this.appVersionName = appVersionName;
}
public String getAppVersionCode() {
return appVersionCode;
}
public void setAppVersionCode(String appVersionCode) {
this.appVersionCode = appVersionCode;
}
}
@@ -0,0 +1,17 @@
package com.vernu.sms.dtos
class RegisterDeviceInputDTO {
var fcmToken: String? = null
@get:JvmName("isEnabled") var enabled: Boolean? = null
var brand: String? = null
var manufacturer: String? = null
var model: String? = null
var name: String? = null
var serial: String? = null
var buildId: String? = null
var os: String? = null
var osVersion: String? = null
var appVersionName: String? = null
var appVersionCode: Int = 0
var simInfo: SimInfoCollectionDTO? = null
}
@@ -1,9 +0,0 @@
package com.vernu.sms.dtos;
import java.util.Map;
public class RegisterDeviceResponseDTO {
public boolean success;
public Map<String, Object> data;
public String error;
}
@@ -0,0 +1,7 @@
package com.vernu.sms.dtos
class RegisterDeviceResponseDTO {
@JvmField var success: Boolean = false
@JvmField var data: Map<String, Any?>? = null
@JvmField var error: String? = null
}
@@ -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
}
@@ -0,0 +1,3 @@
package com.vernu.sms.dtos
class SMSForwardResponseDTO
@@ -0,0 +1,6 @@
package com.vernu.sms.dtos
class SimInfoCollectionDTO {
var lastUpdated: Long = 0
var sims: MutableList<SimInfoDTO>? = null
}
@@ -0,0 +1,14 @@
package com.vernu.sms.dtos
class SimInfoDTO {
var subscriptionId: Int = 0
var iccId: String? = null
var cardId: Int? = null
var carrierName: String? = null
var displayName: String? = null
var simSlotIndex: Int? = null
var mcc: String? = null
var mnc: String? = null
var countryIso: String? = null
var subscriptionType: String? = null
}
@@ -0,0 +1,27 @@
package com.vernu.sms.dtos
import com.google.gson.annotations.SerializedName
data class SubscriptionResponse(
@SerializedName("plan") val plan: SubscriptionPlan? = null,
@SerializedName("currentPeriodStart") val currentPeriodStart: String? = null,
@SerializedName("currentPeriodEnd") val currentPeriodEnd: String? = null,
@SerializedName("isActive") val isActive: Boolean? = null,
@SerializedName("usage") val usage: SubscriptionUsage? = null
)
data class SubscriptionPlan(
@SerializedName("name") val name: String? = null,
@SerializedName("displayName") val displayName: String? = null
)
data class SubscriptionUsage(
@SerializedName("processedSmsToday") val processedSmsToday: Int? = null,
@SerializedName("processedSmsLastMonth") val processedSmsLastMonth: Int? = null,
@SerializedName("dailyLimit") val dailyLimit: Int? = null,
@SerializedName("monthlyLimit") val monthlyLimit: Int? = null,
@SerializedName("dailyRemaining") val dailyRemaining: Int? = null,
@SerializedName("monthlyRemaining") val monthlyRemaining: Int? = null,
@SerializedName("dailyUsagePercentage") val dailyUsagePercentage: Int? = null,
@SerializedName("monthlyUsagePercentage") val monthlyUsagePercentage: Int? = null
)
@@ -0,0 +1,12 @@
package com.vernu.sms.dtos
import com.google.gson.annotations.SerializedName
data class UserProfileWrapper(
@SerializedName("data") val data: UserProfile? = null
)
data class UserProfile(
@SerializedName("name") val name: String? = null,
@SerializedName("email") val email: String? = null
)
@@ -0,0 +1,17 @@
package com.vernu.sms.helpers
import org.json.JSONObject
import retrofit2.Response
/**
* Extracts the human-readable `message` field the API returns in error bodies
* (e.g. the device-limit 429), falling back to null when it can't be parsed.
*/
fun Response<*>.serverErrorMessage(): String? {
val raw = errorBody()?.string()?.takeIf { it.isNotBlank() } ?: return null
return try {
JSONObject(raw).optString("message").takeIf { it.isNotBlank() }
} catch (e: Exception) {
null
}
}
@@ -0,0 +1,162 @@
package com.vernu.sms.helpers
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.net.ConnectivityManager
import android.os.BatteryManager
import android.os.StatFs
import android.os.SystemClock
import android.util.Log
import com.google.firebase.messaging.FirebaseMessaging
import com.vernu.sms.ApiManager
import com.vernu.sms.AppConstants
import com.vernu.sms.BuildConfig
import com.vernu.sms.TextBeeUtils
import com.vernu.sms.dtos.HeartbeatInputDTO
import com.vernu.sms.dtos.SimInfoCollectionDTO
import java.io.IOException
import java.util.Locale
import java.util.TimeZone
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
object HeartbeatHelper {
private const val TAG = "HeartbeatHelper"
@JvmStatic
fun sendHeartbeat(context: Context, deviceId: String, apiKey: String): Boolean {
if (deviceId.isEmpty()) {
Log.d(TAG, "Device not registered, skipping heartbeat")
return false
}
if (apiKey.isEmpty()) {
Log.e(TAG, "API key not available, skipping heartbeat")
return false
}
val heartbeatInput = HeartbeatInputDTO()
return try {
// FCM token (blocking wait up to 5 seconds)
try {
val latch = CountDownLatch(1)
var fcmTokenResult: String? = null
FirebaseMessaging.getInstance().token.addOnCompleteListener { task ->
if (task.isSuccessful) fcmTokenResult = task.result
latch.countDown()
}
if (latch.await(5, TimeUnit.SECONDS) && fcmTokenResult != null) {
heartbeatInput.fcmToken = fcmTokenResult
}
} catch (e: Exception) {
Log.e(TAG, "Failed to get FCM token: ${e.message}")
}
// Battery info
val batteryStatus = context.registerReceiver(null, IntentFilter(Intent.ACTION_BATTERY_CHANGED))
if (batteryStatus != null) {
val level = batteryStatus.getIntExtra(BatteryManager.EXTRA_LEVEL, -1)
val scale = batteryStatus.getIntExtra(BatteryManager.EXTRA_SCALE, -1)
heartbeatInput.batteryPercentage = ((level / scale.toFloat()) * 100).toInt()
val status = batteryStatus.getIntExtra(BatteryManager.EXTRA_STATUS, -1)
heartbeatInput.isCharging = status == BatteryManager.BATTERY_STATUS_CHARGING ||
status == BatteryManager.BATTERY_STATUS_FULL
}
// Network type
@Suppress("DEPRECATION")
val cm = context.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager
@Suppress("DEPRECATION")
val activeNetwork = cm?.activeNetworkInfo
@Suppress("DEPRECATION")
heartbeatInput.networkType = when {
activeNetwork?.isConnected == true && activeNetwork.type == ConnectivityManager.TYPE_WIFI -> "wifi"
activeNetwork?.isConnected == true && activeNetwork.type == ConnectivityManager.TYPE_MOBILE -> "cellular"
else -> "none"
}
// App version
heartbeatInput.appVersionName = BuildConfig.VERSION_NAME
heartbeatInput.appVersionCode = BuildConfig.VERSION_CODE
// Device uptime
heartbeatInput.deviceUptimeMillis = SystemClock.uptimeMillis()
// Memory
val runtime = Runtime.getRuntime()
heartbeatInput.memoryFreeBytes = runtime.freeMemory()
heartbeatInput.memoryTotalBytes = runtime.totalMemory()
heartbeatInput.memoryMaxBytes = runtime.maxMemory()
// Storage
val statFs = StatFs(context.filesDir.path)
heartbeatInput.storageAvailableBytes = statFs.availableBytes
heartbeatInput.storageTotalBytes = statFs.totalBytes
// Locale / timezone
heartbeatInput.timezone = TimeZone.getDefault().id
heartbeatInput.locale = Locale.getDefault().toString()
// Preferences
heartbeatInput.receiveSMSEnabled = SharedPreferenceHelper.getSharedPreferenceBoolean(
context, AppConstants.SHARED_PREFS_RECEIVE_SMS_ENABLED_KEY, false
)
heartbeatInput.smsSendDelaySeconds = SharedPreferenceHelper.getSharedPreferenceInt(
context, AppConstants.SHARED_PREFS_SMS_SEND_DELAY_SECONDS_KEY,
AppConstants.DEFAULT_SMS_SEND_DELAY_SECONDS
)
// SIM info
heartbeatInput.simInfo = SimInfoCollectionDTO().apply {
lastUpdated = System.currentTimeMillis()
sims = TextBeeUtils.collectSimInfo(context)
}
// Send heartbeat (blocking)
val response = ApiManager.getApiService().heartbeat(deviceId, apiKey, heartbeatInput).execute()
if (response.isSuccessful && response.body() != null) {
val body = response.body()!!
if (body.fcmTokenUpdated) Log.d(TAG, "FCM token was updated during heartbeat")
if (!body.name.isNullOrBlank()) {
SharedPreferenceHelper.setSharedPreferenceString(
context, AppConstants.SHARED_PREFS_DEVICE_NAME_KEY, body.name!!
)
Log.d(TAG, "Synced device name from heartbeat: ${body.name}")
}
SharedPreferenceHelper.setSharedPreferenceString(
context, AppConstants.SHARED_PREFS_LAST_HEARTBEAT_MS_KEY,
System.currentTimeMillis().toString()
)
Log.d(TAG, "Heartbeat sent successfully")
true
} else {
Log.e(TAG, "Failed to send heartbeat. Response code: ${response.code()}")
false
}
} catch (e: IOException) {
Log.e(TAG, "Heartbeat API call failed: ${e.message}")
false
} catch (e: Exception) {
Log.e(TAG, "Error collecting device information: ${e.message}")
false
}
}
@JvmStatic
fun isDeviceEligibleForHeartbeat(context: Context): Boolean {
val deviceId = SharedPreferenceHelper.getSharedPreferenceString(
context, AppConstants.SHARED_PREFS_DEVICE_ID_KEY, ""
) ?: ""
if (deviceId.isEmpty()) return false
val deviceEnabled = SharedPreferenceHelper.getSharedPreferenceBoolean(
context, AppConstants.SHARED_PREFS_GATEWAY_ENABLED_KEY, false
)
if (!deviceEnabled) return false
return SharedPreferenceHelper.getSharedPreferenceBoolean(
context, AppConstants.SHARED_PREFS_HEARTBEAT_ENABLED_KEY, true
)
}
}
@@ -0,0 +1,62 @@
package com.vernu.sms.helpers
import android.content.Context
import android.util.Log
import androidx.work.*
import com.vernu.sms.AppConstants
import com.vernu.sms.workers.HeartbeatWorker
import java.util.concurrent.TimeUnit
object HeartbeatManager {
private const val TAG = "HeartbeatManager"
private const val MIN_INTERVAL_MINUTES = 15
private const val UNIQUE_WORK_NAME = "heartbeat_unique_work"
@JvmStatic
fun scheduleHeartbeat(context: Context) {
val appContext = context.applicationContext
var intervalMinutes = SharedPreferenceHelper.getSharedPreferenceInt(
appContext, AppConstants.SHARED_PREFS_HEARTBEAT_INTERVAL_MINUTES_KEY, 30
)
if (intervalMinutes < MIN_INTERVAL_MINUTES) {
Log.w(TAG, "Interval $intervalMinutes minutes is less than minimum $MIN_INTERVAL_MINUTES minutes, using minimum")
intervalMinutes = MIN_INTERVAL_MINUTES
}
Log.d(TAG, "Scheduling heartbeat with interval: $intervalMinutes minutes")
val constraints = Constraints.Builder()
.setRequiredNetworkType(NetworkType.CONNECTED)
.build()
val heartbeatWork = PeriodicWorkRequest.Builder(
HeartbeatWorker::class.java,
intervalMinutes.toLong(),
TimeUnit.MINUTES
)
.setConstraints(constraints)
.addTag(AppConstants.HEARTBEAT_WORK_TAG)
.build()
WorkManager.getInstance(appContext)
.enqueueUniquePeriodicWork(
UNIQUE_WORK_NAME,
ExistingPeriodicWorkPolicy.REPLACE,
heartbeatWork
)
Log.d(TAG, "Heartbeat scheduled successfully with unique work name: $UNIQUE_WORK_NAME")
}
@JvmStatic
fun cancelHeartbeat(context: Context) {
Log.d(TAG, "Cancelling heartbeat work")
val appContext = context.applicationContext
WorkManager.getInstance(appContext).cancelUniqueWork(UNIQUE_WORK_NAME)
WorkManager.getInstance(appContext).cancelAllWorkByTag(AppConstants.HEARTBEAT_WORK_TAG)
}
@JvmStatic
fun triggerHeartbeat(context: Context) {
Log.d(TAG, "Triggering immediate heartbeat")
scheduleHeartbeat(context)
}
}
@@ -0,0 +1,61 @@
package com.vernu.sms.helpers
import android.content.Context
import android.util.Log
import com.google.gson.Gson
import com.google.gson.reflect.TypeToken
import com.vernu.sms.AppConstants
import com.vernu.sms.models.SMSFilterRule
object SMSFilterHelper {
private const val TAG = "SMSFilterHelper"
enum class FilterMode { ALLOW_LIST, BLOCK_LIST }
class FilterConfig {
@get:JvmName("isEnabled") var enabled: Boolean = false
var mode: FilterMode = FilterMode.BLOCK_LIST
var rules: MutableList<SMSFilterRule> = mutableListOf()
}
@JvmStatic
fun loadFilterConfig(context: Context): FilterConfig {
val json = SharedPreferenceHelper.getSharedPreferenceString(
context, AppConstants.SHARED_PREFS_SMS_FILTER_CONFIG_KEY, null
)
if (json.isNullOrEmpty()) return FilterConfig()
return try {
val type = object : TypeToken<FilterConfig>() {}.type
Gson().fromJson<FilterConfig>(json, type) ?: FilterConfig()
} catch (e: Exception) {
Log.e(TAG, "Error loading filter config: ${e.message}")
FilterConfig()
}
}
@JvmStatic
fun saveFilterConfig(context: Context, config: FilterConfig) {
try {
SharedPreferenceHelper.setSharedPreferenceString(
context,
AppConstants.SHARED_PREFS_SMS_FILTER_CONFIG_KEY,
Gson().toJson(config)
)
} catch (e: Exception) {
Log.e(TAG, "Error saving filter config: ${e.message}")
}
}
@JvmStatic
fun shouldProcessSMS(sender: String?, message: String?, context: Context): Boolean {
val config = loadFilterConfig(context)
if (!config.enabled) return true
if (config.rules.isEmpty()) return true
val matchesAnyRule = config.rules.any { it.matches(sender, message) }
return if (config.mode == FilterMode.ALLOW_LIST) matchesAnyRule else !matchesAnyRule
}
@JvmStatic
fun shouldProcessSMS(sender: String?, context: Context): Boolean =
shouldProcessSMS(sender, null, context)
}
@@ -1,25 +0,0 @@
package com.vernu.sms.helpers;
import android.telephony.SmsManager;
import java.util.ArrayList;
public class SMSHelper {
public static void sendSMS(String phoneNo, String message) {
SmsManager smsManager = SmsManager.getDefault();
//for sms with more than 160 chars
ArrayList<String> parts = smsManager.divideMessage(message);
if (parts.size() > 1) {
smsManager.sendMultipartTextMessage(phoneNo, null, parts, null, null);
} else {
smsManager.sendTextMessage(phoneNo, null, message, null, null);
}
}
public static void sendSMSFromSpecificSim(String phoneNo, String message, int simSlot) {
SmsManager smsManager = SmsManager.getSmsManagerForSubscriptionId(simSlot);
smsManager.sendMultipartTextMessage(phoneNo, null, smsManager.divideMessage(message), null, null);
}
}
@@ -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,47 +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);
}
}
@@ -0,0 +1,42 @@
package com.vernu.sms.helpers
import android.content.Context
object SharedPreferenceHelper {
private const val PREF_FILE = "PREF"
@JvmStatic
fun setSharedPreferenceString(context: Context, key: String, value: String) {
context.getSharedPreferences(PREF_FILE, 0).edit().putString(key, value).apply()
}
@JvmStatic
fun setSharedPreferenceInt(context: Context, key: String, value: Int) {
context.getSharedPreferences(PREF_FILE, 0).edit().putInt(key, value).apply()
}
@JvmStatic
fun setSharedPreferenceBoolean(context: Context, key: String, value: Boolean) {
context.getSharedPreferences(PREF_FILE, 0).edit().putBoolean(key, value).apply()
}
@JvmStatic
fun getSharedPreferenceString(context: Context, key: String, defValue: String?): String? {
return context.getSharedPreferences(PREF_FILE, 0).getString(key, defValue)
}
@JvmStatic
fun getSharedPreferenceInt(context: Context, key: String, defValue: Int): Int {
return context.getSharedPreferences(PREF_FILE, 0).getInt(key, defValue)
}
@JvmStatic
fun getSharedPreferenceBoolean(context: Context, key: String, defValue: Boolean): Boolean {
return context.getSharedPreferences(PREF_FILE, 0).getBoolean(key, defValue)
}
@JvmStatic
fun clearSharedPreference(context: Context, key: String) {
context.getSharedPreferences(PREF_FILE, 0).edit().remove(key).apply()
}
}
@@ -0,0 +1,111 @@
package com.vernu.sms.helpers;
import android.content.Context;
import android.util.Log;
import com.vernu.sms.ApiManager;
import com.vernu.sms.AppConstants;
import com.vernu.sms.BuildConfig;
import com.vernu.sms.dtos.RegisterDeviceInputDTO;
import com.vernu.sms.dtos.RegisterDeviceResponseDTO;
import retrofit2.Call;
import retrofit2.Callback;
import retrofit2.Response;
public class VersionTracker {
private static final String TAG = "VersionTracker";
/**
* Checks if the app version has changed since the last time it was run
* @param context Application context
* @return true if version has changed, false otherwise
*/
public static boolean hasVersionChanged(Context context) {
int lastVersionCode = SharedPreferenceHelper.getSharedPreferenceInt(
context,
AppConstants.SHARED_PREFS_LAST_VERSION_CODE_KEY,
-1
);
String lastVersionName = SharedPreferenceHelper.getSharedPreferenceString(
context,
AppConstants.SHARED_PREFS_LAST_VERSION_NAME_KEY,
""
);
int currentVersionCode = BuildConfig.VERSION_CODE;
String currentVersionName = BuildConfig.VERSION_NAME;
// First app launch or version changed
return lastVersionCode == -1 ||
lastVersionCode != currentVersionCode ||
!lastVersionName.equals(currentVersionName);
}
/**
* Updates the stored version information with current version
* @param context Application context
*/
public static void updateStoredVersion(Context context) {
SharedPreferenceHelper.setSharedPreferenceInt(
context,
AppConstants.SHARED_PREFS_LAST_VERSION_CODE_KEY,
BuildConfig.VERSION_CODE
);
SharedPreferenceHelper.setSharedPreferenceString(
context,
AppConstants.SHARED_PREFS_LAST_VERSION_NAME_KEY,
BuildConfig.VERSION_NAME
);
}
/**
* Reports current app version to the server
* @param context Application context
*/
public static void reportVersionToServer(Context context) {
String deviceId = SharedPreferenceHelper.getSharedPreferenceString(
context,
AppConstants.SHARED_PREFS_DEVICE_ID_KEY,
""
);
String apiKey = SharedPreferenceHelper.getSharedPreferenceString(
context,
AppConstants.SHARED_PREFS_API_KEY_KEY,
""
);
// If device is not registered or no API key, can't report version
if (deviceId.isEmpty() || apiKey.isEmpty()) {
Log.d(TAG, "Can't report version: device not registered or no API key");
return;
}
RegisterDeviceInputDTO updateInput = new RegisterDeviceInputDTO();
updateInput.setAppVersionCode(BuildConfig.VERSION_CODE);
updateInput.setAppVersionName(BuildConfig.VERSION_NAME);
Call<RegisterDeviceResponseDTO> apiCall = ApiManager.getApiService()
.updateDevice(deviceId, apiKey, updateInput);
apiCall.enqueue(new Callback<RegisterDeviceResponseDTO>() {
@Override
public void onResponse(Call<RegisterDeviceResponseDTO> call, Response<RegisterDeviceResponseDTO> response) {
if (response.isSuccessful()) {
Log.d(TAG, "Version update reported successfully");
updateStoredVersion(context);
} else {
Log.e(TAG, "Failed to report version update: " + response.code());
}
}
@Override
public void onFailure(Call<RegisterDeviceResponseDTO> call, Throwable t) {
Log.e(TAG, "Error reporting version update: " + t.getMessage());
}
});
}
}
@@ -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,27 +0,0 @@
package com.vernu.sms.models;
public class SMSPayload {
private String[] receivers;
private String smsBody;
public SMSPayload(String[] receivers, String smsBody) {
this.receivers = receivers;
this.smsBody = smsBody;
}
public String[] getReceivers() {
return receivers;
}
public void setReceivers(String[] receivers) {
this.receivers = receivers;
}
public String getSmsBody() {
return smsBody;
}
public void setSmsBody(String smsBody) {
this.smsBody = smsBody;
}
}
@@ -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
}
@@ -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}")
}
})
}
}
}
@@ -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")
}
}
}
@@ -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,101 +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.R;
import com.vernu.sms.activities.MainActivity;
import com.vernu.sms.helpers.SMSHelper;
import com.vernu.sms.helpers.SharedPreferenceHelper;
import com.vernu.sms.models.SMSPayload;
public class FCMService extends FirebaseMessagingService {
private static final String TAG = "MyFirebaseMsgService";
private static final String DEFAULT_NOTIFICATION_CHANNEL_ID = "N1";
@Override
public void onMessageReceived(RemoteMessage remoteMessage) {
Log.d("FCM_MESSAGE", remoteMessage.getData().toString());
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) {
int preferedSim = SharedPreferenceHelper.getSharedPreferenceInt(this, "PREFERED_SIM", -1);
for (String receiver : smsPayload.getReceivers()) {
if(preferedSim == -1) {
SMSHelper.sendSMS(receiver, smsPayload.getSmsBody());
continue;
}
try {
SMSHelper.sendSMSFromSpecificSim(receiver, smsPayload.getSmsBody(), preferedSim);
} catch(Exception e) {
Log.d("SMS_SEND_ERROR", e.getMessage());
}
}
}
// TODO: Handle FCM Notification Messages
if (remoteMessage.getNotification() != null) {
// sendNotification("notif msg", "msg body");
}
}
@Override
public void onNewToken(String token) {
sendRegistrationToServer(token);
}
private void sendRegistrationToServer(String token) {
}
/* 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())
}
}
@@ -1,19 +1,32 @@
package com.vernu.sms.services;
import com.vernu.sms.dtos.SMSDTO;
import com.vernu.sms.dtos.SMSForwardResponseDTO;
import com.vernu.sms.dtos.RegisterDeviceInputDTO;
import com.vernu.sms.dtos.RegisterDeviceResponseDTO;
import com.vernu.sms.dtos.HeartbeatInputDTO;
import com.vernu.sms.dtos.HeartbeatResponseDTO;
import retrofit2.Call;
import retrofit2.http.Body;
import retrofit2.http.Header;
import retrofit2.http.PATCH;
import retrofit2.http.POST;
import retrofit2.http.Path;
import retrofit2.http.Query;
public interface GatewayApiService {
@POST("gateway/devices")
Call<RegisterDeviceResponseDTO> registerDevice(@Query("apiKey") String apiKey, @Body() RegisterDeviceInputDTO body);
Call<RegisterDeviceResponseDTO> registerDevice(@Header("x-api-key") String apiKey, @Body() RegisterDeviceInputDTO body);
@PATCH("gateway/devices/{deviceId}")
Call<RegisterDeviceResponseDTO> updateDevice(@Path("deviceId") String deviceId, @Query("apiKey") String apiKey, @Body() RegisterDeviceInputDTO body);
Call<RegisterDeviceResponseDTO> updateDevice(@Path("deviceId") String deviceId, @Header("x-api-key") String apiKey, @Body() RegisterDeviceInputDTO body);
@POST("gateway/devices/{deviceId}/receive-sms")
Call<SMSForwardResponseDTO> sendReceivedSMS(@Path("deviceId") String deviceId, @Header("x-api-key") String apiKey, @Body() SMSDTO body);
@PATCH("gateway/devices/{deviceId}/sms-status")
Call<SMSForwardResponseDTO> updateSMSStatus(@Path("deviceId") String deviceId, @Header("x-api-key") String apiKey, @Body() SMSDTO body);
@POST("gateway/devices/{deviceId}/heartbeat")
Call<HeartbeatResponseDTO> heartbeat(@Path("deviceId") String deviceId, @Header("x-api-key") String apiKey, @Body() HeartbeatInputDTO body);
}
@@ -0,0 +1,58 @@
package com.vernu.sms.services
import com.vernu.sms.dtos.GatewayStatsResponse
import com.vernu.sms.dtos.MessagesResponse
import com.vernu.sms.dtos.RegisterDeviceInputDTO
import com.vernu.sms.dtos.RegisterDeviceResponseDTO
import com.vernu.sms.dtos.SendSmsRequest
import com.vernu.sms.dtos.SubscriptionResponse
import com.vernu.sms.dtos.UserProfileWrapper
import retrofit2.Response
import retrofit2.http.*
interface GatewayApiServiceKt {
@GET("auth/who-am-i")
suspend fun whoAmI(
@Header("x-api-key") apiKey: String
): Response<UserProfileWrapper>
@GET("gateway/stats")
suspend fun getStats(
@Header("x-api-key") apiKey: String
): Response<GatewayStatsResponse>
@POST("gateway/devices")
suspend fun registerDevice(
@Header("x-api-key") apiKey: String,
@Body body: RegisterDeviceInputDTO
): Response<RegisterDeviceResponseDTO>
@GET("billing/current-subscription")
suspend fun getCurrentSubscription(
@Header("x-api-key") apiKey: String
): Response<SubscriptionResponse>
@PATCH("gateway/devices/{deviceId}")
suspend fun updateDevice(
@Path("deviceId") deviceId: String,
@Header("x-api-key") apiKey: String,
@Body body: RegisterDeviceInputDTO
): Response<RegisterDeviceResponseDTO>
@GET("gateway/devices/{deviceId}/messages")
suspend fun getMessages(
@Path("deviceId") deviceId: String,
@Header("x-api-key") apiKey: String,
@Query("page") page: Int,
@Query("limit") limit: Int,
@Query("type") type: String
): Response<MessagesResponse>
@POST("gateway/devices/{deviceId}/send-sms")
suspend fun sendSms(
@Path("deviceId") deviceId: String,
@Header("x-api-key") apiKey: String,
@Body body: SendSmsRequest
): Response<Any>
}
@@ -0,0 +1,100 @@
package com.vernu.sms.services
import android.app.*
import android.content.Context
import android.content.Intent
import android.content.pm.ServiceInfo
import android.os.Build
import android.os.IBinder
import android.util.Log
import androidx.core.app.NotificationCompat
import com.vernu.sms.AppConstants
import com.vernu.sms.R
import com.vernu.sms.activities.MainActivity
import com.vernu.sms.helpers.SharedPreferenceHelper
class StickyNotificationService : Service() {
companion object {
private const val TAG = "StickyNotificationService"
private const val NOTIFICATION_CHANNEL_ID = "stickyNotificationChannel"
private const val NOTIFICATION_ID = 1
}
override fun onBind(intent: Intent): IBinder? {
Log.i(TAG, "Service onBind ${intent.action}")
return null
}
override fun onCreate() {
super.onCreate()
Log.i(TAG, "Service Started")
val stickyNotificationEnabled = SharedPreferenceHelper.getSharedPreferenceBoolean(
applicationContext, AppConstants.SHARED_PREFS_STICKY_NOTIFICATION_ENABLED_KEY, false
)
if (stickyNotificationEnabled) {
val notification = createNotification()
try {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
startForeground(NOTIFICATION_ID, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_REMOTE_MESSAGING)
} else {
startForeground(NOTIFICATION_ID, notification)
}
Log.i(TAG, "Started foreground service with sticky notification")
} catch (e: Exception) {
// ForegroundServiceStartNotAllowedException on API 31+ when app is in background
Log.w(TAG, "Cannot start foreground service (likely background restriction): ${e.message}")
stopSelf()
}
} else {
Log.i(TAG, "Sticky notification disabled by user preference")
}
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
Log.i(TAG, "Received start id $startId: $intent")
return START_STICKY
}
override fun onDestroy() {
super.onDestroy()
Log.i(TAG, "StickyNotificationService destroyed")
}
private fun createNotification(): Notification {
val notificationManager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val channel = NotificationChannel(
NOTIFICATION_CHANNEL_ID, NOTIFICATION_CHANNEL_ID, NotificationManager.IMPORTANCE_HIGH
).apply {
enableVibration(false)
setShowBadge(false)
}
notificationManager.createNotificationChannel(channel)
val pendingIntent = PendingIntent.getActivity(
this, 0,
Intent(this, MainActivity::class.java),
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
)
Notification.Builder(this, NOTIFICATION_CHANNEL_ID)
.setContentTitle("TextBee Active")
.setContentText("SMS gateway service is active")
.setContentIntent(pendingIntent)
.setOngoing(true)
.setSmallIcon(R.mipmap.ic_launcher)
.build()
} else {
@Suppress("DEPRECATION")
NotificationCompat.Builder(this, NOTIFICATION_CHANNEL_ID)
.setContentTitle("TextBee Active")
.setContentText("SMS gateway service is active")
.setOngoing(true)
.setSmallIcon(R.mipmap.ic_launcher)
.build()
}
}
}
@@ -0,0 +1,624 @@
package com.vernu.sms.ui.dashboard
import android.Manifest
import android.content.Intent
import android.content.pm.PackageManager
import android.net.Uri
import android.os.Build
import android.provider.Settings
import android.widget.Toast
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ContentCopy
import androidx.compose.material.icons.filled.MenuBook
import androidx.compose.material.icons.filled.OpenInBrowser
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material.icons.filled.Warning
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.scale
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalLifecycleOwner
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.viewmodel.compose.viewModel
import com.vernu.sms.R
import com.vernu.sms.dtos.SimInfoDTO
import com.vernu.sms.dtos.SubscriptionResponse
import com.vernu.sms.dtos.UserProfile
import java.text.SimpleDateFormat
import java.util.Locale
import java.util.TimeZone
private val REQUIRED_PERMISSIONS = listOf(
Manifest.permission.SEND_SMS,
Manifest.permission.RECEIVE_SMS,
Manifest.permission.READ_PHONE_STATE
)
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun DashboardScreen(
viewModel: DashboardViewModel = viewModel()
) {
val state by viewModel.state.collectAsState()
val context = LocalContext.current
LaunchedEffect(state.userMessage) {
state.userMessage?.let { message ->
Toast.makeText(context, message, Toast.LENGTH_LONG).show()
viewModel.consumeUserMessage()
}
}
fun checkMissingPermissions() = REQUIRED_PERMISSIONS.filter {
ContextCompat.checkSelfPermission(context, it) != PackageManager.PERMISSION_GRANTED
}
var missingPermissions by remember { mutableStateOf(checkMissingPermissions()) }
var permissionsDenied by remember { mutableStateOf(false) }
val permissionLauncher = rememberLauncherForActivityResult(
ActivityResultContracts.RequestMultiplePermissions()
) {
val stillMissing = checkMissingPermissions()
missingPermissions = stillMissing
if (stillMissing.isNotEmpty()) permissionsDenied = true
}
val lifecycleOwner = LocalLifecycleOwner.current
DisposableEffect(lifecycleOwner) {
val observer = LifecycleEventObserver { _, event ->
if (event == Lifecycle.Event.ON_RESUME) {
missingPermissions = checkMissingPermissions()
if (missingPermissions.isEmpty()) permissionsDenied = false
}
}
lifecycleOwner.lifecycle.addObserver(observer)
onDispose { lifecycleOwner.lifecycle.removeObserver(observer) }
}
Scaffold(
topBar = {
TopAppBar(
title = {
Row(verticalAlignment = Alignment.CenterVertically) {
Image(
painter = painterResource(id = R.drawable.ic_app_logo),
contentDescription = null,
modifier = Modifier.size(28.dp)
)
Spacer(modifier = Modifier.width(8.dp))
Column {
Text(
text = "textbee.dev",
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.primary
)
val greeting = state.userProfile?.name?.takeIf { it.isNotBlank() }
?: state.userProfile?.email?.takeIf { it.isNotBlank() }
if (greeting != null) {
Text(
text = "Hello, $greeting",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
},
actions = {
IconButton(onClick = { viewModel.refresh() }) {
Icon(Icons.Default.Refresh, contentDescription = "Refresh")
}
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.surface
)
)
}
) { padding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(padding)
.padding(horizontal = 16.dp)
.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
Spacer(modifier = Modifier.height(4.dp))
if (missingPermissions.isNotEmpty()) {
PermissionWarningCard(
missingPermissions = missingPermissions,
showOpenSettings = permissionsDenied,
onGrant = { permissionLauncher.launch(missingPermissions.toTypedArray()) },
onOpenSettings = {
context.startActivity(
Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply {
data = Uri.fromParts("package", context.packageName, null)
}
)
}
)
}
DeviceStatusCard(
state = state,
onToggle = { viewModel.toggleGateway(it) },
onReceiveSmsToggle = { viewModel.setReceiveSms(it) }
)
SubscriptionCard(
subscription = state.subscription,
isLoading = state.isSubscriptionLoading,
unavailable = state.subscriptionUnavailable
)
QuickActionsSection()
Spacer(modifier = Modifier.height(8.dp))
}
}
}
@Composable
private fun DeviceStatusCard(
state: DashboardState,
onToggle: (Boolean) -> Unit,
onReceiveSmsToggle: (Boolean) -> Unit
) {
val clipboard = LocalClipboardManager.current
val statusColor = if (state.isGatewayEnabled) MaterialTheme.colorScheme.primary
else MaterialTheme.colorScheme.onSurfaceVariant
val statusText = if (state.isGatewayEnabled) "Enabled" else "Disabled"
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface),
elevation = CardDefaults.cardElevation(defaultElevation = 2.dp)
) {
Column(modifier = Modifier.padding(20.dp)) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
val hardwareModel = "${Build.BRAND.replaceFirstChar { it.uppercase() }} ${Build.MODEL}"
val customName = state.deviceName.trim()
val displayName = customName.ifEmpty { hardwareModel }
val showModel = customName.isNotEmpty() &&
customName.lowercase() != hardwareModel.lowercase()
Column(modifier = Modifier.weight(1f).padding(end = 12.dp)) {
Text(
text = displayName,
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.SemiBold
)
if (showModel) {
Text(
text = hardwareModel,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
if (state.deviceId.isNotEmpty()) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = state.deviceId,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f)
)
IconButton(
onClick = { clipboard.setText(AnnotatedString(state.deviceId)) },
modifier = Modifier.size(20.dp)
) {
Icon(
Icons.Default.ContentCopy,
contentDescription = "Copy Device ID",
modifier = Modifier.size(12.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f)
)
}
}
}
}
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Switch(
checked = state.isGatewayEnabled,
onCheckedChange = onToggle,
enabled = !state.isTogglingGateway
)
Text(
text = "Gateway",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
if (!state.isGatewayEnabled) {
Spacer(modifier = Modifier.height(12.dp))
Surface(
color = statusColor.copy(alpha = 0.15f),
shape = MaterialTheme.shapes.small
) {
Text(
text = statusText,
modifier = Modifier.padding(horizontal = 10.dp, vertical = 4.dp),
style = MaterialTheme.typography.labelMedium,
color = statusColor,
fontWeight = FontWeight.SemiBold
)
}
}
Spacer(modifier = Modifier.height(8.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Column(modifier = Modifier.weight(1f).padding(end = 8.dp)) {
Text(
text = "Receive SMS",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Text(
text = "Received messages appear in your dashboard and API",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f)
)
}
Switch(
checked = state.isReceiveSmsEnabled,
onCheckedChange = onReceiveSmsToggle,
modifier = Modifier.scale(0.75f)
)
}
if (state.availableSims.isNotEmpty()) {
SimCardsSection(sims = state.availableSims)
}
}
}
}
@Composable
private fun PermissionWarningCard(
missingPermissions: List<String>,
showOpenSettings: Boolean,
onGrant: () -> Unit,
onOpenSettings: () -> Unit
) {
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.errorContainer),
elevation = CardDefaults.cardElevation(defaultElevation = 2.dp)
) {
Column(modifier = Modifier.padding(16.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
Icons.Default.Warning,
contentDescription = null,
tint = MaterialTheme.colorScheme.error,
modifier = Modifier.size(18.dp)
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = "Permissions Required",
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.onErrorContainer
)
}
Spacer(modifier = Modifier.height(6.dp))
Text(
text = "The gateway won't work without: ${missingPermissions.joinToString { friendlyPermissionName(it) }}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onErrorContainer
)
Spacer(modifier = Modifier.height(12.dp))
Button(
onClick = if (showOpenSettings) onOpenSettings else onGrant,
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.error,
contentColor = MaterialTheme.colorScheme.onError
)
) {
Text(if (showOpenSettings) "Open App Settings" else "Grant Permissions")
}
}
}
}
private fun friendlyPermissionName(permission: String) = when (permission) {
Manifest.permission.SEND_SMS -> "Send SMS"
Manifest.permission.RECEIVE_SMS -> "Receive SMS"
Manifest.permission.READ_PHONE_STATE -> "Phone State"
else -> permission.substringAfterLast(".")
}
@Composable
private fun SimCardsSection(sims: List<SimInfoDTO>) {
val context = LocalContext.current
val clipboard = LocalClipboardManager.current
Divider(modifier = Modifier.padding(top = 10.dp, bottom = 2.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "SIM Cards",
style = MaterialTheme.typography.labelMedium,
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Text(
text = "simSubscriptionId",
style = MaterialTheme.typography.labelSmall,
fontFamily = FontFamily.Monospace,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f)
)
}
sims.forEach { sim ->
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "SIM ${(sim.simSlotIndex ?: 0) + 1} · ${sim.carrierName ?: sim.displayName ?: "Unknown"}",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.weight(1f)
)
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = sim.subscriptionId.toString(),
style = MaterialTheme.typography.labelSmall,
fontFamily = FontFamily.Monospace,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
IconButton(
onClick = {
clipboard.setText(AnnotatedString(sim.subscriptionId.toString()))
Toast.makeText(context, "Subscription ID copied", Toast.LENGTH_SHORT).show()
},
modifier = Modifier.size(28.dp)
) {
Icon(
Icons.Default.ContentCopy,
contentDescription = "Copy subscription ID",
modifier = Modifier.size(13.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
}
}
@Composable
private fun SubscriptionCard(
subscription: SubscriptionResponse?,
isLoading: Boolean,
unavailable: Boolean
) {
val context = LocalContext.current
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface),
elevation = CardDefaults.cardElevation(defaultElevation = 2.dp)
) {
when {
isLoading -> {
Box(
modifier = Modifier
.fillMaxWidth()
.padding(24.dp),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator(modifier = Modifier.size(24.dp))
}
}
unavailable || subscription == null -> {
// silently hide rather than show an error card
}
else -> {
Column(modifier = Modifier.padding(20.dp)) {
val planName = subscription.plan?.name?.uppercase() ?: "FREE"
val isFree = subscription.plan?.name?.lowercase() == "free" ||
subscription.plan?.name == null
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Column {
Text(
text = "Subscription",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold
)
Spacer(modifier = Modifier.height(4.dp))
Surface(
color = if (isFree) MaterialTheme.colorScheme.surfaceVariant
else MaterialTheme.colorScheme.primary.copy(alpha = 0.15f),
shape = MaterialTheme.shapes.small
) {
Text(
text = planName,
modifier = Modifier.padding(horizontal = 8.dp, vertical = 3.dp),
style = MaterialTheme.typography.labelSmall,
fontWeight = FontWeight.Bold,
color = if (isFree) MaterialTheme.colorScheme.onSurfaceVariant
else MaterialTheme.colorScheme.primary
)
}
}
if (isFree) {
OutlinedButton(
onClick = {
context.startActivity(
Intent(Intent.ACTION_VIEW, Uri.parse("https://textbee.dev/pricing"))
)
}
) {
Text("Upgrade")
}
} else {
TextButton(
onClick = {
context.startActivity(
Intent(Intent.ACTION_VIEW, Uri.parse("https://app.textbee.dev/dashboard/account"))
)
}
) {
Text("Manage")
}
}
}
if (isFree) {
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "Unlock higher limits, more devices & priority support",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
} else {
subscription.usage?.let { usage ->
Spacer(modifier = Modifier.height(16.dp))
UsageRow(
label = "Today",
used = usage.processedSmsToday,
limit = usage.dailyLimit,
pct = usage.dailyUsagePercentage
)
Spacer(modifier = Modifier.height(10.dp))
UsageRow(
label = "This month",
used = usage.processedSmsLastMonth,
limit = usage.monthlyLimit,
pct = usage.monthlyUsagePercentage
)
}
subscription.currentPeriodEnd?.let { dateStr ->
formatDate(dateStr)?.let { formatted ->
Spacer(modifier = Modifier.height(12.dp))
Text(
text = "Renews $formatted",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
}
}
}
}
}
@Composable
private fun UsageRow(label: String, used: Int?, limit: Int?, pct: Int?) {
val isUnlimited = limit == -1
val progress = if (isUnlimited || limit == null || limit == 0) 0f
else (pct ?: 0).coerceIn(0, 100) / 100f
Column {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(text = label, style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant)
Text(
text = if (isUnlimited) "${used ?: 0} / Unlimited"
else "${used ?: 0} / ${limit ?: "—"}",
style = MaterialTheme.typography.bodySmall,
fontWeight = FontWeight.Medium
)
}
Spacer(modifier = Modifier.height(4.dp))
if (!isUnlimited && limit != null && limit > 0) {
LinearProgressIndicator(
progress = progress,
modifier = Modifier
.fillMaxWidth()
.height(6.dp),
color = if (progress > 0.8f) MaterialTheme.colorScheme.error
else MaterialTheme.colorScheme.primary,
trackColor = MaterialTheme.colorScheme.surfaceVariant
)
}
}
}
@Composable
private fun QuickActionsSection() {
val context = LocalContext.current
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text(
text = "Quick Actions",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold
)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
OutlinedButton(
onClick = {
context.startActivity(
Intent(Intent.ACTION_VIEW, Uri.parse("https://app.textbee.dev/dashboard"))
)
},
modifier = Modifier.weight(1f)
) {
Icon(Icons.Default.OpenInBrowser, contentDescription = null, modifier = Modifier.size(16.dp))
Spacer(modifier = Modifier.width(6.dp))
Text("Dashboard")
}
OutlinedButton(
onClick = {
context.startActivity(
Intent(Intent.ACTION_VIEW, Uri.parse("https://textbee.dev/docs"))
)
},
modifier = Modifier.weight(1f)
) {
Icon(Icons.Default.MenuBook, contentDescription = null, modifier = Modifier.size(16.dp))
Spacer(modifier = Modifier.width(6.dp))
Text("Explore Docs")
}
}
}
}
private fun formatDate(isoDate: String): String? {
return try {
val sdf = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss", Locale.getDefault())
sdf.timeZone = TimeZone.getTimeZone("UTC")
val date = sdf.parse(isoDate.take(19)) ?: return null
SimpleDateFormat("MMM d, yyyy", Locale.getDefault()).format(date)
} catch (e: Exception) {
null
}
}
@@ -0,0 +1,208 @@
package com.vernu.sms.ui.dashboard
import android.app.Application
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import com.vernu.sms.ApiManagerKt
import com.vernu.sms.AppConstants
import com.vernu.sms.TextBeeUtils
import com.vernu.sms.dtos.RegisterDeviceInputDTO
import com.vernu.sms.dtos.SimInfoDTO
import com.vernu.sms.dtos.SubscriptionResponse
import com.vernu.sms.dtos.UserProfile
import com.vernu.sms.helpers.HeartbeatManager
import com.vernu.sms.helpers.SharedPreferenceHelper
import com.vernu.sms.helpers.serverErrorMessage
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
data class DashboardState(
val deviceName: String = "",
val deviceId: String = "",
val isGatewayEnabled: Boolean = false,
val lastHeartbeatMs: Long? = null,
val isTogglingGateway: Boolean = false,
val subscription: SubscriptionResponse? = null,
val isSubscriptionLoading: Boolean = true,
val subscriptionUnavailable: Boolean = false,
val userProfile: UserProfile? = null,
val availableSims: List<SimInfoDTO> = emptyList(),
val isReceiveSmsEnabled: Boolean = false,
val userMessage: String? = null
)
class DashboardViewModel(app: Application) : AndroidViewModel(app) {
private val context get() = getApplication<Application>().applicationContext
private val _state = MutableStateFlow(DashboardState())
val state: StateFlow<DashboardState> = _state.asStateFlow()
init {
loadLocalState()
fetchSubscription()
fetchUserProfile()
}
fun refresh() {
loadLocalState()
fetchSubscription()
fetchUserProfile()
}
private fun loadLocalState() {
val deviceName = SharedPreferenceHelper.getSharedPreferenceString(
context, AppConstants.SHARED_PREFS_DEVICE_NAME_KEY, ""
) ?: ""
val deviceId = SharedPreferenceHelper.getSharedPreferenceString(
context, AppConstants.SHARED_PREFS_DEVICE_ID_KEY, ""
) ?: ""
val isEnabled = SharedPreferenceHelper.getSharedPreferenceBoolean(
context, AppConstants.SHARED_PREFS_GATEWAY_ENABLED_KEY, false
)
val lastHeartbeatStr = SharedPreferenceHelper.getSharedPreferenceString(
context, AppConstants.SHARED_PREFS_LAST_HEARTBEAT_MS_KEY, ""
)
val lastHeartbeatMs = lastHeartbeatStr?.toLongOrNull()
val sims = try {
TextBeeUtils.collectSimInfo(context)
} catch (e: Exception) {
emptyList<SimInfoDTO>()
}
val isReceiveSms = SharedPreferenceHelper.getSharedPreferenceBoolean(
context, AppConstants.SHARED_PREFS_RECEIVE_SMS_ENABLED_KEY, false
)
_state.update {
it.copy(
deviceName = deviceName,
deviceId = deviceId,
isGatewayEnabled = isEnabled,
lastHeartbeatMs = lastHeartbeatMs,
availableSims = sims,
isReceiveSmsEnabled = isReceiveSms
)
}
// Restart sticky notification service on every launch if it should be running,
// matching legacy MainActivity behaviour (service is killed by OS on modern Android).
if (isEnabled) {
TextBeeUtils.startStickyNotificationService(context)
if (deviceId.isNotEmpty()) HeartbeatManager.scheduleHeartbeat(context)
}
}
private fun fetchUserProfile() {
val apiKey = SharedPreferenceHelper.getSharedPreferenceString(
context, AppConstants.SHARED_PREFS_API_KEY_KEY, ""
) ?: ""
if (apiKey.isEmpty()) return
viewModelScope.launch {
try {
val response = ApiManagerKt.getApiService().whoAmI(apiKey)
if (response.isSuccessful) {
_state.update { it.copy(userProfile = response.body()?.data) }
}
} catch (e: Exception) {
TextBeeUtils.logException(e, "User profile fetch failed")
}
}
}
private fun fetchSubscription() {
val apiKey = SharedPreferenceHelper.getSharedPreferenceString(
context, AppConstants.SHARED_PREFS_API_KEY_KEY, ""
) ?: ""
if (apiKey.isEmpty()) {
_state.update { it.copy(isSubscriptionLoading = false, subscriptionUnavailable = true) }
return
}
viewModelScope.launch {
_state.update { it.copy(isSubscriptionLoading = true, subscriptionUnavailable = false) }
try {
val response = ApiManagerKt.getApiService().getCurrentSubscription(apiKey)
if (response.isSuccessful) {
_state.update { it.copy(isSubscriptionLoading = false, subscription = response.body()) }
} else {
_state.update { it.copy(isSubscriptionLoading = false, subscriptionUnavailable = true) }
}
} catch (e: Exception) {
_state.update { it.copy(isSubscriptionLoading = false, subscriptionUnavailable = true) }
TextBeeUtils.logException(e, "Subscription fetch failed")
}
}
}
fun consumeUserMessage() {
_state.update { it.copy(userMessage = null) }
}
fun setReceiveSms(enabled: Boolean) {
SharedPreferenceHelper.setSharedPreferenceBoolean(
context, AppConstants.SHARED_PREFS_RECEIVE_SMS_ENABLED_KEY, enabled
)
_state.update { it.copy(isReceiveSmsEnabled = enabled) }
}
fun toggleGateway(enabled: Boolean) {
val deviceId = _state.value.deviceId
val apiKey = SharedPreferenceHelper.getSharedPreferenceString(
context, AppConstants.SHARED_PREFS_API_KEY_KEY, ""
) ?: ""
if (deviceId.isEmpty() || apiKey.isEmpty()) return
viewModelScope.launch {
_state.update { it.copy(isTogglingGateway = true) }
try {
val input = RegisterDeviceInputDTO().apply { this.enabled = enabled }
val response = ApiManagerKt.getApiService().updateDevice(deviceId, apiKey, input)
if (response.isSuccessful) {
SharedPreferenceHelper.setSharedPreferenceBoolean(
context, AppConstants.SHARED_PREFS_GATEWAY_ENABLED_KEY, enabled
)
_state.update { it.copy(isGatewayEnabled = enabled) }
try {
if (enabled) {
if (SharedPreferenceHelper.getSharedPreferenceBoolean(
context, AppConstants.SHARED_PREFS_STICKY_NOTIFICATION_ENABLED_KEY, false
)
) {
TextBeeUtils.startStickyNotificationService(context)
}
HeartbeatManager.scheduleHeartbeat(context)
} else {
TextBeeUtils.stopStickyNotificationService(context)
HeartbeatManager.cancelHeartbeat(context)
}
} catch (e: Exception) {
TextBeeUtils.logException(e, "Gateway service toggle failed")
}
_state.update {
it.copy(
userMessage = if (enabled) "Gateway enabled" else "Gateway disabled"
)
}
} else {
// Surface the server's reason (e.g. device-limit 429) so the
// toggle doesn't silently snap back with no explanation.
val message = response.serverErrorMessage()
?: if (response.code() == 429) {
"You've reached your plan's device limit. Disable or remove another device, or upgrade your plan."
} else {
"Couldn't update the gateway. Please try again."
}
_state.update { it.copy(userMessage = message) }
}
} catch (e: Exception) {
TextBeeUtils.logException(e, "Gateway toggle failed")
_state.update { it.copy(userMessage = "Couldn't update the gateway. Please check your connection.") }
} finally {
_state.update { it.copy(isTogglingGateway = false) }
}
}
}
}
@@ -0,0 +1,171 @@
package com.vernu.sms.ui.main
import android.content.Intent
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.foundation.layout.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Dashboard
import androidx.compose.material.icons.filled.Message
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.navigation.NavGraph.Companion.findStartDestination
import androidx.navigation.NavHostController
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.currentBackStackEntryAsState
import androidx.navigation.compose.rememberNavController
import com.vernu.sms.AppConstants
import com.vernu.sms.activities.MainActivity
import com.vernu.sms.helpers.HeartbeatManager
import com.vernu.sms.helpers.SharedPreferenceHelper
import com.vernu.sms.ui.dashboard.DashboardScreen
import com.vernu.sms.ui.messages.ComposeScreen
import com.vernu.sms.ui.messages.MessagesScreen
import com.vernu.sms.ui.onboarding.OnboardingActivity
import com.vernu.sms.ui.settings.SMSFilterScreen
import com.vernu.sms.ui.settings.SettingsScreen
import com.vernu.sms.ui.theme.TextBeeTheme
enum class MainDestination(val label: String, val icon: ImageVector) {
DASHBOARD("Dashboard", Icons.Default.Dashboard),
MESSAGES("Messages", Icons.Default.Message),
SETTINGS("Settings", Icons.Default.Settings)
}
class NewMainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
TextBeeTheme {
val navController = rememberNavController()
MainScaffold(
navController = navController,
onSwitchToLegacy = {
SharedPreferenceHelper.setSharedPreferenceBoolean(
this, AppConstants.SHARED_PREFS_USE_NEW_UI_KEY, false
)
startActivity(
Intent(this, MainActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
}
)
},
onDisconnect = {
listOf(
AppConstants.SHARED_PREFS_DEVICE_ID_KEY,
AppConstants.SHARED_PREFS_API_KEY_KEY,
AppConstants.SHARED_PREFS_GATEWAY_ENABLED_KEY,
AppConstants.SHARED_PREFS_DEVICE_NAME_KEY,
AppConstants.SHARED_PREFS_LAST_HEARTBEAT_MS_KEY
).forEach { key ->
SharedPreferenceHelper.clearSharedPreference(this, key)
}
HeartbeatManager.cancelHeartbeat(this)
startActivity(
Intent(this, OnboardingActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
}
)
}
)
}
}
}
}
@Composable
private fun MainScaffold(
navController: NavHostController,
onSwitchToLegacy: () -> Unit,
onDisconnect: () -> Unit
) {
val backStackEntry by navController.currentBackStackEntryAsState()
val currentRoute = backStackEntry?.destination?.route
val showBottomBar = currentRoute != "compose" && currentRoute != "filters"
Scaffold(
bottomBar = {
if (showBottomBar) {
NavigationBar(
containerColor = MaterialTheme.colorScheme.surface,
tonalElevation = 4.dp
) {
MainDestination.values().forEach { dest ->
val selected = currentRoute == dest.name
NavigationBarItem(
selected = selected,
onClick = {
navController.navigate(dest.name) {
popUpTo(navController.graph.findStartDestination().id) {
saveState = true
}
launchSingleTop = true
restoreState = true
}
},
icon = {
Icon(
dest.icon,
contentDescription = dest.label,
modifier = Modifier.size(if (selected) 26.dp else 22.dp)
)
},
label = {
Text(
dest.label,
style = MaterialTheme.typography.labelSmall,
fontWeight = if (selected) FontWeight.Bold else FontWeight.Normal
)
},
colors = NavigationBarItemDefaults.colors(
selectedIconColor = MaterialTheme.colorScheme.primary,
selectedTextColor = MaterialTheme.colorScheme.primary,
indicatorColor = MaterialTheme.colorScheme.surfaceVariant,
unselectedIconColor = MaterialTheme.colorScheme.onSurfaceVariant,
unselectedTextColor = MaterialTheme.colorScheme.onSurfaceVariant
)
)
}
}
}
}
) { innerPadding ->
NavHost(
navController = navController,
startDestination = MainDestination.DASHBOARD.name,
modifier = Modifier.padding(innerPadding)
) {
composable(MainDestination.DASHBOARD.name) {
DashboardScreen()
}
composable(MainDestination.MESSAGES.name) {
MessagesScreen(
onNavigateToCompose = { navController.navigate("compose") }
)
}
composable(MainDestination.SETTINGS.name) {
SettingsScreen(
onSwitchToLegacy = onSwitchToLegacy,
onNavigateToFilters = { navController.navigate("filters") },
onDisconnect = onDisconnect
)
}
composable("compose") {
ComposeScreen(
onNavigateBack = { navController.popBackStack() }
)
}
composable("filters") {
SMSFilterScreen(onNavigateBack = { navController.popBackStack() })
}
}
}
}
@@ -0,0 +1,178 @@
package com.vernu.sms.ui.messages
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Send
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
@Composable
fun ComposeScreen(
viewModel: ComposeViewModel = viewModel(),
onNavigateBack: () -> Unit
) {
val state by viewModel.state.collectAsState()
var recipientInput by remember { mutableStateOf("") }
val snackbarHostState = remember { SnackbarHostState() }
LaunchedEffect(state.sendSuccess) {
if (state.sendSuccess) {
snackbarHostState.showSnackbar("Message sent successfully!")
viewModel.clearSuccess()
}
}
LaunchedEffect(state.sendError) {
state.sendError?.let {
snackbarHostState.showSnackbar(it)
viewModel.clearError()
}
}
Scaffold(
topBar = {
TopAppBar(
title = {
Text("New Message", fontWeight = FontWeight.SemiBold)
},
navigationIcon = {
IconButton(onClick = onNavigateBack) {
Icon(Icons.Default.ArrowBack, contentDescription = "Back")
}
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.surface
)
)
},
snackbarHost = { SnackbarHost(snackbarHostState) }
) { padding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(padding)
.padding(16.dp)
.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
Text(
text = "Recipients",
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.SemiBold
)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically
) {
OutlinedTextField(
value = recipientInput,
onValueChange = { recipientInput = it },
modifier = Modifier.weight(1f),
label = { Text("Phone number") },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Phone),
singleLine = true
)
Button(
onClick = {
val trimmed = recipientInput.trim()
if (trimmed.isNotEmpty() && !state.recipients.contains(trimmed)) {
viewModel.addRecipient(trimmed)
recipientInput = ""
}
},
enabled = recipientInput.trim().isNotEmpty()
) {
Text("Add")
}
}
if (state.recipients.isNotEmpty()) {
FlowRow(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
state.recipients.forEach { number ->
InputChip(
selected = false,
onClick = {},
label = { Text(number) },
trailingIcon = {
IconButton(
onClick = { viewModel.removeRecipient(number) },
modifier = Modifier.size(18.dp)
) {
Icon(
Icons.Default.Close,
contentDescription = "Remove",
modifier = Modifier.size(14.dp)
)
}
}
)
}
}
}
Divider()
Text(
text = "Message",
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.SemiBold
)
OutlinedTextField(
value = state.message,
onValueChange = { viewModel.setMessage(it) },
modifier = Modifier
.fillMaxWidth()
.heightIn(min = 120.dp),
label = { Text("Type your message") },
maxLines = 8,
supportingText = {
Text("${state.message.length} characters")
}
)
Button(
onClick = { viewModel.sendSms() },
modifier = Modifier.fillMaxWidth(),
enabled = !state.isSending &&
state.recipients.isNotEmpty() &&
state.message.isNotEmpty()
) {
if (state.isSending) {
CircularProgressIndicator(
modifier = Modifier.size(18.dp),
color = MaterialTheme.colorScheme.onPrimary,
strokeWidth = 2.dp
)
Spacer(modifier = Modifier.width(8.dp))
Text("Sending...")
} else {
Icon(
Icons.Default.Send,
contentDescription = null,
modifier = Modifier.size(18.dp)
)
Spacer(modifier = Modifier.width(8.dp))
Text("Send Message")
}
}
}
}
}
@@ -0,0 +1,101 @@
package com.vernu.sms.ui.messages
import android.app.Application
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import com.vernu.sms.ApiManagerKt
import com.vernu.sms.AppConstants
import com.vernu.sms.dtos.SendSmsRequest
import com.vernu.sms.helpers.SharedPreferenceHelper
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import org.json.JSONObject
import retrofit2.Response
data class ComposeState(
val recipients: List<String> = emptyList(),
val message: String = "",
val isSending: Boolean = false,
val sendError: String? = null,
val sendSuccess: Boolean = false
)
class ComposeViewModel(app: Application) : AndroidViewModel(app) {
private val context get() = getApplication<Application>().applicationContext
private val _state = MutableStateFlow(ComposeState())
val state: StateFlow<ComposeState> = _state.asStateFlow()
fun addRecipient(number: String) {
_state.update { it.copy(recipients = it.recipients + number) }
}
fun removeRecipient(number: String) {
_state.update { it.copy(recipients = it.recipients - number) }
}
fun setMessage(msg: String) {
_state.update { it.copy(message = msg) }
}
fun clearError() {
_state.update { it.copy(sendError = null) }
}
fun clearSuccess() {
_state.update { it.copy(sendSuccess = false, message = "") }
}
private fun extractErrorMessage(response: Response<*>): String {
return try {
val body = response.errorBody()?.string()
if (!body.isNullOrBlank()) {
val json = JSONObject(body)
json.optString("message").takeIf { it.isNotBlank() }
?: json.optString("error").takeIf { it.isNotBlank() }
?: "Failed to send (${response.code()})"
} else {
"Failed to send (${response.code()})"
}
} catch (e: Exception) {
"Failed to send (${response.code()})"
}
}
fun sendSms() {
val apiKey = SharedPreferenceHelper.getSharedPreferenceString(
context, AppConstants.SHARED_PREFS_API_KEY_KEY, ""
) ?: ""
val deviceId = SharedPreferenceHelper.getSharedPreferenceString(
context, AppConstants.SHARED_PREFS_DEVICE_ID_KEY, ""
) ?: ""
if (apiKey.isEmpty() || deviceId.isEmpty()) return
val s = _state.value
if (s.recipients.isEmpty() || s.message.isEmpty()) return
viewModelScope.launch {
_state.update { it.copy(isSending = true, sendError = null) }
try {
val response = ApiManagerKt.getApiService().sendSms(
deviceId, apiKey, SendSmsRequest(s.message, s.recipients)
)
if (response.isSuccessful) {
_state.update { it.copy(isSending = false, sendSuccess = true) }
} else {
_state.update {
it.copy(isSending = false, sendError = extractErrorMessage(response))
}
}
} catch (e: Exception) {
_state.update {
it.copy(isSending = false, sendError = "Network error. Please try again.")
}
}
}
}
}
@@ -0,0 +1,360 @@
package com.vernu.sms.ui.messages
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowDownward
import androidx.compose.material.icons.filled.ArrowUpward
import androidx.compose.material.icons.filled.Create
import androidx.compose.material.icons.filled.Forum
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import com.vernu.sms.dtos.SmsMessage
import java.text.SimpleDateFormat
import java.util.Locale
import java.util.TimeZone
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun MessagesScreen(
viewModel: MessagesViewModel = viewModel(),
onNavigateToCompose: () -> Unit = {}
) {
val state by viewModel.state.collectAsState()
var selectedMessage by remember { mutableStateOf<SmsMessage?>(null) }
Scaffold(
topBar = {
TopAppBar(
title = {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
Icons.Default.Forum,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary
)
Spacer(modifier = Modifier.width(8.dp))
Text("Messages", fontWeight = FontWeight.SemiBold)
}
},
actions = {
IconButton(onClick = { viewModel.refresh() }) {
Icon(Icons.Default.Refresh, contentDescription = "Refresh")
}
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.surface
)
)
},
floatingActionButton = {
FloatingActionButton(
onClick = onNavigateToCompose,
containerColor = MaterialTheme.colorScheme.primary
) {
Icon(
Icons.Default.Create,
contentDescription = "Compose",
tint = MaterialTheme.colorScheme.onPrimary
)
}
}
) { padding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(padding)
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 8.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
listOf("all" to "All", "sent" to "Sent", "received" to "Received").forEach { (value, label) ->
FilterChip(
selected = state.filter == value,
onClick = { viewModel.setFilter(value) },
label = { Text(label) }
)
}
}
when {
state.isLoading -> {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator()
}
}
state.error != null -> {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text(
text = state.error ?: "Error",
color = MaterialTheme.colorScheme.error
)
Spacer(modifier = Modifier.height(8.dp))
Button(onClick = { viewModel.refresh() }) {
Text("Retry")
}
}
}
}
state.messages.isEmpty() -> {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Icon(
Icons.Default.Forum,
contentDescription = null,
modifier = Modifier.size(48.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "No messages yet",
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
else -> {
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
items(state.messages) { message ->
MessageItem(
message = message,
onClick = { selectedMessage = message }
)
}
if (state.currentPage < state.totalPages) {
item {
Box(
modifier = Modifier
.fillMaxWidth()
.padding(8.dp),
contentAlignment = Alignment.Center
) {
if (state.isLoadingMore) {
CircularProgressIndicator(modifier = Modifier.size(24.dp))
} else {
OutlinedButton(onClick = { viewModel.loadMore() }) {
Text("Load more")
}
}
}
}
}
item { Spacer(modifier = Modifier.height(80.dp)) }
}
}
}
}
}
selectedMessage?.let { msg ->
MessageDetailDialog(message = msg, onDismiss = { selectedMessage = null })
}
}
@Composable
private fun MessageItem(message: SmsMessage, onClick: () -> Unit) {
val accentColor = if (message.isReceived) Color(0xFF4CAF50)
else MaterialTheme.colorScheme.primary
Card(
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = onClick),
elevation = CardDefaults.cardElevation(defaultElevation = 1.dp),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface)
) {
Row(
modifier = Modifier
.fillMaxWidth()
.height(IntrinsicSize.Min)
) {
Box(
modifier = Modifier
.width(4.dp)
.fillMaxHeight()
.background(accentColor)
)
Column(
modifier = Modifier
.padding(12.dp)
.fillMaxWidth()
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
imageVector = if (message.isReceived) Icons.Default.ArrowDownward
else Icons.Default.ArrowUpward,
contentDescription = null,
modifier = Modifier.size(14.dp),
tint = accentColor
)
Spacer(modifier = Modifier.width(4.dp))
Text(
text = message.counterparty,
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.SemiBold
)
}
Text(
text = formatRelativeTime(message.createdAt),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
Spacer(modifier = Modifier.height(4.dp))
Text(
text = message.message ?: "",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
if (!message.isReceived && message.status != null) {
Spacer(modifier = Modifier.height(4.dp))
StatusBadge(status = message.status)
}
}
}
}
}
@Composable
private fun StatusBadge(status: String) {
val (color, label) = when (status.lowercase()) {
"delivered" -> MaterialTheme.colorScheme.tertiary to "Delivered"
"sent" -> MaterialTheme.colorScheme.primary to "Sent"
"failed" -> MaterialTheme.colorScheme.error to "Failed"
else -> MaterialTheme.colorScheme.onSurfaceVariant to "Pending"
}
Surface(
color = color.copy(alpha = 0.15f),
shape = MaterialTheme.shapes.extraSmall
) {
Text(
text = label,
modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp),
style = MaterialTheme.typography.labelSmall,
color = color,
fontWeight = FontWeight.SemiBold
)
}
}
@Composable
private fun MessageDetailDialog(message: SmsMessage, onDismiss: () -> Unit) {
val accentColor = if (message.isReceived) Color(0xFF4CAF50)
else MaterialTheme.colorScheme.primary
AlertDialog(
onDismissRequest = onDismiss,
title = {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
imageVector = if (message.isReceived) Icons.Default.ArrowDownward
else Icons.Default.ArrowUpward,
contentDescription = null,
tint = accentColor,
modifier = Modifier.size(18.dp)
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = if (message.isReceived) "Received from" else "Sent to",
style = MaterialTheme.typography.titleMedium
)
}
},
text = {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text(
text = message.counterparty,
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.SemiBold,
color = accentColor
)
if (!message.isReceived && message.status != null) {
StatusBadge(status = message.status)
}
Divider()
Text(
text = message.message ?: "",
style = MaterialTheme.typography.bodyMedium
)
val timestamp = if (message.isReceived) message.receivedAt else message.requestedAt
timestamp?.let {
Text(
text = formatFullDate(it),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
},
confirmButton = {
TextButton(onClick = onDismiss) { Text("Close") }
}
)
}
private fun formatRelativeTime(isoDate: String?): String {
if (isoDate == null) return ""
return try {
val sdf = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss", Locale.getDefault())
sdf.timeZone = TimeZone.getTimeZone("UTC")
val date = sdf.parse(isoDate.take(19)) ?: return ""
val diffMs = System.currentTimeMillis() - date.time
when {
diffMs < 60_000 -> "Just now"
diffMs < 3_600_000 -> "${diffMs / 60_000}m ago"
diffMs < 86_400_000 -> "${diffMs / 3_600_000}h ago"
diffMs < 604_800_000 -> "${diffMs / 86_400_000}d ago"
else -> SimpleDateFormat("MMM d", Locale.getDefault()).format(date)
}
} catch (e: Exception) {
""
}
}
private fun formatFullDate(isoDate: String): String {
return try {
val sdf = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss", Locale.getDefault())
sdf.timeZone = TimeZone.getTimeZone("UTC")
val date = sdf.parse(isoDate.take(19)) ?: return ""
SimpleDateFormat("MMM d, yyyy 'at' h:mm a", Locale.getDefault()).format(date)
} catch (e: Exception) {
""
}
}
@@ -0,0 +1,103 @@
package com.vernu.sms.ui.messages
import android.app.Application
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import com.vernu.sms.ApiManagerKt
import com.vernu.sms.AppConstants
import com.vernu.sms.dtos.SmsMessage
import com.vernu.sms.helpers.SharedPreferenceHelper
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
data class MessagesState(
val messages: List<SmsMessage> = emptyList(),
val isLoading: Boolean = true,
val isLoadingMore: Boolean = false,
val error: String? = null,
val filter: String = "all",
val currentPage: Int = 1,
val totalPages: Int = 1,
val total: Int = 0
)
class MessagesViewModel(app: Application) : AndroidViewModel(app) {
private val context get() = getApplication<Application>().applicationContext
private val _state = MutableStateFlow(MessagesState())
val state: StateFlow<MessagesState> = _state.asStateFlow()
init {
fetchMessages(reset = true)
}
fun setFilter(filter: String) {
_state.update { it.copy(filter = filter, currentPage = 1) }
fetchMessages(reset = true)
}
fun refresh() = fetchMessages(reset = true)
fun loadMore() {
val s = _state.value
if (s.isLoadingMore || s.currentPage >= s.totalPages) return
_state.update { it.copy(currentPage = it.currentPage + 1) }
fetchMessages(reset = false)
}
private fun fetchMessages(reset: Boolean) {
val apiKey = SharedPreferenceHelper.getSharedPreferenceString(
context, AppConstants.SHARED_PREFS_API_KEY_KEY, ""
) ?: ""
val deviceId = SharedPreferenceHelper.getSharedPreferenceString(
context, AppConstants.SHARED_PREFS_DEVICE_ID_KEY, ""
) ?: ""
if (apiKey.isEmpty() || deviceId.isEmpty()) {
_state.update { it.copy(isLoading = false, error = "Device not connected") }
return
}
val page = if (reset) 1 else _state.value.currentPage
val filter = _state.value.filter
viewModelScope.launch {
if (reset) {
_state.update { it.copy(isLoading = true, error = null) }
} else {
_state.update { it.copy(isLoadingMore = true) }
}
try {
val response = ApiManagerKt.getApiService()
.getMessages(deviceId, apiKey, page, 20, filter)
if (response.isSuccessful) {
val body = response.body()
val newMessages = body?.data ?: emptyList()
val meta = body?.meta
_state.update {
it.copy(
messages = if (reset) newMessages else it.messages + newMessages,
isLoading = false,
isLoadingMore = false,
error = null,
currentPage = page,
totalPages = meta?.totalPages ?: 1,
total = meta?.total ?: 0
)
}
} else {
_state.update {
it.copy(isLoading = false, isLoadingMore = false, error = "Failed to load messages")
}
}
} catch (e: Exception) {
_state.update {
it.copy(isLoading = false, isLoadingMore = false, error = "Network error")
}
}
}
}
}
@@ -0,0 +1,104 @@
package com.vernu.sms.ui.onboarding
import android.content.Intent
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.viewModels
import androidx.compose.runtime.Composable
import androidx.navigation.NavHostController
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import com.journeyapps.barcodescanner.ScanContract
import com.journeyapps.barcodescanner.ScanOptions
import com.vernu.sms.ui.main.NewMainActivity
import com.vernu.sms.ui.onboarding.screens.*
import com.vernu.sms.ui.theme.TextBeeTheme
class OnboardingActivity : ComponentActivity() {
private val viewModel: OnboardingViewModel by viewModels()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val qrLauncher = registerForActivityResult(ScanContract()) { result ->
result?.contents?.let { scanned ->
viewModel.onQrScanned(scanned.trim())
}
}
setContent {
TextBeeTheme {
val navController = rememberNavController()
OnboardingNavGraph(
navController = navController,
viewModel = viewModel,
onScanQr = {
qrLauncher.launch(ScanOptions().apply {
setPrompt("Scan the QR code from textbee.dev/dashboard")
setBeepEnabled(true)
setOrientationLocked(false)
})
},
onComplete = {
val intent = Intent(this, NewMainActivity::class.java)
intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
startActivity(intent)
}
)
}
}
}
}
@Composable
private fun OnboardingNavGraph(
navController: NavHostController,
viewModel: OnboardingViewModel,
onScanQr: () -> Unit,
onComplete: () -> Unit
) {
NavHost(navController = navController, startDestination = "welcome") {
composable("welcome") {
WelcomeScreen(
onGetStarted = {
viewModel.setReturningUser(false)
navController.navigate("credentials")
},
onHaveDeviceId = {
viewModel.setReturningUser(true)
navController.navigate("credentials")
}
)
}
composable("credentials") {
CredentialsScreen(
viewModel = viewModel,
onScanQr = onScanQr,
onNext = { navController.navigate("device_setup") },
onBack = { navController.popBackStack() }
)
}
composable("device_setup") {
DeviceSetupScreen(
viewModel = viewModel,
onSuccess = { navController.navigate("permissions") },
onBack = { navController.popBackStack() }
)
}
composable("permissions") {
PermissionsScreen(
onContinue = { navController.navigate("setup_complete") },
onBack = { navController.popBackStack() }
)
}
composable("setup_complete") {
SetupCompleteScreen(
viewModel = viewModel,
onOpenDashboard = onComplete
)
}
}
}
@@ -0,0 +1,189 @@
package com.vernu.sms.ui.onboarding
import android.content.Context
import android.os.Build
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.google.firebase.messaging.FirebaseMessaging
import com.vernu.sms.ApiManagerKt
import com.vernu.sms.AppConstants
import com.vernu.sms.BuildConfig
import com.vernu.sms.TextBeeUtils
import com.vernu.sms.dtos.RegisterDeviceInputDTO
import com.vernu.sms.dtos.SimInfoCollectionDTO
import com.vernu.sms.helpers.HeartbeatManager
import com.vernu.sms.helpers.SharedPreferenceHelper
import com.vernu.sms.helpers.serverErrorMessage
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException
import kotlin.coroutines.suspendCoroutine
data class OnboardingState(
val apiKey: String = "",
val deviceId: String = "",
val deviceName: String = "${Build.BRAND} ${Build.MODEL}",
val isReturningUser: Boolean = false,
val useExistingDeviceId: Boolean = false,
val isQrScanned: Boolean = false,
val isLoading: Boolean = false,
val errorMessage: String? = null,
val registeredDeviceId: String? = null,
val registeredDeviceName: String? = null
)
class OnboardingViewModel : ViewModel() {
private val _state = MutableStateFlow(OnboardingState())
val state: StateFlow<OnboardingState> = _state.asStateFlow()
private val _registrationSuccess = Channel<Unit>(Channel.CONFLATED)
val registrationSuccess = _registrationSuccess.receiveAsFlow()
fun setApiKey(key: String) {
_state.update { it.copy(apiKey = key.trim(), errorMessage = null, isQrScanned = false) }
}
fun onQrScanned(key: String) {
_state.update { it.copy(apiKey = key.trim(), errorMessage = null, isQrScanned = true) }
}
fun setDeviceId(id: String) {
_state.update { it.copy(deviceId = id.trim(), errorMessage = null) }
}
fun setDeviceName(name: String) {
_state.update { it.copy(deviceName = name) }
}
fun setReturningUser(returning: Boolean) {
_state.update { it.copy(isReturningUser = returning, useExistingDeviceId = returning) }
}
fun setUseExistingDeviceId(use: Boolean) {
_state.update { it.copy(useExistingDeviceId = use, deviceId = if (!use) "" else it.deviceId) }
}
fun clearError() {
_state.update { it.copy(errorMessage = null) }
}
fun registerOrUpdateDevice(context: Context) {
val current = _state.value
val apiKey = current.apiKey
val deviceId = current.deviceId
val shouldUpdate = current.isReturningUser || (current.useExistingDeviceId && deviceId.isNotEmpty())
if (apiKey.isEmpty()) {
_state.update { it.copy(errorMessage = "Please enter your API key.") }
return
}
if ((current.isReturningUser || current.useExistingDeviceId) && deviceId.isEmpty()) {
_state.update { it.copy(errorMessage = "Please enter your Device ID.") }
return
}
viewModelScope.launch {
_state.update { it.copy(isLoading = true, errorMessage = null) }
try {
val fcmToken = getFcmToken()
val collectedSimInfo = SimInfoCollectionDTO().apply {
lastUpdated = System.currentTimeMillis()
sims = TextBeeUtils.collectSimInfo(context)
}
val input = RegisterDeviceInputDTO().apply {
this.fcmToken = fcmToken
enabled = true
brand = Build.BRAND
manufacturer = Build.MANUFACTURER
model = Build.MODEL
buildId = Build.ID
os = Build.VERSION.BASE_OS
appVersionCode = BuildConfig.VERSION_CODE
appVersionName = BuildConfig.VERSION_NAME
name = current.deviceName.ifEmpty { "${Build.BRAND} ${Build.MODEL}" }
simInfo = collectedSimInfo
}
val response = if (shouldUpdate) {
ApiManagerKt.getApiService().updateDevice(deviceId, apiKey, input)
} else {
ApiManagerKt.getApiService().registerDevice(apiKey, input)
}
if (response.isSuccessful) {
val data = response.body()?.data
?: throw IllegalStateException("missing_response")
val registeredId = data["_id"] as? String
?: throw IllegalStateException("missing_id")
val heartbeatInterval = (data["heartbeatIntervalMinutes"] as? Double)?.toInt() ?: 30
val name = data["name"] as? String ?: ""
SharedPreferenceHelper.setSharedPreferenceString(
context, AppConstants.SHARED_PREFS_DEVICE_ID_KEY, registeredId
)
SharedPreferenceHelper.setSharedPreferenceString(
context, AppConstants.SHARED_PREFS_API_KEY_KEY, apiKey
)
SharedPreferenceHelper.setSharedPreferenceInt(
context, AppConstants.SHARED_PREFS_HEARTBEAT_INTERVAL_MINUTES_KEY, heartbeatInterval
)
val resolvedName = name.ifEmpty { current.deviceName }
SharedPreferenceHelper.setSharedPreferenceString(
context, AppConstants.SHARED_PREFS_DEVICE_NAME_KEY, resolvedName
)
SharedPreferenceHelper.setSharedPreferenceBoolean(
context, AppConstants.SHARED_PREFS_GATEWAY_ENABLED_KEY, true
)
HeartbeatManager.scheduleHeartbeat(context)
_state.update {
it.copy(
isLoading = false,
registeredDeviceId = registeredId,
registeredDeviceName = resolvedName
)
}
_registrationSuccess.send(Unit)
} else {
_state.update {
it.copy(
isLoading = false,
errorMessage = when (response.code()) {
401 -> "Invalid API key. Go back and check your key."
404 -> "Device ID not found. Verify it in your dashboard."
429 -> response.serverErrorMessage()
?: "You've reached your plan's device limit. Disable or remove another device, or upgrade your plan."
in 500..599 -> "Server error. Please try again in a moment."
else -> "Request failed (${response.code()}). Please try again."
}
)
}
}
} catch (e: Exception) {
val message = when {
e.message == "missing_id" -> "Unexpected server response. Please try again."
e.message?.contains("Unable to resolve host") == true ||
e.message?.contains("timeout") == true ||
e.message?.contains("connect") == true ->
"No internet connection. Check your network and retry."
else -> "Something went wrong. Please try again."
}
_state.update { it.copy(isLoading = false, errorMessage = message) }
TextBeeUtils.logException(e, "Onboarding device registration failed")
}
}
}
private suspend fun getFcmToken(): String = suspendCoroutine { cont ->
FirebaseMessaging.getInstance().token
.addOnSuccessListener { token -> cont.resume(token) }
.addOnFailureListener { e -> cont.resumeWithException(e) }
}
}
@@ -0,0 +1,285 @@
package com.vernu.sms.ui.onboarding.screens
import android.content.Intent
import android.net.Uri
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.QrCodeScanner
import androidx.compose.material.icons.filled.Visibility
import androidx.compose.material.icons.filled.VisibilityOff
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.unit.dp
import com.vernu.sms.ui.onboarding.OnboardingViewModel
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun CredentialsScreen(
viewModel: OnboardingViewModel,
onScanQr: () -> Unit,
onNext: () -> Unit,
onBack: () -> Unit
) {
val state by viewModel.state.collectAsState()
val context = LocalContext.current
var selectedTab by remember { mutableStateOf(0) }
var showApiKey by remember { mutableStateOf(false) }
val tabs = listOf("Scan QR Code", "Enter Manually")
Scaffold(
topBar = {
TopAppBar(
title = {},
navigationIcon = {
IconButton(onClick = onBack) {
Icon(Icons.Default.ArrowBack, contentDescription = "Back")
}
}
)
}
) { padding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(padding)
.padding(horizontal = 24.dp)
.verticalScroll(rememberScrollState())
) {
Spacer(modifier = Modifier.height(8.dp))
StepIndicator(current = 1, total = 3)
Spacer(modifier = Modifier.height(24.dp))
Text(
text = "Connect your account",
style = MaterialTheme.typography.headlineMedium
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = "Enter your API key from your textbee.dev dashboard",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
TextButton(
onClick = {
context.startActivity(
Intent(Intent.ACTION_VIEW, Uri.parse("https://app.textbee.dev/register"))
)
},
contentPadding = PaddingValues(0.dp)
) {
Text(
text = "Don't have an account? Sign up free",
style = MaterialTheme.typography.bodySmall
)
}
Spacer(modifier = Modifier.height(16.dp))
TabRow(selectedTabIndex = selectedTab) {
tabs.forEachIndexed { index, title ->
Tab(
selected = selectedTab == index,
onClick = { selectedTab = index },
text = { Text(title) }
)
}
}
Spacer(modifier = Modifier.height(24.dp))
when (selectedTab) {
0 -> QrTab(
apiKey = state.apiKey,
isQrScanned = state.isQrScanned,
onScanQr = onScanQr,
onSwitchToManual = { selectedTab = 1 }
)
1 -> ManualTab(
apiKey = state.apiKey,
showApiKey = showApiKey,
onApiKeyChange = { viewModel.setApiKey(it) },
onToggleVisibility = { showApiKey = !showApiKey }
)
}
Spacer(modifier = Modifier.height(32.dp))
Button(
onClick = onNext,
enabled = state.apiKey.isNotEmpty(),
modifier = Modifier
.fillMaxWidth()
.height(52.dp)
) {
Text("Next →")
}
Spacer(modifier = Modifier.height(24.dp))
}
}
}
@Composable
private fun QrTab(
apiKey: String,
isQrScanned: Boolean,
onScanQr: () -> Unit,
onSwitchToManual: () -> Unit
) {
val context = LocalContext.current
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Column(
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(6.dp)
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "1. Go to your textbee.dev dashboard",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.weight(1f)
)
TextButton(
onClick = {
context.startActivity(
Intent(Intent.ACTION_VIEW, Uri.parse("https://app.textbee.dev/dashboard"))
)
},
contentPadding = PaddingValues(horizontal = 8.dp, vertical = 0.dp)
) {
Text("Open", style = MaterialTheme.typography.bodySmall)
}
}
Text(
text = "2. Click \"Register Device\" or \"Generate API Key\"",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.fillMaxWidth()
)
Text(
text = "3. Scan the QR code shown on screen",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.fillMaxWidth()
)
}
Spacer(modifier = Modifier.height(24.dp))
if (isQrScanned && apiKey.isNotEmpty()) {
Card(
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.primaryContainer
),
modifier = Modifier.fillMaxWidth()
) {
Row(
modifier = Modifier.padding(16.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
Icons.Default.Check,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary
)
Spacer(modifier = Modifier.width(12.dp))
Column {
Text(
text = "QR scanned successfully",
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.primary
)
Text(
text = apiKey.take(8) + "••••••••",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
Spacer(modifier = Modifier.height(16.dp))
OutlinedButton(onClick = onScanQr) {
Icon(Icons.Default.QrCodeScanner, contentDescription = null)
Spacer(modifier = Modifier.width(8.dp))
Text("Scan Again")
}
} else {
Button(
onClick = onScanQr,
modifier = Modifier
.fillMaxWidth()
.height(52.dp)
) {
Icon(Icons.Default.QrCodeScanner, contentDescription = null)
Spacer(modifier = Modifier.width(8.dp))
Text("Scan QR Code")
}
}
Spacer(modifier = Modifier.height(16.dp))
TextButton(onClick = onSwitchToManual) {
Text("Enter manually instead")
}
}
}
@Composable
private fun ManualTab(
apiKey: String,
showApiKey: Boolean,
onApiKeyChange: (String) -> Unit,
onToggleVisibility: () -> Unit
) {
val context = LocalContext.current
OutlinedTextField(
value = apiKey,
onValueChange = onApiKeyChange,
label = { Text("API Key") },
placeholder = { Text("Paste your API key here") },
visualTransformation = if (showApiKey) VisualTransformation.None else PasswordVisualTransformation(),
trailingIcon = {
IconButton(onClick = onToggleVisibility) {
Icon(
imageVector = if (showApiKey) Icons.Default.VisibilityOff else Icons.Default.Visibility,
contentDescription = if (showApiKey) "Hide" else "Show"
)
}
},
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
modifier = Modifier.fillMaxWidth(),
singleLine = true
)
TextButton(
onClick = {
context.startActivity(
Intent(Intent.ACTION_VIEW, Uri.parse("https://app.textbee.dev/dashboard"))
)
},
contentPadding = PaddingValues(0.dp)
) {
Text(
text = "Get your API key at app.textbee.dev/dashboard",
style = MaterialTheme.typography.bodySmall
)
}
}
@@ -0,0 +1,201 @@
package com.vernu.sms.ui.onboarding.screens
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material.icons.filled.Close
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import com.vernu.sms.ui.onboarding.OnboardingViewModel
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun DeviceSetupScreen(
viewModel: OnboardingViewModel,
onSuccess: () -> Unit,
onBack: () -> Unit
) {
val state by viewModel.state.collectAsState()
val context = LocalContext.current
LaunchedEffect(Unit) {
viewModel.registrationSuccess.collect { onSuccess() }
}
Box(modifier = Modifier.fillMaxSize()) {
Scaffold(
topBar = {
TopAppBar(
title = {},
navigationIcon = {
IconButton(onClick = onBack) {
Icon(Icons.Default.ArrowBack, contentDescription = "Back")
}
}
)
}
) { padding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(padding)
.padding(horizontal = 24.dp)
.verticalScroll(rememberScrollState())
) {
Spacer(modifier = Modifier.height(8.dp))
StepIndicator(current = 2, total = 3)
Spacer(modifier = Modifier.height(24.dp))
Text(
text = if (state.isReturningUser) "Reconnect your device" else "Set up your device",
style = MaterialTheme.typography.headlineMedium
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = if (state.isReturningUser)
"Enter your Device ID to reconnect this device to your account"
else
"Give this device a name and register it to start sending SMS",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.height(24.dp))
if (state.isReturningUser) {
OutlinedTextField(
value = state.deviceId,
onValueChange = { viewModel.setDeviceId(it) },
label = { Text("Device ID") },
placeholder = { Text("Enter your Device ID") },
supportingText = { Text("Find it at textbee.dev/dashboard → Devices") },
modifier = Modifier.fillMaxWidth(),
singleLine = true
)
Spacer(modifier = Modifier.height(16.dp))
} else {
var expandDeviceId by remember { mutableStateOf(false) }
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth()
) {
Checkbox(
checked = expandDeviceId,
onCheckedChange = {
expandDeviceId = it
viewModel.setUseExistingDeviceId(it)
}
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = "This device was previously registered",
style = MaterialTheme.typography.bodyMedium
)
}
Text(
text = "Use this if you reinstalled the app and want to reconnect your existing device rather than create a new one in your dashboard",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(start = 48.dp)
)
if (expandDeviceId) {
Spacer(modifier = Modifier.height(8.dp))
OutlinedTextField(
value = state.deviceId,
onValueChange = { viewModel.setDeviceId(it) },
label = { Text("Device ID") },
placeholder = { Text("Enter your Device ID") },
modifier = Modifier.fillMaxWidth(),
singleLine = true
)
Spacer(modifier = Modifier.height(16.dp))
}
}
OutlinedTextField(
value = state.deviceName,
onValueChange = { viewModel.setDeviceName(it) },
label = { Text("Device Name (optional)") },
placeholder = { Text("e.g. My Gateway Phone") },
modifier = Modifier.fillMaxWidth(),
singleLine = true
)
state.errorMessage?.let { error ->
Spacer(modifier = Modifier.height(16.dp))
Card(
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.errorContainer
),
modifier = Modifier.fillMaxWidth()
) {
Row(
modifier = Modifier.padding(12.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = error,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onErrorContainer,
modifier = Modifier.weight(1f)
)
IconButton(onClick = { viewModel.clearError() }, modifier = Modifier.size(20.dp)) {
Icon(
Icons.Default.Close,
contentDescription = "Dismiss",
tint = MaterialTheme.colorScheme.onErrorContainer,
modifier = Modifier.size(16.dp)
)
}
}
}
}
Spacer(modifier = Modifier.height(32.dp))
Button(
onClick = { viewModel.registerOrUpdateDevice(context) },
enabled = !state.isLoading,
modifier = Modifier
.fillMaxWidth()
.height(52.dp)
) {
Text(if (state.isReturningUser) "Reconnect Device" else "Register Device")
}
Spacer(modifier = Modifier.height(24.dp))
}
}
if (state.isLoading) {
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background.copy(alpha = 0.7f)
) {
Column(
modifier = Modifier.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
CircularProgressIndicator(color = MaterialTheme.colorScheme.primary)
Spacer(modifier = Modifier.height(16.dp))
Text(
text = "Connecting your device…",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onBackground
)
}
}
}
}
}
@@ -0,0 +1,65 @@
package com.vernu.sms.ui.onboarding.screens
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
@Composable
fun StepIndicator(current: Int, total: Int) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
repeat(total) { index ->
val step = index + 1
val isActive = step == current
val isDone = step < current
Box(
modifier = Modifier
.size(if (isActive) 28.dp else 24.dp)
.background(
color = when {
isActive -> MaterialTheme.colorScheme.primary
isDone -> MaterialTheme.colorScheme.primary.copy(alpha = 0.4f)
else -> MaterialTheme.colorScheme.surfaceVariant
},
shape = CircleShape
),
contentAlignment = Alignment.Center
) {
Text(
text = step.toString(),
style = MaterialTheme.typography.labelSmall,
fontWeight = FontWeight.Bold,
color = if (isActive || isDone) Color.White
else MaterialTheme.colorScheme.onSurfaceVariant
)
}
if (index < total - 1) {
Box(
modifier = Modifier
.width(24.dp)
.height(2.dp)
.background(
if (isDone) MaterialTheme.colorScheme.primary.copy(alpha = 0.4f)
else MaterialTheme.colorScheme.surfaceVariant
)
)
}
}
Spacer(modifier = Modifier.width(8.dp))
Text(
text = "Step $current of $total",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
@@ -0,0 +1,252 @@
package com.vernu.sms.ui.onboarding.screens
import android.Manifest
import android.content.Intent
import android.content.pm.PackageManager
import android.net.Uri
import android.provider.Settings
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.PhoneAndroid
import androidx.compose.material.icons.filled.Sms
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun PermissionsScreen(
onContinue: () -> Unit,
onBack: () -> Unit
) {
val context = LocalContext.current
val permissions = remember {
listOf(
PermissionItem(
permission = Manifest.permission.SEND_SMS,
label = "Send SMS",
rationale = "Required to send messages from your device",
icon = Icons.Default.Sms
),
PermissionItem(
permission = Manifest.permission.RECEIVE_SMS,
label = "Receive SMS",
rationale = "Required to receive and forward incoming messages",
icon = Icons.Default.Sms
),
PermissionItem(
permission = Manifest.permission.READ_PHONE_STATE,
label = "Phone State",
rationale = "Required to detect SIM cards for multi-SIM support",
icon = Icons.Default.PhoneAndroid
)
)
}
var grantedMap by remember {
mutableStateOf(
permissions.associate { item ->
item.permission to (ContextCompat.checkSelfPermission(context, item.permission) == PackageManager.PERMISSION_GRANTED)
}
)
}
val launcher = rememberLauncherForActivityResult(
ActivityResultContracts.RequestMultiplePermissions()
) { results ->
grantedMap = grantedMap + results
}
val allGranted = grantedMap.values.all { it }
Scaffold(
topBar = {
TopAppBar(
title = {},
navigationIcon = {
IconButton(onClick = onBack) {
Icon(Icons.Default.ArrowBack, contentDescription = "Back")
}
}
)
}
) { padding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(padding)
.padding(horizontal = 24.dp)
.verticalScroll(rememberScrollState())
) {
Spacer(modifier = Modifier.height(8.dp))
StepIndicator(current = 3, total = 3)
Spacer(modifier = Modifier.height(24.dp))
Text(
text = "Grant Permissions",
style = MaterialTheme.typography.headlineMedium
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = "These permissions are required for the SMS gateway to work",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.height(24.dp))
if (!allGranted) {
Button(
onClick = {
val missing = permissions
.filter { grantedMap[it.permission] == false }
.map { it.permission }
.toTypedArray()
launcher.launch(missing)
},
modifier = Modifier
.fillMaxWidth()
.height(48.dp)
) {
Text("Grant All Permissions")
}
Spacer(modifier = Modifier.height(16.dp))
}
permissions.forEach { item ->
val isGranted = grantedMap[item.permission] == true
PermissionRow(
item = item,
isGranted = isGranted,
onGrant = { launcher.launch(arrayOf(item.permission)) },
onOpenSettings = {
context.startActivity(
Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply {
data = Uri.fromParts("package", context.packageName, null)
}
)
}
)
Spacer(modifier = Modifier.height(8.dp))
}
Spacer(modifier = Modifier.height(16.dp))
Text(
text = "These permissions are only used to send and receive SMS on your behalf. textbee never accesses your existing message history.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center
)
TextButton(
onClick = {
context.startActivity(
Intent(Intent.ACTION_VIEW, Uri.parse("https://textbee.dev/privacy-policy"))
)
},
contentPadding = PaddingValues(0.dp)
) {
Text("Privacy Policy", style = MaterialTheme.typography.bodySmall)
}
Spacer(modifier = Modifier.height(16.dp))
Button(
onClick = onContinue,
modifier = Modifier
.fillMaxWidth()
.height(52.dp)
) {
Text(if (allGranted) "Continue" else "Continue Anyway")
}
if (!allGranted) {
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "Some features may be limited without all permissions",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
Spacer(modifier = Modifier.height(24.dp))
}
}
}
@Composable
private fun PermissionRow(
item: PermissionItem,
isGranted: Boolean,
onGrant: () -> Unit,
onOpenSettings: () -> Unit
) {
Card(
colors = CardDefaults.cardColors(
containerColor = if (isGranted)
MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.5f)
else
MaterialTheme.colorScheme.surfaceVariant
),
modifier = Modifier.fillMaxWidth()
) {
Row(
modifier = Modifier.padding(16.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
imageVector = item.icon,
contentDescription = null,
tint = if (isGranted) MaterialTheme.colorScheme.primary
else MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(24.dp)
)
Spacer(modifier = Modifier.width(12.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
text = item.label,
style = MaterialTheme.typography.titleMedium
)
Text(
text = item.rationale,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
Spacer(modifier = Modifier.width(8.dp))
if (isGranted) {
Icon(
Icons.Default.Check,
contentDescription = "Granted",
tint = MaterialTheme.colorScheme.primary
)
} else {
TextButton(onClick = onGrant) {
Text("Grant")
}
}
}
}
}
private data class PermissionItem(
val permission: String,
val label: String,
val rationale: String,
val icon: ImageVector
)
@@ -0,0 +1,230 @@
package com.vernu.sms.ui.onboarding.screens
import android.content.Intent
import android.net.Uri
import androidx.compose.animation.core.*
import com.vernu.sms.AppConstants
import com.vernu.sms.helpers.SharedPreferenceHelper
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.ContentCopy
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.scale
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.ClipboardManager
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import com.vernu.sms.ui.onboarding.OnboardingViewModel
@Composable
fun SetupCompleteScreen(
viewModel: OnboardingViewModel,
onOpenDashboard: () -> Unit
) {
val state by viewModel.state.collectAsState()
val clipboardManager: ClipboardManager = LocalClipboardManager.current
val context = LocalContext.current
var receiveSmsEnabled by remember { mutableStateOf(true) }
val scale = remember { Animatable(0f) }
LaunchedEffect(Unit) {
scale.animateTo(
targetValue = 1f,
animationSpec = spring(
dampingRatio = Spring.DampingRatioMediumBouncy,
stiffness = Spring.StiffnessMedium
)
)
}
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background
) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(32.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Box(
modifier = Modifier
.size(96.dp)
.scale(scale.value)
.background(MaterialTheme.colorScheme.primary, CircleShape),
contentAlignment = Alignment.Center
) {
Icon(
imageVector = Icons.Default.Check,
contentDescription = null,
modifier = Modifier.size(48.dp),
tint = Color.White
)
}
Spacer(modifier = Modifier.height(32.dp))
Text(
text = "You're all set!",
style = MaterialTheme.typography.headlineMedium,
fontWeight = FontWeight.Bold
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = state.registeredDeviceName ?: "Your device",
style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.primary
)
Spacer(modifier = Modifier.height(24.dp))
state.registeredDeviceId?.let { deviceId ->
Card(
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant
),
modifier = Modifier.fillMaxWidth()
) {
Column(modifier = Modifier.padding(16.dp)) {
Text(
text = "Device ID",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.height(4.dp))
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth()
) {
Text(
text = deviceId,
style = MaterialTheme.typography.bodyMedium,
fontFamily = FontFamily.Monospace,
modifier = Modifier.weight(1f)
)
IconButton(
onClick = {
clipboardManager.setText(AnnotatedString(deviceId))
}
) {
Icon(
Icons.Default.ContentCopy,
contentDescription = "Copy",
modifier = Modifier.size(18.dp)
)
}
}
Spacer(modifier = Modifier.height(4.dp))
Text(
text = "Keep this handy. You will need it for API calls and managing devices in your dashboard.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
Spacer(modifier = Modifier.height(12.dp))
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant)
) {
Row(
modifier = Modifier.padding(16.dp),
verticalAlignment = Alignment.CenterVertically
) {
Column(modifier = Modifier.weight(1f).padding(end = 12.dp)) {
Text(
text = "Forward received SMS",
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.SemiBold
)
Spacer(modifier = Modifier.height(2.dp))
Text(
text = "SMS you receive on this phone will appear in your textbee dashboard and be accessible via API",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
Switch(
checked = receiveSmsEnabled,
onCheckedChange = { enabled ->
receiveSmsEnabled = enabled
SharedPreferenceHelper.setSharedPreferenceBoolean(
context,
AppConstants.SHARED_PREFS_RECEIVE_SMS_ENABLED_KEY,
enabled
)
},
modifier = Modifier.scale(0.75f)
)
}
}
Spacer(modifier = Modifier.height(16.dp))
Text(
text = "Your device is registered and ready. Head to your dashboard to send your first SMS or connect via API.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center
)
Spacer(modifier = Modifier.height(32.dp))
Button(
onClick = onOpenDashboard,
modifier = Modifier
.fillMaxWidth()
.height(52.dp)
) {
Text("Open App Dashboard", style = MaterialTheme.typography.labelLarge)
}
Spacer(modifier = Modifier.height(12.dp))
OutlinedButton(
onClick = {
context.startActivity(
Intent(Intent.ACTION_VIEW, Uri.parse("https://app.textbee.dev/dashboard"))
)
},
modifier = Modifier
.fillMaxWidth()
.height(52.dp)
) {
Text("Open Web Dashboard", style = MaterialTheme.typography.labelLarge)
}
Spacer(modifier = Modifier.height(8.dp))
TextButton(
onClick = {
context.startActivity(
Intent(Intent.ACTION_VIEW, Uri.parse("https://textbee.dev/docs"))
)
}
) {
Text(
text = "New to textbee? Read the quickstart guide",
style = MaterialTheme.typography.bodySmall
)
}
}
}
}
@@ -0,0 +1,150 @@
package com.vernu.sms.ui.onboarding.screens
import android.content.Intent
import android.net.Uri
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import com.vernu.sms.R
@Composable
fun WelcomeScreen(
onGetStarted: () -> Unit,
onHaveDeviceId: () -> Unit
) {
val context = LocalContext.current
Column(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.background)
.padding(32.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Box(
modifier = Modifier
.size(96.dp)
.background(MaterialTheme.colorScheme.primaryContainer, CircleShape),
contentAlignment = Alignment.Center
) {
Image(
painter = painterResource(id = R.drawable.ic_app_logo),
contentDescription = null,
modifier = Modifier.size(60.dp)
)
}
Spacer(modifier = Modifier.height(32.dp))
Text(
text = "textbee",
style = MaterialTheme.typography.headlineLarge,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.primary
)
Spacer(modifier = Modifier.height(12.dp))
Text(
text = "Turn your Android into a\nprogrammatic SMS gateway",
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center
)
Spacer(modifier = Modifier.height(24.dp))
Column(
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(6.dp)
) {
listOf(
"Create a free account at textbee.dev",
"Connect this phone as your SMS gateway",
"Send SMS via API from any app or automation"
).forEachIndexed { i, step ->
Row(
verticalAlignment = Alignment.Top,
modifier = Modifier.fillMaxWidth()
) {
Text(
text = "${i + 1}.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.primary,
fontWeight = FontWeight.Bold,
modifier = Modifier.width(20.dp)
)
Text(
text = step,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
Spacer(modifier = Modifier.height(32.dp))
Button(
onClick = onGetStarted,
modifier = Modifier
.fillMaxWidth()
.height(52.dp)
) {
Text("Get Started", style = MaterialTheme.typography.labelLarge)
}
Spacer(modifier = Modifier.height(12.dp))
OutlinedButton(
onClick = onHaveDeviceId,
modifier = Modifier
.fillMaxWidth()
.height(52.dp)
) {
Text("Reconnect a device", style = MaterialTheme.typography.labelLarge)
}
Spacer(modifier = Modifier.height(16.dp))
TextButton(
onClick = {
context.startActivity(
Intent(Intent.ACTION_VIEW, Uri.parse("https://app.textbee.dev/register"))
)
}
) {
Text(
text = "Don't have an account? Sign up free",
style = MaterialTheme.typography.bodySmall
)
}
Spacer(modifier = Modifier.height(8.dp))
TextButton(
onClick = {
context.startActivity(
Intent(Intent.ACTION_VIEW, Uri.parse("https://textbee.dev"))
)
}
) {
Text(
text = "textbee.dev",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
@@ -0,0 +1,354 @@
package com.vernu.sms.ui.settings
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import com.vernu.sms.helpers.SMSFilterHelper
import com.vernu.sms.models.SMSFilterRule
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SMSFilterScreen(
onNavigateBack: () -> Unit,
viewModel: SMSFilterViewModel = viewModel()
) {
val config by viewModel.config.collectAsState()
var showDialog by remember { mutableStateOf(false) }
var editingIndex by remember { mutableStateOf<Int?>(null) }
Scaffold(
topBar = {
TopAppBar(
title = { Text("SMS Filters", fontWeight = FontWeight.SemiBold) },
navigationIcon = {
IconButton(onClick = onNavigateBack) {
Icon(Icons.Default.ArrowBack, contentDescription = "Back")
}
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.surface
)
)
},
floatingActionButton = {
FloatingActionButton(onClick = {
editingIndex = null
showDialog = true
}) {
Icon(Icons.Default.Add, contentDescription = "Add rule")
}
}
) { padding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(padding)
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically
) {
Column(modifier = Modifier.weight(1f)) {
Text("Enable SMS Filtering", style = MaterialTheme.typography.bodyLarge)
Text(
"Filter incoming SMS based on rules below",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
Switch(
checked = config.enabled,
onCheckedChange = { viewModel.setEnabled(it) }
)
}
Divider()
if (config.enabled) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 8.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
FilterChip(
selected = config.mode == SMSFilterHelper.FilterMode.ALLOW_LIST,
onClick = { viewModel.setMode(SMSFilterHelper.FilterMode.ALLOW_LIST) },
label = { Text("Allow List") }
)
FilterChip(
selected = config.mode == SMSFilterHelper.FilterMode.BLOCK_LIST,
onClick = { viewModel.setMode(SMSFilterHelper.FilterMode.BLOCK_LIST) },
label = { Text("Block List") }
)
}
Text(
text = if (config.mode == SMSFilterHelper.FilterMode.ALLOW_LIST)
"Only SMS matching a rule will be forwarded"
else
"SMS matching a rule will be blocked",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(start = 16.dp, end = 16.dp, bottom = 8.dp)
)
Divider()
}
if (config.rules.isEmpty()) {
Box(
modifier = Modifier
.fillMaxWidth()
.weight(1f),
contentAlignment = Alignment.Center
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
Icon(
Icons.Default.FilterList,
contentDescription = null,
modifier = Modifier.size(48.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
Text(
"No filter rules",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Text(
"Tap + to add a rule",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
} else {
LazyColumn(modifier = Modifier.weight(1f)) {
itemsIndexed(config.rules) { index, rule ->
FilterRuleRow(
rule = rule,
onEdit = {
editingIndex = index
showDialog = true
},
onDelete = { viewModel.deleteRule(index) }
)
if (index < config.rules.lastIndex) {
Divider(modifier = Modifier.padding(start = 16.dp))
}
}
}
}
}
}
if (showDialog) {
FilterRuleDialog(
rule = editingIndex?.let { config.rules.getOrNull(it) },
onConfirm = { rule ->
val idx = editingIndex
if (idx != null) viewModel.updateRule(idx, rule) else viewModel.addRule(rule)
showDialog = false
},
onDismiss = { showDialog = false }
)
}
}
@Composable
private fun FilterRuleRow(
rule: SMSFilterRule,
onEdit: () -> Unit,
onDelete: () -> Unit
) {
Row(
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = onEdit)
.padding(horizontal = 16.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = rule.pattern ?: "",
fontWeight = FontWeight.SemiBold,
style = MaterialTheme.typography.bodyLarge
)
Spacer(modifier = Modifier.height(4.dp))
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
SuggestionChip(
onClick = {},
label = {
Text(
rule.matchType?.name?.replace('_', ' ')
?.lowercase()?.replaceFirstChar { it.uppercaseChar() } ?: "",
style = MaterialTheme.typography.labelSmall
)
}
)
SuggestionChip(
onClick = {},
label = {
Text(
rule.filterTarget.name.lowercase().replaceFirstChar { it.uppercaseChar() },
style = MaterialTheme.typography.labelSmall
)
}
)
if (rule.caseSensitive) {
SuggestionChip(
onClick = {},
label = { Text("Case sensitive", style = MaterialTheme.typography.labelSmall) }
)
}
}
}
IconButton(onClick = onDelete) {
Icon(
Icons.Default.Delete,
contentDescription = "Delete rule",
tint = MaterialTheme.colorScheme.error
)
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun FilterRuleDialog(
rule: SMSFilterRule?,
onConfirm: (SMSFilterRule) -> Unit,
onDismiss: () -> Unit
) {
var pattern by remember(rule) { mutableStateOf(rule?.pattern ?: "") }
var matchType by remember(rule) {
mutableStateOf(rule?.matchType ?: SMSFilterRule.MatchType.CONTAINS)
}
var filterTarget by remember(rule) {
mutableStateOf(rule?.filterTarget ?: SMSFilterRule.FilterTarget.SENDER)
}
var caseSensitive by remember(rule) { mutableStateOf(rule?.caseSensitive ?: false) }
var matchTypeExpanded by remember { mutableStateOf(false) }
var filterTargetExpanded by remember { mutableStateOf(false) }
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(if (rule == null) "Add Rule" else "Edit Rule") },
text = {
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
OutlinedTextField(
value = pattern,
onValueChange = { pattern = it },
label = { Text("Pattern") },
singleLine = true,
modifier = Modifier.fillMaxWidth()
)
ExposedDropdownMenuBox(
expanded = filterTargetExpanded,
onExpandedChange = { filterTargetExpanded = !filterTargetExpanded }
) {
OutlinedTextField(
value = filterTarget.name.lowercase()
.replaceFirstChar { it.uppercaseChar() },
onValueChange = {},
readOnly = true,
label = { Text("Filter Target") },
trailingIcon = {
ExposedDropdownMenuDefaults.TrailingIcon(expanded = filterTargetExpanded)
},
modifier = Modifier.menuAnchor().fillMaxWidth()
)
ExposedDropdownMenu(
expanded = filterTargetExpanded,
onDismissRequest = { filterTargetExpanded = false }
) {
SMSFilterRule.FilterTarget.values().forEach { target ->
DropdownMenuItem(
text = {
Text(target.name.lowercase().replaceFirstChar { it.uppercaseChar() })
},
onClick = {
filterTarget = target
filterTargetExpanded = false
}
)
}
}
}
ExposedDropdownMenuBox(
expanded = matchTypeExpanded,
onExpandedChange = { matchTypeExpanded = !matchTypeExpanded }
) {
OutlinedTextField(
value = matchType.name.replace('_', ' ').lowercase()
.replaceFirstChar { it.uppercaseChar() },
onValueChange = {},
readOnly = true,
label = { Text("Match Type") },
trailingIcon = {
ExposedDropdownMenuDefaults.TrailingIcon(expanded = matchTypeExpanded)
},
modifier = Modifier.menuAnchor().fillMaxWidth()
)
ExposedDropdownMenu(
expanded = matchTypeExpanded,
onDismissRequest = { matchTypeExpanded = false }
) {
SMSFilterRule.MatchType.values().forEach { type ->
DropdownMenuItem(
text = {
Text(
type.name.replace('_', ' ').lowercase()
.replaceFirstChar { it.uppercaseChar() }
)
},
onClick = {
matchType = type
matchTypeExpanded = false
}
)
}
}
}
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth()
) {
Text(
"Case sensitive",
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.weight(1f)
)
Switch(checked = caseSensitive, onCheckedChange = { caseSensitive = it })
}
}
},
confirmButton = {
TextButton(
onClick = {
onConfirm(SMSFilterRule(pattern.trim(), matchType, filterTarget, caseSensitive))
},
enabled = pattern.isNotBlank()
) { Text("Save") }
},
dismissButton = {
TextButton(onClick = onDismiss) { Text("Cancel") }
}
)
}
@@ -0,0 +1,37 @@
package com.vernu.sms.ui.settings
import android.app.Application
import androidx.lifecycle.AndroidViewModel
import com.vernu.sms.helpers.SMSFilterHelper
import com.vernu.sms.models.SMSFilterRule
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
class SMSFilterViewModel(app: Application) : AndroidViewModel(app) {
private val _config = MutableStateFlow(SMSFilterHelper.loadFilterConfig(app))
val config: StateFlow<SMSFilterHelper.FilterConfig> = _config.asStateFlow()
fun setEnabled(enabled: Boolean) = mutate { it.enabled = enabled }
fun setMode(mode: SMSFilterHelper.FilterMode) = mutate { it.mode = mode }
fun addRule(rule: SMSFilterRule) = mutate { it.rules.add(rule) }
fun updateRule(index: Int, rule: SMSFilterRule) = mutate { it.rules[index] = rule }
fun deleteRule(index: Int) = mutate { it.rules.removeAt(index) }
private fun mutate(block: (SMSFilterHelper.FilterConfig) -> Unit) {
val current = _config.value
val copy = SMSFilterHelper.FilterConfig().apply {
enabled = current.enabled
mode = current.mode
rules = ArrayList(current.rules)
}
block(copy)
_config.value = copy
SMSFilterHelper.saveFilterConfig(getApplication(), copy)
}
}
@@ -0,0 +1,584 @@
package com.vernu.sms.ui.settings
import android.content.Intent
import android.net.Uri
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import com.vernu.sms.BuildConfig
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SettingsScreen(
onSwitchToLegacy: () -> Unit,
onNavigateToFilters: () -> Unit,
onDisconnect: () -> Unit,
viewModel: SettingsViewModel = viewModel()
) {
val state by viewModel.state.collectAsState()
val context = LocalContext.current
val clipboard = LocalClipboardManager.current
val snackbarHostState = remember { SnackbarHostState() }
var showDisconnectDialog by remember { mutableStateOf(false) }
var showLegacyDialog by remember { mutableStateOf(false) }
var showAboutDialog by remember { mutableStateOf(false) }
var showDeviceNameDialog by remember { mutableStateOf(false) }
var editedDeviceName by remember(state.deviceName) { mutableStateOf(state.deviceName) }
var showDelayDialog by remember { mutableStateOf(false) }
var editedDelay by remember(state.smsSendDelaySeconds) { mutableStateOf(state.smsSendDelaySeconds.toString()) }
LaunchedEffect(state.snackbarMessage) {
state.snackbarMessage?.let {
snackbarHostState.showSnackbar(it)
viewModel.clearSnackbar()
}
}
Scaffold(
topBar = {
TopAppBar(
title = {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(Icons.Default.Settings, contentDescription = null, tint = MaterialTheme.colorScheme.primary)
Spacer(modifier = Modifier.width(8.dp))
Text("Settings", fontWeight = FontWeight.SemiBold)
}
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.surface
)
)
},
snackbarHost = { SnackbarHost(snackbarHostState) }
) { padding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(padding)
.verticalScroll(rememberScrollState())
) {
SettingsSectionHeader("Account")
SettingsRow(
icon = Icons.Default.Fingerprint,
title = "Device ID",
subtitle = state.deviceId.ifEmpty { "" },
subtitleFont = FontFamily.Monospace,
trailing = {
IconButton(onClick = { clipboard.setText(AnnotatedString(state.deviceId)) }) {
Icon(Icons.Default.ContentCopy, contentDescription = "Copy", modifier = Modifier.size(18.dp))
}
}
)
SettingsRow(
icon = Icons.Default.Key,
title = "API Key",
subtitle = if (state.apiKey.isEmpty()) "" else "••••••••" + state.apiKey.takeLast(4),
trailing = {
IconButton(onClick = { clipboard.setText(AnnotatedString(state.apiKey)) }) {
Icon(Icons.Default.ContentCopy, contentDescription = "Copy", modifier = Modifier.size(18.dp))
}
}
)
SettingsRow(
icon = Icons.Default.Edit,
title = "Device Name",
subtitle = state.deviceName,
onClick = {
editedDeviceName = state.deviceName
showDeviceNameDialog = true
},
trailing = {
if (state.isSavingDeviceName) {
CircularProgressIndicator(modifier = Modifier.size(18.dp), strokeWidth = 2.dp)
} else {
Icon(Icons.Default.ChevronRight, contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant)
}
}
)
Divider(modifier = Modifier.padding(start = 56.dp))
SettingsRow(
icon = Icons.Default.LinkOff,
title = "Disconnect Device",
titleColor = MaterialTheme.colorScheme.error,
onClick = { showDisconnectDialog = true }
)
SettingsSectionHeader("Gateway")
SettingsSwitchRow(
icon = Icons.Default.Power,
title = "Gateway Enabled",
subtitle = "Allow sending and receiving SMS",
checked = state.isGatewayEnabled,
onCheckedChange = { viewModel.setGatewayEnabled(it) }
)
if (state.availableSims.size > 1) {
SimSelectionRow(
sims = state.availableSims,
selectedId = state.preferredSimSubscriptionId,
onSelect = { viewModel.setPreferredSim(it) }
)
Text(
text = "Use the simSubscriptionId field in your API requests to override this setting",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(start = 56.dp, end = 16.dp, bottom = 8.dp)
)
}
SettingsSectionHeader("SMS")
SettingsSwitchRow(
icon = Icons.Default.MoveToInbox,
title = "Receive SMS",
subtitle = "Forward incoming SMS to backend",
checked = state.isReceiveSmsEnabled,
onCheckedChange = { viewModel.setReceiveSms(it) }
)
SettingsRow(
icon = Icons.Default.Timer,
title = "Send Delay",
subtitle = "${state.smsSendDelaySeconds}s between each SMS",
onClick = {
editedDelay = state.smsSendDelaySeconds.toString()
showDelayDialog = true
},
trailing = {
Icon(Icons.Default.ChevronRight, contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant)
}
)
SettingsRow(
icon = Icons.Default.FilterList,
title = "Configure Filters",
subtitle = "Allow/block list for incoming SMS",
onClick = onNavigateToFilters,
trailing = {
Icon(Icons.Default.ChevronRight, contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant)
}
)
SettingsSectionHeader("System")
SettingsSwitchRow(
icon = Icons.Default.NotificationsActive,
title = "Sticky Notification",
subtitle = "Keeps the gateway alive in the background",
checked = state.isStickyNotificationEnabled,
onCheckedChange = { viewModel.setStickyNotification(it) }
)
SettingsRow(
icon = Icons.Default.Info,
title = "App Version",
subtitle = "${state.appVersionName} (Build ${state.appVersionCode})"
)
SettingsRow(
icon = Icons.Default.AutoAwesome,
title = "About",
subtitle = "textbee.dev",
onClick = { showAboutDialog = true },
trailing = {
Icon(Icons.Default.ChevronRight, contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant)
}
)
SettingsRow(
icon = Icons.Default.SystemUpdate,
title = "Check for Updates",
onClick = {
val versionInfo = "${BuildConfig.VERSION_NAME}(${BuildConfig.VERSION_CODE})"
val url = "https://textbee.dev/download?currentVersion=${Uri.encode(versionInfo)}"
context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(url)))
},
trailing = {
Icon(Icons.Default.OpenInBrowser, contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.size(18.dp))
}
)
SettingsSectionHeader("Community")
SettingsRow(
icon = Icons.Default.SupportAgent,
title = "Get Support",
onClick = {
context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse("https://app.textbee.dev/dashboard/account/get-support")))
},
trailing = {
Icon(Icons.Default.OpenInBrowser, contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.size(18.dp))
}
)
SettingsRow(
icon = Icons.Default.Share,
title = "Share textbee",
subtitle = "Help spread the word",
onClick = {
val shareText = "i've been using textbee.dev to send SMS via API from my own phone, " +
"no Twilio or paid services needed. works great for automations, alerts, " +
"notifications, or anything that needs programmatic SMS. open source and free to start\n\n" +
"https://textbee.dev"
context.startActivity(
Intent.createChooser(
Intent(Intent.ACTION_SEND).apply {
type = "text/plain"
putExtra(Intent.EXTRA_TEXT, shareText)
},
"Share TextBee"
)
)
}
)
SettingsSectionHeader("Legal")
SettingsRow(
icon = Icons.Default.Gavel,
title = "Terms of Service",
onClick = {
context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse("https://textbee.dev/terms-of-service")))
},
trailing = {
Icon(Icons.Default.OpenInBrowser, contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.size(18.dp))
}
)
SettingsRow(
icon = Icons.Default.Policy,
title = "Privacy Policy",
onClick = {
context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse("https://textbee.dev/privacy-policy")))
},
trailing = {
Icon(Icons.Default.OpenInBrowser, contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.size(18.dp))
}
)
SettingsSectionHeader("UI")
SettingsRow(
icon = Icons.Default.SwapHoriz,
title = "Switch to Legacy UI",
subtitle = "Use the original interface",
onClick = { showLegacyDialog = true },
trailing = {
Icon(Icons.Default.ChevronRight, contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant)
}
)
Spacer(modifier = Modifier.height(32.dp))
}
}
if (showDeviceNameDialog) {
AlertDialog(
onDismissRequest = { showDeviceNameDialog = false },
title = { Text("Edit Device Name") },
text = {
OutlinedTextField(
value = editedDeviceName,
onValueChange = { editedDeviceName = it },
label = { Text("Device Name") },
singleLine = true
)
},
confirmButton = {
TextButton(onClick = {
viewModel.saveDeviceName(editedDeviceName)
showDeviceNameDialog = false
}) { Text("Save") }
},
dismissButton = {
TextButton(onClick = { showDeviceNameDialog = false }) { Text("Cancel") }
}
)
}
if (showDelayDialog) {
AlertDialog(
onDismissRequest = { showDelayDialog = false },
title = { Text("SMS Send Delay") },
text = {
Column {
Text(
"Delay between each SMS in seconds (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/vernu/textbee")))
}
) {
Text("GitHub")
}
}
}
},
confirmButton = {
TextButton(onClick = { showAboutDialog = false }) { Text("Close") }
}
)
}
if (showDisconnectDialog) {
AlertDialog(
onDismissRequest = { showDisconnectDialog = false },
title = { Text("Disconnect Device?") },
text = { Text("This will remove all credentials from this device. You'll need to reconnect to use the gateway.") },
confirmButton = {
TextButton(
onClick = {
showDisconnectDialog = false
onDisconnect()
},
colors = ButtonDefaults.textButtonColors(contentColor = MaterialTheme.colorScheme.error)
) { Text("Disconnect") }
},
dismissButton = {
TextButton(onClick = { showDisconnectDialog = false }) { Text("Cancel") }
}
)
}
}
@Composable
private fun SettingsSectionHeader(title: String) {
Text(
text = title.uppercase(),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(start = 16.dp, top = 20.dp, bottom = 4.dp)
)
}
@Composable
private fun SettingsRow(
icon: androidx.compose.ui.graphics.vector.ImageVector,
title: String,
subtitle: String? = null,
subtitleFont: FontFamily = FontFamily.Default,
titleColor: androidx.compose.ui.graphics.Color = MaterialTheme.colorScheme.onSurface,
onClick: (() -> Unit)? = null,
trailing: @Composable (() -> Unit)? = null
) {
Row(
modifier = Modifier
.fillMaxWidth()
.then(if (onClick != null) Modifier.clickable(onClick = onClick) else Modifier)
.padding(horizontal = 16.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
imageVector = icon,
contentDescription = null,
modifier = Modifier.size(24.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.width(16.dp))
Column(modifier = Modifier.weight(1f)) {
Text(text = title, style = MaterialTheme.typography.bodyLarge, color = titleColor)
subtitle?.let {
Text(
text = it,
style = MaterialTheme.typography.bodySmall.copy(fontFamily = subtitleFont),
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
trailing?.invoke()
}
}
@Composable
private fun SettingsSwitchRow(
icon: androidx.compose.ui.graphics.vector.ImageVector,
title: String,
subtitle: String? = null,
checked: Boolean,
onCheckedChange: (Boolean) -> Unit
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
imageVector = icon,
contentDescription = null,
modifier = Modifier.size(24.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.width(16.dp))
Column(modifier = Modifier.weight(1f)) {
Text(text = title, style = MaterialTheme.typography.bodyLarge)
subtitle?.let {
Text(text = it, style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant)
}
}
Switch(checked = checked, onCheckedChange = onCheckedChange)
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun SimSelectionRow(
sims: List<SimOption>,
selectedId: Int,
onSelect: (Int) -> Unit
) {
var expanded by remember { mutableStateOf(false) }
val selectedSim = sims.find { it.subscriptionId == selectedId }
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
imageVector = Icons.Default.SimCard,
contentDescription = null,
modifier = Modifier.size(24.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.width(16.dp))
Column(modifier = Modifier.weight(1f)) {
Text(text = "Default SIM", style = MaterialTheme.typography.bodyLarge)
ExposedDropdownMenuBox(
expanded = expanded,
onExpandedChange = { expanded = !expanded }
) {
OutlinedTextField(
value = selectedSim?.let { "${it.displayName} · ID: ${it.subscriptionId}" }
?: "Device Default",
onValueChange = {},
readOnly = true,
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) },
modifier = Modifier
.menuAnchor()
.fillMaxWidth(),
textStyle = MaterialTheme.typography.bodySmall
)
ExposedDropdownMenu(
expanded = expanded,
onDismissRequest = { expanded = false }
) {
DropdownMenuItem(
text = { Text("Device Default") },
onClick = {
onSelect(-1)
expanded = false
}
)
sims.forEach { sim ->
DropdownMenuItem(
text = { Text("${sim.displayName} · ID: ${sim.subscriptionId}") },
onClick = {
onSelect(sim.subscriptionId)
expanded = false
}
)
}
}
}
}
}
}
@@ -0,0 +1,215 @@
package com.vernu.sms.ui.settings
import android.app.Application
import android.os.Build
import android.telephony.SubscriptionManager
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import com.vernu.sms.ApiManagerKt
import com.vernu.sms.AppConstants
import com.vernu.sms.BuildConfig
import com.vernu.sms.TextBeeUtils
import com.vernu.sms.dtos.RegisterDeviceInputDTO
import com.vernu.sms.helpers.SharedPreferenceHelper
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import org.json.JSONObject
import retrofit2.Response
data class SimOption(val subscriptionId: Int, val displayName: String)
data class SettingsState(
val deviceId: String = "",
val apiKey: String = "",
val deviceName: String = "",
val isGatewayEnabled: Boolean = false,
val isReceiveSmsEnabled: Boolean = false,
val isStickyNotificationEnabled: Boolean = false,
val smsSendDelaySeconds: Int = AppConstants.DEFAULT_SMS_SEND_DELAY_SECONDS,
val preferredSimSubscriptionId: Int = -1,
val availableSims: List<SimOption> = emptyList(),
val appVersionName: String = BuildConfig.VERSION_NAME,
val appVersionCode: Int = BuildConfig.VERSION_CODE,
val isSavingDeviceName: Boolean = false,
val snackbarMessage: String? = null
)
class SettingsViewModel(app: Application) : AndroidViewModel(app) {
private val context get() = getApplication<Application>().applicationContext
private val _state = MutableStateFlow(SettingsState())
val state: StateFlow<SettingsState> = _state.asStateFlow()
init {
loadSettings()
}
private fun loadSettings() {
val deviceId = SharedPreferenceHelper.getSharedPreferenceString(
context, AppConstants.SHARED_PREFS_DEVICE_ID_KEY, ""
) ?: ""
val apiKey = SharedPreferenceHelper.getSharedPreferenceString(
context, AppConstants.SHARED_PREFS_API_KEY_KEY, ""
) ?: ""
val deviceName = SharedPreferenceHelper.getSharedPreferenceString(
context, AppConstants.SHARED_PREFS_DEVICE_NAME_KEY, ""
) ?: ""
val isGatewayEnabled = SharedPreferenceHelper.getSharedPreferenceBoolean(
context, AppConstants.SHARED_PREFS_GATEWAY_ENABLED_KEY, false
)
val isReceiveSms = SharedPreferenceHelper.getSharedPreferenceBoolean(
context, AppConstants.SHARED_PREFS_RECEIVE_SMS_ENABLED_KEY, false
)
val isSticky = SharedPreferenceHelper.getSharedPreferenceBoolean(
context, AppConstants.SHARED_PREFS_STICKY_NOTIFICATION_ENABLED_KEY, false
)
val smsDelay = SharedPreferenceHelper.getSharedPreferenceInt(
context, AppConstants.SHARED_PREFS_SMS_SEND_DELAY_SECONDS_KEY,
AppConstants.DEFAULT_SMS_SEND_DELAY_SECONDS
)
val preferredSim = SharedPreferenceHelper.getSharedPreferenceInt(
context, AppConstants.SHARED_PREFS_PREFERRED_SIM_KEY, -1
)
val sims = try {
TextBeeUtils.getAvailableSimSlots(context).map { info ->
SimOption(
subscriptionId = info.subscriptionId,
displayName = "${info.carrierName} (SIM ${info.simSlotIndex + 1})"
)
}
} catch (e: Exception) {
emptyList()
}
_state.update {
it.copy(
deviceId = deviceId,
apiKey = apiKey,
deviceName = deviceName.ifEmpty { "${Build.BRAND} ${Build.MODEL}" },
isGatewayEnabled = isGatewayEnabled,
isReceiveSmsEnabled = isReceiveSms,
isStickyNotificationEnabled = isSticky,
smsSendDelaySeconds = smsDelay,
preferredSimSubscriptionId = preferredSim,
availableSims = sims
)
}
}
fun setGatewayEnabled(enabled: Boolean) {
val deviceId = _state.value.deviceId
val apiKey = _state.value.apiKey
if (deviceId.isEmpty() || apiKey.isEmpty()) return
viewModelScope.launch {
try {
val input = RegisterDeviceInputDTO().apply { this.enabled = enabled }
val response = ApiManagerKt.getApiService().updateDevice(deviceId, apiKey, input)
if (response.isSuccessful) {
SharedPreferenceHelper.setSharedPreferenceBoolean(
context, AppConstants.SHARED_PREFS_GATEWAY_ENABLED_KEY, enabled
)
_state.update { it.copy(isGatewayEnabled = enabled) }
if (enabled) {
TextBeeUtils.startStickyNotificationService(context)
com.vernu.sms.helpers.HeartbeatManager.scheduleHeartbeat(context)
} else {
TextBeeUtils.stopStickyNotificationService(context)
com.vernu.sms.helpers.HeartbeatManager.cancelHeartbeat(context)
}
} else {
_state.update { it.copy(snackbarMessage = extractErrorMessage(response, "Failed to update gateway status")) }
}
} catch (e: Exception) {
_state.update { it.copy(snackbarMessage = "Network error. Try again.") }
TextBeeUtils.logException(e, "Gateway toggle from settings failed")
}
}
}
fun setReceiveSms(enabled: Boolean) {
SharedPreferenceHelper.setSharedPreferenceBoolean(
context, AppConstants.SHARED_PREFS_RECEIVE_SMS_ENABLED_KEY, enabled
)
_state.update { it.copy(isReceiveSmsEnabled = enabled) }
}
fun setStickyNotification(enabled: Boolean) {
SharedPreferenceHelper.setSharedPreferenceBoolean(
context, AppConstants.SHARED_PREFS_STICKY_NOTIFICATION_ENABLED_KEY, enabled
)
try {
if (enabled) TextBeeUtils.startStickyNotificationService(context)
else TextBeeUtils.stopStickyNotificationService(context)
} catch (e: Exception) {
TextBeeUtils.logException(e, "Sticky notification toggle failed")
_state.update { it.copy(snackbarMessage = "Could not start notification service") }
}
_state.update { it.copy(isStickyNotificationEnabled = enabled) }
}
fun setSmsSendDelay(seconds: Int) {
val clamped = seconds.coerceIn(0, 3600)
SharedPreferenceHelper.setSharedPreferenceInt(
context, AppConstants.SHARED_PREFS_SMS_SEND_DELAY_SECONDS_KEY, clamped
)
_state.update { it.copy(smsSendDelaySeconds = clamped) }
}
fun setPreferredSim(subscriptionId: Int) {
SharedPreferenceHelper.setSharedPreferenceInt(
context, AppConstants.SHARED_PREFS_PREFERRED_SIM_KEY, subscriptionId
)
_state.update { it.copy(preferredSimSubscriptionId = subscriptionId) }
}
fun saveDeviceName(name: String) {
val deviceId = _state.value.deviceId
val apiKey = _state.value.apiKey
if (deviceId.isEmpty() || apiKey.isEmpty() || name.isBlank()) return
viewModelScope.launch {
_state.update { it.copy(isSavingDeviceName = true) }
try {
val input = RegisterDeviceInputDTO().apply { this.name = name.trim() }
val response = ApiManagerKt.getApiService().updateDevice(deviceId, apiKey, input)
if (response.isSuccessful) {
SharedPreferenceHelper.setSharedPreferenceString(
context, AppConstants.SHARED_PREFS_DEVICE_NAME_KEY, name.trim()
)
_state.update { it.copy(deviceName = name.trim(), snackbarMessage = "Device name saved") }
} else {
_state.update { it.copy(snackbarMessage = extractErrorMessage(response, "Failed to save device name")) }
}
} catch (e: Exception) {
_state.update { it.copy(snackbarMessage = "Network error. Try again.") }
TextBeeUtils.logException(e, "Save device name failed")
} finally {
_state.update { it.copy(isSavingDeviceName = false) }
}
}
}
fun clearSnackbar() = _state.update { it.copy(snackbarMessage = null) }
private fun extractErrorMessage(response: Response<*>, fallback: String): String {
return try {
val body = response.errorBody()?.string()
if (!body.isNullOrBlank()) {
val json = JSONObject(body)
json.optString("message").takeIf { it.isNotBlank() }
?: json.optString("error").takeIf { it.isNotBlank() }
?: fallback
} else {
fallback
}
} catch (e: Exception) {
fallback
}
}
}
@@ -0,0 +1,86 @@
package com.vernu.sms.ui.splash
import android.content.Intent
import android.os.Bundle
import android.os.Handler
import android.os.Looper
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import com.vernu.sms.R
import com.vernu.sms.AppConstants
import com.vernu.sms.activities.MainActivity
import com.vernu.sms.helpers.SharedPreferenceHelper
import com.vernu.sms.ui.main.NewMainActivity
import com.vernu.sms.ui.onboarding.OnboardingActivity
import com.vernu.sms.ui.theme.TextBeeTheme
class SplashActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
TextBeeTheme {
SplashContent()
}
}
Handler(Looper.getMainLooper()).postDelayed({ route() }, 400)
}
private fun route() {
val useNewUi = SharedPreferenceHelper.getSharedPreferenceBoolean(
this, AppConstants.SHARED_PREFS_USE_NEW_UI_KEY, true
)
val deviceId = SharedPreferenceHelper.getSharedPreferenceString(
this, AppConstants.SHARED_PREFS_DEVICE_ID_KEY, ""
)
val target = when {
!useNewUi -> Intent(this, MainActivity::class.java)
deviceId.isNullOrEmpty() -> Intent(this, OnboardingActivity::class.java)
else -> Intent(this, NewMainActivity::class.java)
}
startActivity(target)
finish()
}
}
@Composable
private fun SplashContent() {
Box(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.primary),
contentAlignment = Alignment.Center
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Image(
painter = painterResource(id = R.drawable.ic_app_logo),
contentDescription = null,
modifier = Modifier.size(96.dp)
)
Spacer(modifier = Modifier.height(16.dp))
Text(
text = "textbee",
style = MaterialTheme.typography.headlineLarge,
color = Color.White,
fontWeight = FontWeight.Bold
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = "SMS Gateway",
style = MaterialTheme.typography.bodyLarge,
color = Color.White.copy(alpha = 0.8f)
)
}
}
}
@@ -0,0 +1,20 @@
package com.vernu.sms.ui.theme
import androidx.compose.ui.graphics.Color
val Orange700 = Color(0xFFC4620A)
val Orange800 = Color(0xFFA04405)
val Orange600 = Color(0xFFB45309)
val Orange900 = Color(0xFF92400E)
val OrangeLight = Color(0xFFFFF7ED)
val Gray50 = Color(0xFFF9FAFB)
val Gray100 = Color(0xFFF3F4F6)
val Gray200 = Color(0xFFE5E7EB)
val Gray400 = Color(0xFF9CA3AF)
val Gray600 = Color(0xFF4B5563)
val Gray900 = Color(0xFF111827)
val Green500 = Color(0xFF22C55E)
val Red500 = Color(0xFFEF4444)
val Blue500 = Color(0xFF3B82F6)
@@ -0,0 +1,84 @@
package com.vernu.sms.ui.theme
import android.app.Activity
import android.os.Build
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.dynamicDarkColorScheme
import androidx.compose.material3.dynamicLightColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.SideEffect
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalView
import androidx.core.view.WindowCompat
private val LightColorScheme = lightColorScheme(
primary = Orange700,
onPrimary = Color.White,
primaryContainer = OrangeLight,
onPrimaryContainer = Orange900,
secondary = Orange600,
onSecondary = Color.White,
secondaryContainer = OrangeLight,
onSecondaryContainer = Orange900,
background = Gray50,
onBackground = Gray900,
surface = Color.White,
onSurface = Gray900,
surfaceVariant = Gray100,
onSurfaceVariant = Gray600,
outline = Gray200,
error = Red500,
)
private val DarkColorScheme = darkColorScheme(
primary = Orange600,
onPrimary = Color.White,
primaryContainer = Orange900,
onPrimaryContainer = OrangeLight,
secondary = Orange700,
onSecondary = Color.White,
background = Gray900,
onBackground = Gray100,
surface = Color(0xFF1F2937),
onSurface = Gray100,
surfaceVariant = Color(0xFF374151),
onSurfaceVariant = Gray400,
outline = Color(0xFF4B5563),
error = Red500,
)
@Composable
fun TextBeeTheme(
darkTheme: Boolean = isSystemInDarkTheme(),
dynamicColor: Boolean = false,
content: @Composable () -> Unit
) {
val colorScheme = when {
dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
val context = LocalContext.current
if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
}
darkTheme -> DarkColorScheme
else -> LightColorScheme
}
val view = LocalView.current
if (!view.isInEditMode) {
SideEffect {
val window = (view.context as Activity).window
window.statusBarColor = colorScheme.background.toArgb()
WindowCompat.getInsetsController(window, view).isAppearanceLightStatusBars = !darkTheme
}
}
MaterialTheme(
colorScheme = colorScheme,
typography = Typography,
content = content
)
}
@@ -0,0 +1,49 @@
package com.vernu.sms.ui.theme
import androidx.compose.material3.Typography
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.sp
val Typography = Typography(
headlineLarge = TextStyle(
fontWeight = FontWeight.Bold,
fontSize = 28.sp,
lineHeight = 36.sp
),
headlineMedium = TextStyle(
fontWeight = FontWeight.SemiBold,
fontSize = 22.sp,
lineHeight = 30.sp
),
titleLarge = TextStyle(
fontWeight = FontWeight.SemiBold,
fontSize = 18.sp,
lineHeight = 26.sp
),
titleMedium = TextStyle(
fontWeight = FontWeight.Medium,
fontSize = 16.sp,
lineHeight = 24.sp
),
bodyLarge = TextStyle(
fontWeight = FontWeight.Normal,
fontSize = 16.sp,
lineHeight = 24.sp
),
bodyMedium = TextStyle(
fontWeight = FontWeight.Normal,
fontSize = 14.sp,
lineHeight = 20.sp
),
labelLarge = TextStyle(
fontWeight = FontWeight.Medium,
fontSize = 14.sp,
lineHeight = 20.sp
),
labelSmall = TextStyle(
fontWeight = FontWeight.Medium,
fontSize = 11.sp,
lineHeight = 16.sp
)
)
@@ -0,0 +1,38 @@
package com.vernu.sms.workers
import android.content.Context
import android.util.Log
import androidx.work.Worker
import androidx.work.WorkerParameters
import com.vernu.sms.AppConstants
import com.vernu.sms.helpers.HeartbeatHelper
import com.vernu.sms.helpers.SharedPreferenceHelper
class HeartbeatWorker(context: Context, workerParams: WorkerParameters) : Worker(context, workerParams) {
companion object {
private const val TAG = "HeartbeatWorker"
}
override fun doWork(): Result {
val context = applicationContext
if (!HeartbeatHelper.isDeviceEligibleForHeartbeat(context)) {
Log.d(TAG, "Device not eligible for heartbeat, skipping")
return Result.success()
}
val deviceId = SharedPreferenceHelper.getSharedPreferenceString(
context, AppConstants.SHARED_PREFS_DEVICE_ID_KEY, ""
) ?: ""
val apiKey = SharedPreferenceHelper.getSharedPreferenceString(
context, AppConstants.SHARED_PREFS_API_KEY_KEY, ""
) ?: ""
return if (HeartbeatHelper.sendHeartbeat(context, deviceId, apiKey)) {
Result.success()
} else {
Log.e(TAG, "Failed to send heartbeat, will retry")
Result.retry()
}
}
}
@@ -0,0 +1,90 @@
package com.vernu.sms.workers
import android.content.Context
import android.util.Log
import androidx.work.*
import com.google.gson.Gson
import com.vernu.sms.ApiManager
import com.vernu.sms.dtos.SMSDTO
import com.vernu.sms.dtos.SMSForwardResponseDTO
import java.io.IOException
import java.util.concurrent.TimeUnit
class SMSReceivedWorker(context: Context, workerParams: WorkerParameters) : Worker(context, workerParams) {
companion object {
private const val TAG = "SMSReceivedWorker"
private const val MAX_RETRIES = 5
const val KEY_DEVICE_ID = "device_id"
const val KEY_API_KEY = "api_key"
const val KEY_SMS_DTO = "sms_dto"
const val KEY_RETRY_COUNT = "retry_count"
fun enqueueWork(context: Context, deviceId: String, apiKey: String, smsDTO: SMSDTO) {
val inputData = Data.Builder()
.putString(KEY_DEVICE_ID, deviceId)
.putString(KEY_API_KEY, apiKey)
.putString(KEY_SMS_DTO, Gson().toJson(smsDTO))
.putInt(KEY_RETRY_COUNT, 0)
.build()
val constraints = Constraints.Builder()
.setRequiredNetworkType(NetworkType.CONNECTED)
.build()
val workRequest = OneTimeWorkRequest.Builder(SMSReceivedWorker::class.java)
.setConstraints(constraints)
.setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 10, TimeUnit.SECONDS)
.setInputData(inputData)
.addTag("sms_received")
.build()
val fp = smsDTO.fingerprint
val uniqueWorkName = if (!fp.isNullOrEmpty()) {
"sms_received_$fp"
} else {
Log.w(TAG, "Fingerprint not available, using timestamp for work name")
"sms_received_${System.currentTimeMillis()}"
}
WorkManager.getInstance(context)
.beginUniqueWork(uniqueWorkName, ExistingWorkPolicy.KEEP, workRequest)
.enqueue()
Log.d(TAG, "Work enqueued for received SMS from: ${smsDTO.sender} with fingerprint: $uniqueWorkName")
}
}
override fun doWork(): Result {
val deviceId = inputData.getString(KEY_DEVICE_ID)
val apiKey = inputData.getString(KEY_API_KEY)
val smsDtoJson = inputData.getString(KEY_SMS_DTO)
val retryCount = inputData.getInt(KEY_RETRY_COUNT, 0)
if (deviceId == null || apiKey == null || smsDtoJson == null) {
Log.e(TAG, "Missing required parameters")
return Result.failure()
}
if (retryCount >= MAX_RETRIES) {
Log.e(TAG, "Maximum retry count reached for received SMS")
return Result.failure()
}
val smsDTO = Gson().fromJson(smsDtoJson, SMSDTO::class.java)
return try {
val response = ApiManager.getApiService().sendReceivedSMS(deviceId, apiKey, smsDTO).execute()
if (response.isSuccessful) {
Log.d(TAG, "Received SMS sent to server successfully")
Result.success()
} else {
Log.e(TAG, "Failed to send received SMS to server. Response code: ${response.code()}")
Result.retry()
}
} catch (e: IOException) {
Log.e(TAG, "API call failed: ${e.message}")
Result.retry()
}
}
}
@@ -0,0 +1,82 @@
package com.vernu.sms.workers
import android.content.Context
import android.util.Log
import androidx.work.*
import com.google.gson.Gson
import com.vernu.sms.ApiManager
import com.vernu.sms.dtos.SMSDTO
import com.vernu.sms.dtos.SMSForwardResponseDTO
import java.io.IOException
import java.util.concurrent.TimeUnit
class SMSStatusUpdateWorker(context: Context, workerParams: WorkerParameters) : Worker(context, workerParams) {
companion object {
private const val TAG = "SMSStatusUpdateWorker"
private const val MAX_RETRIES = 5
const val KEY_DEVICE_ID = "device_id"
const val KEY_API_KEY = "api_key"
const val KEY_SMS_DTO = "sms_dto"
const val KEY_RETRY_COUNT = "retry_count"
fun enqueueWork(context: Context, deviceId: String, apiKey: String, smsDTO: SMSDTO) {
val inputData = Data.Builder()
.putString(KEY_DEVICE_ID, deviceId)
.putString(KEY_API_KEY, apiKey)
.putString(KEY_SMS_DTO, Gson().toJson(smsDTO))
.putInt(KEY_RETRY_COUNT, 0)
.build()
val constraints = Constraints.Builder()
.setRequiredNetworkType(NetworkType.CONNECTED)
.build()
val workRequest = OneTimeWorkRequest.Builder(SMSStatusUpdateWorker::class.java)
.setConstraints(constraints)
.setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 10, TimeUnit.SECONDS)
.setInputData(inputData)
.build()
val uniqueWorkName = "sms_status_${smsDTO.status}_${System.currentTimeMillis()}"
WorkManager.getInstance(context)
.beginUniqueWork(uniqueWorkName, ExistingWorkPolicy.REPLACE, workRequest)
.enqueue()
Log.d(TAG, "Work enqueued for SMS status update - ID: ${smsDTO.smsId}")
}
}
override fun doWork(): Result {
val deviceId = inputData.getString(KEY_DEVICE_ID)
val apiKey = inputData.getString(KEY_API_KEY)
val smsDtoJson = inputData.getString(KEY_SMS_DTO)
val retryCount = inputData.getInt(KEY_RETRY_COUNT, 0)
if (deviceId == null || apiKey == null || smsDtoJson == null) {
Log.e(TAG, "Missing required parameters")
return Result.failure()
}
if (retryCount >= MAX_RETRIES) {
Log.e(TAG, "Maximum retry count reached for SMS status update")
return Result.failure()
}
val smsDTO = Gson().fromJson(smsDtoJson, SMSDTO::class.java)
return try {
val response = ApiManager.getApiService().updateSMSStatus(deviceId, apiKey, smsDTO).execute()
if (response.isSuccessful) {
Log.d(TAG, "SMS status updated successfully - ID: ${smsDTO.smsId}, Status: ${smsDTO.status}")
Result.success()
} else {
Log.e(TAG, "Failed to update SMS status. Response code: ${response.code()}")
Result.retry()
}
} catch (e: IOException) {
Log.e(TAG, "API call failed: ${e.message}")
Result.retry()
}
}
}
@@ -0,0 +1,99 @@
package com.vernu.sms.workers
import android.content.Context
import android.util.Log
import androidx.work.*
import com.vernu.sms.AppConstants
import com.vernu.sms.TextBeeUtils
import com.vernu.sms.helpers.SMSHelper
import com.vernu.sms.helpers.SharedPreferenceHelper
class SmsSendWorker(context: Context, workerParams: WorkerParameters) : Worker(context, workerParams) {
companion object {
private const val TAG = "SmsSendWorker"
private const val QUEUE_NAME = "sms_send_queue"
const val KEY_PHONE = "phone"
const val KEY_MESSAGE = "message"
const val KEY_SMS_ID = "sms_id"
const val KEY_SMS_BATCH_ID = "sms_batch_id"
const val KEY_SIM_SUBSCRIPTION_ID = "sim_subscription_id"
fun enqueue(
context: Context, phone: String, message: String,
smsId: String?, smsBatchId: String?, simSubscriptionId: Int?
) {
val inputData = Data.Builder()
.putString(KEY_PHONE, phone)
.putString(KEY_MESSAGE, message)
.putString(KEY_SMS_ID, smsId)
.putString(KEY_SMS_BATCH_ID, smsBatchId)
.putInt(KEY_SIM_SUBSCRIPTION_ID, simSubscriptionId ?: -1)
.build()
val workRequest = OneTimeWorkRequest.Builder(SmsSendWorker::class.java)
.setInputData(inputData)
.build()
WorkManager.getInstance(context)
.beginUniqueWork(QUEUE_NAME, ExistingWorkPolicy.APPEND_OR_REPLACE, workRequest)
.enqueue()
Log.d(TAG, "SMS enqueued for sending - ID: $smsId, Phone: $phone")
}
}
override fun doWork(): Result {
val phone = inputData.getString(KEY_PHONE)
val message = inputData.getString(KEY_MESSAGE)
val smsId = inputData.getString(KEY_SMS_ID)
val smsBatchId = inputData.getString(KEY_SMS_BATCH_ID)
val simSubscriptionId = inputData.getInt(KEY_SIM_SUBSCRIPTION_ID, -1)
if (phone == null || message == null || smsId == null) {
Log.e(TAG, "Missing required parameters")
return Result.failure()
}
val context = applicationContext
val resolvedSim = resolveSim(context, simSubscriptionId)
if (resolvedSim != null) {
SMSHelper.sendSMSFromSpecificSim(phone, message, resolvedSim, smsId, smsBatchId ?: "", context)
} else {
SMSHelper.sendSMS(phone, message, smsId, smsBatchId ?: "", context)
}
val delaySeconds = SharedPreferenceHelper.getSharedPreferenceInt(
context, AppConstants.SHARED_PREFS_SMS_SEND_DELAY_SECONDS_KEY,
AppConstants.DEFAULT_SMS_SEND_DELAY_SECONDS
).coerceIn(0, 3600)
if (delaySeconds > 0) {
try {
Thread.sleep(delaySeconds * 1000L)
} catch (e: InterruptedException) {
Thread.currentThread().interrupt()
}
}
return Result.success()
}
private fun resolveSim(context: Context, backendSimId: Int): Int? {
if (backendSimId != -1 && TextBeeUtils.isValidSubscriptionId(context, backendSimId)) {
Log.d(TAG, "Using backend-provided SIM subscription ID: $backendSimId")
return backendSimId
}
val preferredSim = SharedPreferenceHelper.getSharedPreferenceInt(
context, AppConstants.SHARED_PREFS_PREFERRED_SIM_KEY, -1
)
if (preferredSim != -1 && TextBeeUtils.isValidSubscriptionId(context, preferredSim)) {
Log.d(TAG, "Using app-preferred SIM subscription ID: $preferredSim")
return preferredSim
}
return null
}
}
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:color="?attr/colorPrimary" android:state_enabled="true"/>
<item android:color="?attr/colorPrimary" android:alpha="0.12"/>
</selector>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:color="@color/white" android:state_enabled="true"/>
<item android:color="@color/white" android:alpha="0.38"/>
</selector>
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:state_checked="true" android:color="?attr/colorPrimary" />
<item android:state_enabled="false" android:color="@color/text_secondary" />
<item android:color="@color/text_primary" />
</selector>
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:state_checked="true" android:color="?attr/colorPrimary" />
<item android:state_enabled="false" android:color="@color/text_secondary" />
<item android:color="@color/text_secondary" />
</selector>

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