From 90dc0df53cc4bea6dd6429c45d90aedc7e7942a9 Mon Sep 17 00:00:00 2001 From: Acho Arnold Ewin Date: Tue, 7 Jul 2026 10:38:46 +0300 Subject: [PATCH 01/45] feat: add logging for lemonsqueezy --- api/pkg/services/lemonsqueezy_service.go | 1 + 1 file changed, 1 insertion(+) diff --git a/api/pkg/services/lemonsqueezy_service.go b/api/pkg/services/lemonsqueezy_service.go index 96161eda3..5506cbe99 100644 --- a/api/pkg/services/lemonsqueezy_service.go +++ b/api/pkg/services/lemonsqueezy_service.go @@ -237,5 +237,6 @@ func (service *LemonsqueezyService) subscriptionName(variant string) entities.Su } } + service.logger.Warn(stacktrace.NewError(fmt.Sprintf("unknown subscription variant [%s], defaulting to free", variant))) return entities.SubscriptionNameFree } From 075f2b06a5989b34131397c138ffb6a9f8170102 Mon Sep 17 00:00:00 2001 From: Acho Arnold Date: Wed, 8 Jul 2026 12:25:09 +0300 Subject: [PATCH 02/45] feat: add sent/received breakdown and billing period to usage emails (#944) * feat: add sent/received breakdown and billing period to usage emails The usage limit alert and limit exceeded emails now include a breakdown of sent vs received messages and the current billing period (e.g. 19 June 2026 to 19 July 2026) so users have clearer context on their usage. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix: address usage email review feedback Format billing period dates in UTC to match how billing cycle boundaries are computed, and change IsEntitled to <= limit so users get the full message allowance and the exceeded email total matches the stated limit. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix: render billing dates in the user's timezone Format the billing period using the user's configured timezone via user.Location() instead of UTC, so dates match what the user sees. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * style: fix logging message * test: cover usage email breakdown and entitlement boundary Add tests for BillingUsage.IsEntitled at the exact limit boundary, and for the usage limit emails' sent/received breakdown, billing period rendering in the user's timezone, and formatBillingDate timezone conversion. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix: fix email subject --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- api/pkg/emails/hermes_user_email_factory.go | 14 +++- .../emails/hermes_user_email_factory_test.go | 83 +++++++++++++++++++ api/pkg/emails/user_email_factory.go | 2 +- api/pkg/entities/billing_usage.go | 4 +- api/pkg/entities/billing_usage_test.go | 40 +++++++++ api/pkg/services/billing_service.go | 14 ++-- .../services/phone_notification_service.go | 2 +- 7 files changed, 144 insertions(+), 15 deletions(-) create mode 100644 api/pkg/emails/hermes_user_email_factory_test.go create mode 100644 api/pkg/entities/billing_usage_test.go diff --git a/api/pkg/emails/hermes_user_email_factory.go b/api/pkg/emails/hermes_user_email_factory.go index 9ec5754af..cae4a3b9a 100644 --- a/api/pkg/emails/hermes_user_email_factory.go +++ b/api/pkg/emails/hermes_user_email_factory.go @@ -15,6 +15,11 @@ type hermesUserEmailFactory struct { generator hermes.Hermes } +// formatBillingDate renders a date like "19 June 2026" in the user's timezone. +func formatBillingDate(t time.Time, location *time.Location) string { + return t.In(location).Format("2 January 2006") +} + func (factory *hermesUserEmailFactory) APIKeyRotated(emailAddress string, timestamp time.Time, timezone string) (*Email, error) { location, err := time.LoadLocation(timezone) if err != nil { @@ -64,11 +69,12 @@ func (factory *hermesUserEmailFactory) APIKeyRotated(emailAddress string, timest } // UsageLimitExceeded is the email sent when the plan limit is reached -func (factory *hermesUserEmailFactory) UsageLimitExceeded(user *entities.User) (*Email, error) { +func (factory *hermesUserEmailFactory) UsageLimitExceeded(user *entities.User, usage *entities.BillingUsage) (*Email, error) { email := hermes.Email{ Body: hermes.Body{ Intros: []string{ - fmt.Sprintf("You have exceeded your limit of %d messages on your %s plan.", user.SubscriptionName.Limit(), user.SubscriptionName), + fmt.Sprintf("You've reached your limit of %d messages on the %s plan, so new messages will not be processed until your usage resets.", user.SubscriptionName.Limit(), user.SubscriptionName), + fmt.Sprintf("Between %s and %s you sent %d messages and received %d, for a total of %d.", formatBillingDate(usage.StartTimestamp, user.Location()), formatBillingDate(usage.EndTimestamp, user.Location()), usage.SentMessages, usage.ReceivedMessages, usage.TotalMessages()), }, Actions: []hermes.Action{ { @@ -113,8 +119,8 @@ func (factory *hermesUserEmailFactory) UsageLimitAlert(user *entities.User, usag email := hermes.Email{ Body: hermes.Body{ Intros: []string{ - fmt.Sprintf("This is a friendly notification that you have exceeded %d%% of your monthly SMS limit on the %s plan.", percent, user.SubscriptionName), - fmt.Sprintf("You have sent %d messages and received %d messages using httpSMS this month.", usage.SentMessages, usage.ReceivedMessages), + fmt.Sprintf("This is a friendly heads-up that you've used %d%% of your monthly SMS limit on the %s plan.", percent, user.SubscriptionName), + fmt.Sprintf("Between %s and %s you sent %d messages and received %d, for a total of %d out of your %d message limit.", formatBillingDate(usage.StartTimestamp, user.Location()), formatBillingDate(usage.EndTimestamp, user.Location()), usage.SentMessages, usage.ReceivedMessages, usage.TotalMessages(), user.SubscriptionName.Limit()), }, Actions: []hermes.Action{ { diff --git a/api/pkg/emails/hermes_user_email_factory_test.go b/api/pkg/emails/hermes_user_email_factory_test.go new file mode 100644 index 000000000..df322e926 --- /dev/null +++ b/api/pkg/emails/hermes_user_email_factory_test.go @@ -0,0 +1,83 @@ +package emails + +import ( + "testing" + "time" + + "github.com/NdoleStudio/httpsms/pkg/entities" + "github.com/stretchr/testify/assert" +) + +func testUserEmailFactory() UserEmailFactory { + return NewHermesUserEmailFactory(&HermesGeneratorConfig{ + AppURL: "https://httpsms.com", + AppName: "httpSMS", + AppLogoURL: "https://httpsms.com/logo.png", + }) +} + +func TestFormatBillingDate_RendersInProvidedTimezone(t *testing.T) { + // 2026-06-19 02:00 UTC + timestamp := time.Date(2026, 6, 19, 2, 0, 0, 0, time.UTC) + + // A timezone five hours behind UTC rolls back to the previous day. + behind := time.FixedZone("UTC-5", -5*60*60) + assert.Equal(t, "18 June 2026", formatBillingDate(timestamp, behind)) + + // A timezone ahead of UTC stays on the same day. + ahead := time.FixedZone("UTC+10", 10*60*60) + assert.Equal(t, "19 June 2026", formatBillingDate(timestamp, ahead)) + + // UTC renders the underlying date as-is. + assert.Equal(t, "19 June 2026", formatBillingDate(timestamp, time.UTC)) +} + +func TestUsageLimitExceeded_IncludesBreakdownAndBillingPeriod(t *testing.T) { + factory := testUserEmailFactory() + user := &entities.User{ + Email: "name@email.com", + Timezone: "UTC", + SubscriptionName: entities.SubscriptionNameProMonthly, + } + usage := &entities.BillingUsage{ + SentMessages: 3000, + ReceivedMessages: 2000, + StartTimestamp: time.Date(2026, 6, 19, 0, 0, 0, 0, time.UTC), + EndTimestamp: time.Date(2026, 7, 18, 23, 59, 59, 0, time.UTC), + } + + email, err := factory.UsageLimitExceeded(user, usage) + + assert.NoError(t, err) + assert.Equal(t, "name@email.com", email.ToEmail) + assert.Equal(t, "⚠️ You have exceeded your plan limit", email.Subject) + assert.Contains(t, email.Text, "limit of 5000 messages") + assert.Contains(t, email.Text, "Between 19 June 2026 and 18 July 2026") + assert.Contains(t, email.Text, "you sent 3000 messages and received 2000") + assert.Contains(t, email.Text, "for a total of 5000") +} + +func TestUsageLimitAlert_IncludesPercentBreakdownAndLimit(t *testing.T) { + factory := testUserEmailFactory() + user := &entities.User{ + Email: "name@email.com", + Timezone: "UTC", + SubscriptionName: entities.SubscriptionNameProMonthly, + } + usage := &entities.BillingUsage{ + SentMessages: 2500, + ReceivedMessages: 1500, + StartTimestamp: time.Date(2026, 6, 19, 0, 0, 0, 0, time.UTC), + EndTimestamp: time.Date(2026, 7, 18, 23, 59, 59, 0, time.UTC), + } + + email, err := factory.UsageLimitAlert(user, usage) + + assert.NoError(t, err) + assert.Equal(t, "name@email.com", email.ToEmail) + assert.Equal(t, "⚠️ 80% Usage Limit Alert", email.Subject) + assert.Contains(t, email.Text, "used 80% of your monthly SMS limit") + assert.Contains(t, email.Text, "Between 19 June 2026 and 18 July 2026") + assert.Contains(t, email.Text, "you sent 2500 messages and received 1500") + assert.Contains(t, email.Text, "for a total of 4000 out of your 5000 message limit") +} diff --git a/api/pkg/emails/user_email_factory.go b/api/pkg/emails/user_email_factory.go index a8c11d7a9..07e60a877 100644 --- a/api/pkg/emails/user_email_factory.go +++ b/api/pkg/emails/user_email_factory.go @@ -12,7 +12,7 @@ type UserEmailFactory interface { PhoneDead(user *entities.User, lastHeartbeatTimestamp time.Time, owner string) (*Email, error) // UsageLimitExceeded sends an email when the user's limit is exceeded - UsageLimitExceeded(user *entities.User) (*Email, error) + UsageLimitExceeded(user *entities.User, usage *entities.BillingUsage) (*Email, error) // UsageLimitAlert sends an email when a user is approaching the limit UsageLimitAlert(user *entities.User, usage *entities.BillingUsage) (*Email, error) diff --git a/api/pkg/entities/billing_usage.go b/api/pkg/entities/billing_usage.go index 5c5c67d7e..8c9852b26 100644 --- a/api/pkg/entities/billing_usage.go +++ b/api/pkg/entities/billing_usage.go @@ -24,7 +24,7 @@ func (usage *BillingUsage) TotalMessages() uint { return usage.SentMessages + usage.ReceivedMessages } -// IsEntitled checks if a user can send `count` messages +// IsEntitled checks if a user can send `count` messages without exceeding `limit` func (usage *BillingUsage) IsEntitled(count, limit uint) bool { - return (usage.TotalMessages() + count) < limit + return (usage.TotalMessages() + count) <= limit } diff --git a/api/pkg/entities/billing_usage_test.go b/api/pkg/entities/billing_usage_test.go new file mode 100644 index 000000000..fa981750a --- /dev/null +++ b/api/pkg/entities/billing_usage_test.go @@ -0,0 +1,40 @@ +package entities + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestBillingUsage_TotalMessages(t *testing.T) { + usage := BillingUsage{SentMessages: 321, ReceivedMessages: 465} + assert.Equal(t, uint(786), usage.TotalMessages()) +} + +func TestBillingUsage_IsEntitled_BelowLimit(t *testing.T) { + usage := BillingUsage{SentMessages: 100, ReceivedMessages: 100} + assert.True(t, usage.IsEntitled(1, 500)) +} + +func TestBillingUsage_IsEntitled_ReachingExactlyLimitIsEntitled(t *testing.T) { + // total is one below the limit, sending one more brings the total to + // exactly the limit, which should still be allowed. + usage := BillingUsage{SentMessages: 300, ReceivedMessages: 199} + assert.True(t, usage.IsEntitled(1, 500)) +} + +func TestBillingUsage_IsEntitled_ExceedingLimitIsNotEntitled(t *testing.T) { + // total already equals the limit, sending one more would exceed it. + usage := BillingUsage{SentMessages: 300, ReceivedMessages: 200} + assert.False(t, usage.IsEntitled(1, 500)) +} + +func TestBillingUsage_IsEntitled_BulkCountFittingExactly(t *testing.T) { + usage := BillingUsage{SentMessages: 250, ReceivedMessages: 248} + assert.True(t, usage.IsEntitled(2, 500)) +} + +func TestBillingUsage_IsEntitled_BulkCountExceeding(t *testing.T) { + usage := BillingUsage{SentMessages: 250, ReceivedMessages: 248} + assert.False(t, usage.IsEntitled(3, 500)) +} diff --git a/api/pkg/services/billing_service.go b/api/pkg/services/billing_service.go index 1d573dbd8..1a2f7da5e 100644 --- a/api/pkg/services/billing_service.go +++ b/api/pkg/services/billing_service.go @@ -55,20 +55,20 @@ func (service *BillingService) IsEntitledWithCount(ctx context.Context, userID e user, err := service.userRepository.Load(ctx, userID) if err != nil { - msg := fmt.Sprintf("cannot load user with ID [%s], entitlement successfull", userID) + msg := fmt.Sprintf("cannot load user with ID [%s], entitlement successful", userID) ctxLogger.Error(service.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, msg))) return nil } usage, err := service.billingUsageRepository.GetCurrent(ctx, userID) if err != nil { - msg := fmt.Sprintf("cannot load billing usage for user with ID [%s], entitlement successfull", userID) + msg := fmt.Sprintf("cannot load billing usage for user with ID [%s], entitlement successful", userID) ctxLogger.Error(service.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, msg))) return nil } if !usage.IsEntitled(count, user.SubscriptionName.Limit()) { - return service.handleLimitExceeded(ctx, user) + return service.handleLimitExceeded(ctx, user, usage) } return nil @@ -79,11 +79,11 @@ func (service *BillingService) IsEntitled(ctx context.Context, userID entities.U return service.IsEntitledWithCount(ctx, userID, 1) } -func (service *BillingService) handleLimitExceeded(ctx context.Context, user *entities.User) *string { +func (service *BillingService) handleLimitExceeded(ctx context.Context, user *entities.User, usage *entities.BillingUsage) *string { ctx, span := service.tracer.Start(ctx) defer span.End() - service.sendLimitExceededEmail(ctx, user) + service.sendLimitExceededEmail(ctx, user, usage) message := fmt.Sprintf( "You have exceeded your limit of [%d] messages on your [%s] plan. Upgrade to send more messages on https://httpsms.com/billing", @@ -93,7 +93,7 @@ func (service *BillingService) handleLimitExceeded(ctx context.Context, user *en return &message } -func (service *BillingService) sendLimitExceededEmail(ctx context.Context, user *entities.User) { +func (service *BillingService) sendLimitExceededEmail(ctx context.Context, user *entities.User, usage *entities.BillingUsage) { ctx, span, ctxLogger := service.tracer.StartWithLogger(ctx, service.logger) defer span.End() @@ -102,7 +102,7 @@ func (service *BillingService) sendLimitExceededEmail(ctx context.Context, user return } - email, err := service.emailFactory.UsageLimitExceeded(user) + email, err := service.emailFactory.UsageLimitExceeded(user, usage) if err != nil { ctxLogger.Error(stacktrace.Propagate(err, fmt.Sprintf("cannot create usage limit email for user [%s]", user.ID))) return diff --git a/api/pkg/services/phone_notification_service.go b/api/pkg/services/phone_notification_service.go index 79b430378..3ccca9aa2 100644 --- a/api/pkg/services/phone_notification_service.go +++ b/api/pkg/services/phone_notification_service.go @@ -167,7 +167,7 @@ func (service *PhoneNotificationService) Send(ctx context.Context, params *Phone params.MessageID, ), )) - msg := fmt.Sprintf("cannot send notification for to your phone [%s]. Reinstall the httpSMS app on your Android phone.", phone.PhoneNumber) + msg := fmt.Sprintf("cannot send notification to your phone [%s]. Reinstall the httpSMS app on your Android phone.", phone.PhoneNumber) return service.handleNotificationFailed(ctx, errors.New(msg), params) } From 301625e8afe4ad4583dd9f7dc572afd17f5b4053 Mon Sep 17 00:00:00 2001 From: Acho Arnold Ewin Date: Wed, 8 Jul 2026 21:48:49 +0300 Subject: [PATCH 03/45] fix: fix error when deleting user accounts --- web/app/components/BackButton.vue | 2 +- web/app/pages/settings/index.vue | 13 +++++++++---- web/app/stores/auth.ts | 4 ++-- 3 files changed, 12 insertions(+), 7 deletions(-) diff --git a/web/app/components/BackButton.vue b/web/app/components/BackButton.vue index 9dc1b888c..94ca0f625 100644 --- a/web/app/components/BackButton.vue +++ b/web/app/components/BackButton.vue @@ -37,7 +37,7 @@ function goBack() { :block="block" @click="goBack" > - + Go Back diff --git a/web/app/pages/settings/index.vue b/web/app/pages/settings/index.vue index 988f0497a..17e35c7c6 100644 --- a/web/app/pages/settings/index.vue +++ b/web/app/pages/settings/index.vue @@ -780,7 +780,8 @@ async function deleteUserAccount() { await router.push({ name: 'index' }) } catch { notificationsStore.addNotification({ - message: 'Failed to delete your account', + message: + 'We ran into an internal error while deleteing your account please contact us.', type: 'error', }) } finally { @@ -965,10 +966,10 @@ onMounted(async () => { - Are you sure you want to rotate your API Key? - + You will have to logout and login again on the httpSMS Android app with your new API key after you rotate it. @@ -976,6 +977,7 @@ onMounted(async () => { @@ -983,7 +985,10 @@ onMounted(async () => { Yes Rotate Key - Close diff --git a/web/app/stores/auth.ts b/web/app/stores/auth.ts index 7d8e17ae2..53c0546d0 100644 --- a/web/app/stores/auth.ts +++ b/web/app/stores/auth.ts @@ -74,10 +74,10 @@ export const useAuthStore = defineStore('auth', () => { } async function deleteUserAccount(): Promise { - const response = await apiFetch<{ message: string }>('/v1/users/me', { + await apiFetch<{ message: string }>('/v1/users/me', { method: 'DELETE', }) - return response.message + return 'Your account has been deleted successfully' } async function rotateApiKey(userId: string): Promise { From cb7b20d88674fbde72b2d718a3b844b4801ff089 Mon Sep 17 00:00:00 2001 From: Acho Arnold Ewin Date: Thu, 9 Jul 2026 19:35:32 +0300 Subject: [PATCH 04/45] fix: fix error when downloading invoice --- api/pkg/handlers/user_handler.go | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/api/pkg/handlers/user_handler.go b/api/pkg/handlers/user_handler.go index 9b73f742a..2487d4a31 100644 --- a/api/pkg/handlers/user_handler.go +++ b/api/pkg/handlers/user_handler.go @@ -2,6 +2,7 @@ package handlers import ( "fmt" + "io" "github.com/NdoleStudio/httpsms/pkg/requests" "github.com/NdoleStudio/httpsms/pkg/validators" @@ -333,7 +334,7 @@ func (h *UserHandler) subscriptionInvoice(c fiber.Ctx) error { return h.responseUnprocessableEntity(c, errors, "validation errors while generating payment invoice") } - data, err := h.service.GenerateReceipt(ctx, request.UserInvoiceGenerateParams(h.userIDFomContext(c))) + reader, err := h.service.GenerateReceipt(ctx, request.UserInvoiceGenerateParams(h.userIDFomContext(c))) if err != nil { msg := fmt.Sprintf("cannot generate receipt for invoice ID [%s] and user [%s]", request.SubscriptionInvoiceID, h.userFromContext(c)) ctxLogger.Error(stacktrace.Propagate(err, msg)) @@ -343,5 +344,12 @@ func (h *UserHandler) subscriptionInvoice(c fiber.Ctx) error { c.Set(fiber.HeaderContentType, "application/pdf") c.Set(fiber.HeaderContentDisposition, fmt.Sprintf("attachment; filename=\"httpsms.com - %s.pdf\"", request.SubscriptionInvoiceID)) - return c.SendStream(data) + data, err := io.ReadAll(reader) + if err != nil { + msg := fmt.Sprintf("cannot read invoice data with ID [%s] for user with ID [%s]", request.SubscriptionInvoiceID, h.userIDFomContext(c)) + ctxLogger.Error(stacktrace.Propagate(err, msg)) + return h.responseInternalServerError(c) + } + + return c.Send(data) } From 7dbb193c14a0ac9f94bbd2e256973fca14df8841 Mon Sep 17 00:00:00 2001 From: Acho Arnold Ewin Date: Thu, 9 Jul 2026 23:19:22 +0300 Subject: [PATCH 05/45] fix: bug with paypal user update when variant is empty --- api/pkg/services/lemonsqueezy_service.go | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/api/pkg/services/lemonsqueezy_service.go b/api/pkg/services/lemonsqueezy_service.go index 5506cbe99..cf4d4ad25 100644 --- a/api/pkg/services/lemonsqueezy_service.go +++ b/api/pkg/services/lemonsqueezy_service.go @@ -132,6 +132,11 @@ func (service *LemonsqueezyService) HandleSubscriptionUpdatedEvent(ctx context.C ctx, span, ctxLogger := service.tracer.StartWithLogger(ctx, service.logger) defer span.End() + if request.Data.Attributes.VariantName == "" && request.Data.Attributes.Status == "active" { + ctxLogger.Info(fmt.Sprintf("skipping update of subscription [%s] for user [%s] because variant name is empty", request.Data.ID, request.Data.Attributes.UserEmail)) + return nil + } + user, err := service.userRepository.LoadBySubscriptionID(ctx, request.Data.ID) if err != nil { msg := fmt.Sprintf("cannot load user with subscription ID [%s]", request.Data.ID) From cd16f8b3063f14ebc4f70a19351361a35bfc80e7 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 10 Jul 2026 21:01:41 +0000 Subject: [PATCH 06/45] fix(deps): bump golang.org/x/crypto from 0.51.0 to 0.52.0 in /tests (#945) Bumps [golang.org/x/crypto](https://github.com/golang/crypto) from 0.51.0 to 0.52.0. - [Commits](https://github.com/golang/crypto/compare/v0.51.0...v0.52.0) --- updated-dependencies: - dependency-name: golang.org/x/crypto dependency-version: 0.52.0 dependency-type: indirect ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- tests/go.mod | 2 +- tests/go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/go.mod b/tests/go.mod index d803b8b4a..989f4c5b7 100644 --- a/tests/go.mod +++ b/tests/go.mod @@ -19,7 +19,7 @@ require ( github.com/tiendc/go-deepcopy v1.7.2 // indirect github.com/xuri/efp v0.0.1 // indirect github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9 // indirect - golang.org/x/crypto v0.51.0 // indirect + golang.org/x/crypto v0.52.0 // indirect golang.org/x/net v0.55.0 // indirect golang.org/x/text v0.37.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect diff --git a/tests/go.sum b/tests/go.sum index 4c85f1c30..439148658 100644 --- a/tests/go.sum +++ b/tests/go.sum @@ -106,8 +106,8 @@ go.opentelemetry.io/otel/metric v1.19.0 h1:aTzpGtV0ar9wlV4Sna9sdJyII5jTVJEvKETPi go.opentelemetry.io/otel/metric v1.19.0/go.mod h1:L5rUsV9kM1IxCj1MmSdS+JQAcVm319EUrDVLrt7jqt8= go.opentelemetry.io/otel/trace v1.19.0 h1:DFVQmlVbfVeOuBRrwdtaehRrWiL1JoVs9CPIQ1Dzxpg= go.opentelemetry.io/otel/trace v1.19.0/go.mod h1:mfaSyvGyEJEI0nyV2I4qhNQnbBOUUmYZpYojqMnX2vo= -golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI= -golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8= +golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988= +golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc= golang.org/x/exp v0.0.0-20230510235704-dd950f8aeaea h1:vLCWI/yYrdEHyN2JzIzPO3aaQJHQdp89IZBA/+azVC4= golang.org/x/exp v0.0.0-20230510235704-dd950f8aeaea/go.mod h1:V1LtkGg67GoY2N1AnLN78QLrzxkLyJw7RJb1gzOOz9w= golang.org/x/image v0.25.0 h1:Y6uW6rH1y5y/LK1J8BPWZtr6yZ7hrsy6hFrXjgsc2fQ= From 0d1faaca65bcd531a6e1c20e368052f66691e6e2 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 10 Jul 2026 21:14:05 +0000 Subject: [PATCH 07/45] fix(deps): bump github.com/xuri/excelize/v2 in /api (#947) Bumps [github.com/xuri/excelize/v2](https://github.com/xuri/excelize) from 2.10.1 to 2.11.0. - [Release notes](https://github.com/xuri/excelize/releases) - [Commits](https://github.com/xuri/excelize/compare/v2.10.1...v2.11.0) --- updated-dependencies: - dependency-name: github.com/xuri/excelize/v2 dependency-version: 2.11.0 dependency-type: direct:production ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- api/go.mod | 2 +- api/go.sum | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/api/go.mod b/api/go.mod index bba0b2db1..565e15d4a 100644 --- a/api/go.mod +++ b/api/go.mod @@ -46,7 +46,7 @@ require ( github.com/swaggo/swag v1.16.6 github.com/thedevsaddam/govalidator v1.9.10 github.com/uptrace/uptrace-go v1.43.0 - github.com/xuri/excelize/v2 v2.10.1 + github.com/xuri/excelize/v2 v2.11.0 go.mongodb.org/mongo-driver/v2 v2.7.0 go.opentelemetry.io/contrib/instrumentation/go.mongodb.org/mongo-driver/v2/mongo/otelmongo v0.0.0-20260624193928-df9c7a836708 go.opentelemetry.io/otel v1.44.0 diff --git a/api/go.sum b/api/go.sum index 61ed6dd88..dc7298013 100644 --- a/api/go.sum +++ b/api/go.sum @@ -368,8 +368,8 @@ github.com/xdg-go/stringprep v1.0.4 h1:XLI/Ng3O1Atzq0oBs3TWm+5ZVgkq2aqdlvP9JtoZ6 github.com/xdg-go/stringprep v1.0.4/go.mod h1:mPGuuIYwz7CmR2bT9j4GbQqutWS1zV24gijq1dTyGkM= github.com/xuri/efp v0.0.1 h1:fws5Rv3myXyYni8uwj2qKjVaRP30PdjeYe2Y6FDsCL8= github.com/xuri/efp v0.0.1/go.mod h1:ybY/Jr0T0GTCnYjKqmdwxyxn2BQf2RcQIIvex5QldPI= -github.com/xuri/excelize/v2 v2.10.1 h1:V62UlqopMqha3kOpnlHy2CcRVw1V8E63jFoWUmMzxN0= -github.com/xuri/excelize/v2 v2.10.1/go.mod h1:iG5tARpgaEeIhTqt3/fgXCGoBRt4hNXgCp3tfXKoOIc= +github.com/xuri/excelize/v2 v2.11.0 h1:HxaEFl6sRN2+8J5a8HaKq+0M4FsjBGMnWWtjOCPSG88= +github.com/xuri/excelize/v2 v2.11.0/go.mod h1:jxFLbzaIwGQ5ufFNvYfUOHqXhfPaNmP14KWfmNz2Uak= github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9 h1:+C0TIdyyYmzadGaL/HBLbf3WdLgC29pgyhTjAT/0nuE= github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9/go.mod h1:WwHg+CVyzlv/TX9xqBFXEZAuxOPxn2k1GNHwG41IIUQ= github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU= @@ -446,8 +446,8 @@ golang.org/x/crypto v0.0.0-20200709230013-948cd5f35899/go.mod h1:LzIPMQfyMNhhGPh golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto= golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio= -golang.org/x/image v0.25.0 h1:Y6uW6rH1y5y/LK1J8BPWZtr6yZ7hrsy6hFrXjgsc2fQ= -golang.org/x/image v0.25.0/go.mod h1:tCAmOEGthTtkalusGp1g3xa2gke8J6c2N565dTyl9Rs= +golang.org/x/image v0.38.0 h1:5l+q+Y9JDC7mBOMjo4/aPhMDcxEptsX+Tt3GgRQRPuE= +golang.org/x/image v0.38.0/go.mod h1:/3f6vaXC+6CEanU4KJxbcUZyEePbyKbaLoDOe4ehFYY= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ= golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0= From 617f48a4d27f427d84e81179138d5615108fdbda Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 10 Jul 2026 21:14:22 +0000 Subject: [PATCH 08/45] fix(deps): bump github.com/xuri/excelize/v2 in /tests (#946) Bumps [github.com/xuri/excelize/v2](https://github.com/xuri/excelize) from 2.10.1 to 2.11.0. - [Release notes](https://github.com/xuri/excelize/releases) - [Commits](https://github.com/xuri/excelize/compare/v2.10.1...v2.11.0) --- updated-dependencies: - dependency-name: github.com/xuri/excelize/v2 dependency-version: 2.11.0 dependency-type: direct:production ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- tests/go.mod | 10 +++++----- tests/go.sum | 40 ++++++++++++++++++++-------------------- 2 files changed, 25 insertions(+), 25 deletions(-) diff --git a/tests/go.mod b/tests/go.mod index 989f4c5b7..a213d5238 100644 --- a/tests/go.mod +++ b/tests/go.mod @@ -8,19 +8,19 @@ require ( github.com/google/uuid v1.6.0 github.com/stretchr/testify v1.11.1 github.com/wiremock/go-wiremock v1.14.0 - github.com/xuri/excelize/v2 v2.10.1 + github.com/xuri/excelize/v2 v2.11.0 ) require ( github.com/davecgh/go-spew v1.1.1 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect - github.com/richardlehane/mscfb v1.0.6 // indirect + github.com/richardlehane/mscfb v1.0.7 // indirect github.com/richardlehane/msoleps v1.0.6 // indirect github.com/tiendc/go-deepcopy v1.7.2 // indirect github.com/xuri/efp v0.0.1 // indirect github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9 // indirect - golang.org/x/crypto v0.52.0 // indirect - golang.org/x/net v0.55.0 // indirect - golang.org/x/text v0.37.0 // indirect + golang.org/x/crypto v0.53.0 // indirect + golang.org/x/net v0.56.0 // indirect + golang.org/x/text v0.38.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/tests/go.sum b/tests/go.sum index 439148658..089f085cc 100644 --- a/tests/go.sum +++ b/tests/go.sum @@ -68,8 +68,8 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c h1:ncq/mPwQF4JjgDlrVEn3C11VoGHZN7m8qihwgMEtzYw= github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE= -github.com/richardlehane/mscfb v1.0.6 h1:eN3bvvZCp00bs7Zf52bxNwAx5lJDBK1tCuH19qq5aC8= -github.com/richardlehane/mscfb v1.0.6/go.mod h1:pe0+IUIc0AHh0+teNzBlJCtSyZdFOGgV4ZK9bsoV+Jo= +github.com/richardlehane/mscfb v1.0.7 h1:oeoiM0WE79vHwE8RpIYYvIAc8ajTH2mb6UZm55/+EB0= +github.com/richardlehane/mscfb v1.0.7/go.mod h1:pe0+IUIc0AHh0+teNzBlJCtSyZdFOGgV4ZK9bsoV+Jo= github.com/richardlehane/msoleps v1.0.6 h1:9BvkpjvD+iUBalUY4esMwv6uBkfOip/Lzvd93jvR9gg= github.com/richardlehane/msoleps v1.0.6/go.mod h1:BWev5JBpU9Ko2WAgmZEuiz4/u3ZYTKbjLycmwiWUfWg= github.com/shirou/gopsutil/v3 v3.23.12 h1:z90NtUkp3bMtmICZKpC4+WaknU1eXtp5vtbQ11DgpE4= @@ -92,8 +92,8 @@ github.com/wiremock/go-wiremock v1.14.0 h1:cVAV98Odg+hySEYKDRUasVo30q7JE/ysrdx5q github.com/wiremock/go-wiremock v1.14.0/go.mod h1:T5XkKnsKS2asycbUrk2cpxXTEXwa6klHfCWVN8BkhkU= github.com/xuri/efp v0.0.1 h1:fws5Rv3myXyYni8uwj2qKjVaRP30PdjeYe2Y6FDsCL8= github.com/xuri/efp v0.0.1/go.mod h1:ybY/Jr0T0GTCnYjKqmdwxyxn2BQf2RcQIIvex5QldPI= -github.com/xuri/excelize/v2 v2.10.1 h1:V62UlqopMqha3kOpnlHy2CcRVw1V8E63jFoWUmMzxN0= -github.com/xuri/excelize/v2 v2.10.1/go.mod h1:iG5tARpgaEeIhTqt3/fgXCGoBRt4hNXgCp3tfXKoOIc= +github.com/xuri/excelize/v2 v2.11.0 h1:HxaEFl6sRN2+8J5a8HaKq+0M4FsjBGMnWWtjOCPSG88= +github.com/xuri/excelize/v2 v2.11.0/go.mod h1:jxFLbzaIwGQ5ufFNvYfUOHqXhfPaNmP14KWfmNz2Uak= github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9 h1:+C0TIdyyYmzadGaL/HBLbf3WdLgC29pgyhTjAT/0nuE= github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9/go.mod h1:WwHg+CVyzlv/TX9xqBFXEZAuxOPxn2k1GNHwG41IIUQ= github.com/yusufpapurcu/wmi v1.2.3 h1:E1ctvB7uKFMOJw3fdOW32DwGE9I7t++CRUEMKvFoFiw= @@ -106,24 +106,24 @@ go.opentelemetry.io/otel/metric v1.19.0 h1:aTzpGtV0ar9wlV4Sna9sdJyII5jTVJEvKETPi go.opentelemetry.io/otel/metric v1.19.0/go.mod h1:L5rUsV9kM1IxCj1MmSdS+JQAcVm319EUrDVLrt7jqt8= go.opentelemetry.io/otel/trace v1.19.0 h1:DFVQmlVbfVeOuBRrwdtaehRrWiL1JoVs9CPIQ1Dzxpg= go.opentelemetry.io/otel/trace v1.19.0/go.mod h1:mfaSyvGyEJEI0nyV2I4qhNQnbBOUUmYZpYojqMnX2vo= -golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988= -golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc= +golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto= +golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio= golang.org/x/exp v0.0.0-20230510235704-dd950f8aeaea h1:vLCWI/yYrdEHyN2JzIzPO3aaQJHQdp89IZBA/+azVC4= golang.org/x/exp v0.0.0-20230510235704-dd950f8aeaea/go.mod h1:V1LtkGg67GoY2N1AnLN78QLrzxkLyJw7RJb1gzOOz9w= -golang.org/x/image v0.25.0 h1:Y6uW6rH1y5y/LK1J8BPWZtr6yZ7hrsy6hFrXjgsc2fQ= -golang.org/x/image v0.25.0/go.mod h1:tCAmOEGthTtkalusGp1g3xa2gke8J6c2N565dTyl9Rs= -golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM= -golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU= -golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8= -golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww= -golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= -golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= -golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= -golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= -golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= -golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= -golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c= -golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI= +golang.org/x/image v0.38.0 h1:5l+q+Y9JDC7mBOMjo4/aPhMDcxEptsX+Tt3GgRQRPuE= +golang.org/x/image v0.38.0/go.mod h1:/3f6vaXC+6CEanU4KJxbcUZyEePbyKbaLoDOe4ehFYY= +golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4= +golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ= +golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o= +golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec= +golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM= +golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= +golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE= +golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4= +golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8= +golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0= google.golang.org/genproto/googleapis/rpc v0.0.0-20230711160842-782d3b101e98 h1:bVf09lpb+OJbByTj913DRJioFFAjf/ZGxEz7MajTp2U= google.golang.org/genproto/googleapis/rpc v0.0.0-20230711160842-782d3b101e98/go.mod h1:TUfxEVdsvPg18p6AslUXFoLdpED4oBnGwyqk3dV1XzM= google.golang.org/grpc v1.58.3 h1:BjnpXut1btbtgN/6sp+brB2Kbm2LjNXnidYujAVbSoQ= From a6ac39319d9890da89ef9a180b19c0738bccdd10 Mon Sep 17 00:00:00 2001 From: Acho Arnold Date: Sat, 11 Jul 2026 13:21:44 +0300 Subject: [PATCH 09/45] feat(android): migrate XML layouts to Jetpack Compose (#943) * feat: migrate XML layouts to Jetpack Compose * fix(theme): correct status bar icon color logic * fix: use painterResource for platform drawables to avoid crash * feat: use proper material design icons * style: increase top margin for logo in main screen * style: increase top loggo padding * style: increase button sizes and icons consistency * style: use normal centered buttons instead of full-width blocks * feat: auto-detect phone numbers on login screen * style: add icons to buttons and improve heartbeat loader width * style: heart icon color matches button content when disabled * style: update settings screen colors and status bar * fix: address PR review comments\n\n- Improved URL validation in LoginViewModel using URLUtil.\n- Added exception handling around URI constructor and network calls.\n- Cleaned up wildcard imports in UI files.\n- Ensured isLoading is reset on error. * fix: address more review comments from Copilot and Greptile * style: set custom status bar color for Android < 12 * style: ensure status bar is white in light mode and matches background in dark mode * style: change top padding to n4 in heartbeats page * chore: upgrade target/compile SDK to 37 and update all dependencies * chore(android): remove unused navigation-compose dependency Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: c63cc85c-be26-4770-8587-c30bcf787997 * style: ensure phone number text color follows theme in MainActivity --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- android/app/build.gradle.kts | 32 +- .../main/java/com/httpsms/LoginActivity.kt | 284 ++++-------------- .../src/main/java/com/httpsms/MainActivity.kt | 225 +++----------- .../main/java/com/httpsms/SettingsActivity.kt | 143 ++------- .../httpsms/ui/login/LoginActivityContent.kt | 217 +++++++++++++ .../com/httpsms/ui/login/LoginViewModel.kt | 221 ++++++++++++++ .../httpsms/ui/main/MainActivityContent.kt | 243 +++++++++++++++ .../java/com/httpsms/ui/main/MainViewModel.kt | 116 +++++++ .../ui/settings/SettingsActivityContent.kt | 221 ++++++++++++++ .../httpsms/ui/settings/SettingsViewModel.kt | 114 +++++++ .../main/java/com/httpsms/ui/theme/Theme.kt | 75 +++++ .../java/com/httpsms/ui/theme/Typography.kt | 17 ++ .../src/main/res/layout/activity_login.xml | 188 ------------ .../app/src/main/res/layout/activity_main.xml | 232 -------------- .../src/main/res/layout/activity_settings.xml | 200 ------------ android/build.gradle.kts | 4 +- web/app/pages/heartbeats/[id].vue | 2 +- 17 files changed, 1369 insertions(+), 1165 deletions(-) create mode 100644 android/app/src/main/java/com/httpsms/ui/login/LoginActivityContent.kt create mode 100644 android/app/src/main/java/com/httpsms/ui/login/LoginViewModel.kt create mode 100644 android/app/src/main/java/com/httpsms/ui/main/MainActivityContent.kt create mode 100644 android/app/src/main/java/com/httpsms/ui/main/MainViewModel.kt create mode 100644 android/app/src/main/java/com/httpsms/ui/settings/SettingsActivityContent.kt create mode 100644 android/app/src/main/java/com/httpsms/ui/settings/SettingsViewModel.kt create mode 100644 android/app/src/main/java/com/httpsms/ui/theme/Theme.kt create mode 100644 android/app/src/main/java/com/httpsms/ui/theme/Typography.kt delete mode 100644 android/app/src/main/res/layout/activity_login.xml delete mode 100644 android/app/src/main/res/layout/activity_main.xml delete mode 100644 android/app/src/main/res/layout/activity_settings.xml diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 15857e708..f670b8bb7 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -1,7 +1,8 @@ plugins { id("com.android.application") id("com.google.gms.google-services") - id("io.sentry.android.gradle") version "6.2.0" + id("io.sentry.android.gradle") version "6.14.0" + id("org.jetbrains.kotlin.plugin.compose") } val gitHash = providers.exec { @@ -9,12 +10,12 @@ val gitHash = providers.exec { }.standardOutput.asText.map { it.trim() } android { - compileSdk = 36 + compileSdk = 37 defaultConfig { applicationId = "com.httpsms" minSdk = 28 - targetSdk = 36 + targetSdk = 37 versionCode = 1 versionName = gitHash.getOrElse("unknown") testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" @@ -38,26 +39,39 @@ android { buildFeatures { buildConfig = true + compose = true } } dependencies { - implementation(platform("com.google.firebase:firebase-bom:34.11.0")) + val composeBom = platform("androidx.compose:compose-bom:2026.06.01") + implementation(composeBom) + androidTestImplementation(composeBom) + + implementation("androidx.compose.ui:ui") + implementation("androidx.compose.ui:ui-graphics") + implementation("androidx.compose.ui:ui-tooling-preview") + implementation("androidx.compose.material3:material3") + implementation("androidx.compose.material:material-icons-extended") + implementation("androidx.activity:activity-compose:1.13.0") + implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.11.0") + + implementation(platform("com.google.firebase:firebase-bom:34.16.0")) implementation("com.journeyapps:zxing-android-embedded:4.3.0") implementation("com.google.firebase:firebase-analytics") implementation("com.google.firebase:firebase-messaging") - implementation("com.squareup.okhttp3:okhttp:5.3.2") + implementation("com.squareup.okhttp3:okhttp:5.4.0") implementation("com.jakewharton.timber:timber:5.0.1") implementation("androidx.preference:preference-ktx:1.2.1") - implementation("androidx.work:work-runtime-ktx:2.11.1") - implementation("androidx.core:core-ktx:1.18.0") + implementation("androidx.work:work-runtime-ktx:2.11.2") + implementation("androidx.core:core-ktx:1.19.0") implementation("androidx.cardview:cardview:1.0.0") implementation("com.beust:klaxon:5.6") implementation("androidx.appcompat:appcompat:1.7.1") implementation("org.apache.commons:commons-text:1.15.0") - implementation("com.google.android.material:material:1.13.0") + implementation("com.google.android.material:material:1.14.0") implementation("androidx.constraintlayout:constraintlayout:2.2.1") - implementation("com.googlecode.libphonenumber:libphonenumber:9.0.26") + implementation("com.googlecode.libphonenumber:libphonenumber:9.0.34") implementation("com.klinkerapps:android-smsmms:5.2.6") testImplementation("junit:junit:4.13.2") androidTestImplementation("androidx.test.ext:junit:1.3.0") diff --git a/android/app/src/main/java/com/httpsms/LoginActivity.kt b/android/app/src/main/java/com/httpsms/LoginActivity.kt index babe12df6..17bd6db66 100644 --- a/android/app/src/main/java/com/httpsms/LoginActivity.kt +++ b/android/app/src/main/java/com/httpsms/LoginActivity.kt @@ -8,56 +8,75 @@ import android.content.pm.PackageManager import android.os.Build import android.os.Bundle import android.telephony.TelephonyManager -import android.view.View -import android.webkit.URLUtil -import android.widget.LinearLayout import android.widget.Toast +import androidx.activity.compose.setContent import androidx.activity.result.contract.ActivityResultContracts +import androidx.activity.viewModels import androidx.appcompat.app.AppCompatActivity +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue import androidx.core.app.ActivityCompat -import androidx.lifecycle.MutableLiveData import com.google.android.gms.common.ConnectionResult import com.google.android.gms.common.GoogleApiAvailability -import com.google.android.material.button.MaterialButton -import com.google.android.material.progressindicator.LinearProgressIndicator -import com.google.android.material.textfield.TextInputEditText -import com.google.android.material.textfield.TextInputLayout -import com.httpsms.validators.PhoneNumberValidator +import com.httpsms.ui.login.LoginScreen +import com.httpsms.ui.login.LoginViewModel +import com.httpsms.ui.theme.HttpSmsTheme import com.journeyapps.barcodescanner.ScanContract import com.journeyapps.barcodescanner.ScanOptions import timber.log.Timber -import java.net.URI - class LoginActivity : AppCompatActivity() { + private val viewModel: LoginViewModel by viewModels() + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) redirectToMain() - setContentView(R.layout.activity_login) - registerListeners() - setPhoneNumber() - disableSim2() - setServerURL() - setupApiKeyInput() - } - - private fun setupApiKeyInput() { - val apiKeyInputLayout = findViewById(R.id.loginApiKeyTextInputLayout) - val apiKeyInput = findViewById(R.id.loginApiKeyTextInput) - - apiKeyInput.setOnClickListener { - startQrCodeScan() - } + + viewModel.initialize(this, getString(R.string.default_server_url)) + + setContent { + HttpSmsTheme { + val uiState by viewModel.uiState.collectAsState() + + LaunchedEffect(uiState.loginSuccess) { + if (uiState.loginSuccess) { + redirectToMain() + } + } - apiKeyInputLayout.setEndIconOnClickListener { - startQrCodeScan() + LoginScreen( + viewModel = viewModel, + onQrScanClick = { startQrCodeScan() }, + onLoginClick = { + val error = isGooglePlayServicesAvailable() + if (error != null) { + Toast.makeText(this@LoginActivity, error, Toast.LENGTH_SHORT).show() + } else { + viewModel.login( + context = this@LoginActivity, + countryCode = getCountryCode(), + onGooglePlayServicesError = { + Toast.makeText(this@LoginActivity, it, Toast.LENGTH_SHORT).show() + }, + onFcmTokenMissing = { + Toast.makeText( + this@LoginActivity, + "Cannot find FCM token. Make sure you have Google Play Services installed", + Toast.LENGTH_LONG + ).show() + } + ) + } + } + ) + } } } private val barcodeLauncher = registerForActivityResult(ScanContract()) { result -> if (result.contents != null) { - val apiKeyInput = findViewById(R.id.loginApiKeyTextInput) - apiKeyInput.setText(result.contents) + viewModel.onApiKeyChange(result.contents) Toast.makeText(this, "Scanned: ${result.contents}", Toast.LENGTH_LONG).show() } else { Toast.makeText(this, "Scan cancelled", Toast.LENGTH_SHORT).show() @@ -79,46 +98,6 @@ class LoginActivity : AppCompatActivity() { requestPermissions() } - override fun onResume() { - super.onResume() - setPhoneNumber() - disableSim2() - } - - private fun registerListeners() { - loginButton().setOnClickListener { onLoginClick() } - } - - private fun disableSim2() { - if (SmsManagerService.isDualSIM(this)) { - Timber.d("dual sim detected") - val sim2Layout = findViewById(R.id.loginPhoneNumberLayoutSIM2) - sim2Layout.visibility = LinearLayout.VISIBLE - return - } - Timber.d("single sim detected") - val sim2Layout = findViewById(R.id.loginPhoneNumberLayoutSIM2) - sim2Layout.visibility = View.GONE - } - - private fun setPhoneNumber() { - val phoneNumber = getPhoneNumber(this) - if(phoneNumber == null) { - Timber.d("cannot get phone due to no permissions") - return - } - - val phoneInput = findViewById(R.id.loginPhoneNumberInputSIM1) - phoneInput.setText(phoneNumber) - Timber.d("[SIM1] phone number [$phoneNumber] set successfully") - } - - private fun setServerURL() { - val serverUrlInput = findViewById(R.id.loginServerUrlInput) - serverUrlInput.setText(getString(R.string.default_server_url)) - Timber.d("default server url [${serverUrlInput.text.toString()}] set successfully") - } - @SuppressLint("HardwareIds") @Suppress("DEPRECATION") private fun getPhoneNumber(context: Context): String? { @@ -139,13 +118,16 @@ class LoginActivity : AppCompatActivity() { return telephonyManager.line1Number } + private val requestPermissionLauncher = registerForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { permissions -> + permissions.entries.forEach { + Timber.d("${it.key} = ${it.value}") + } + // Try to auto-detect phone numbers after permissions are granted + viewModel.autoDetectPhoneNumbers(this) + } + private fun requestPermissions() { Timber.d("requesting permissions") - val requestPermissionLauncher = registerForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { permissions -> - permissions.entries.forEach { - Timber.d("${it.key} = ${it.value}") - } - } var permissions = arrayOf( Manifest.permission.SEND_SMS, @@ -175,169 +157,19 @@ class LoginActivity : AppCompatActivity() { return null } - - private fun onLoginClick() { - Timber.d("login button clicked") - - val error = isGooglePlayServicesAvailable() - if (error != null) { - Timber.d("google play services not installed [${error}]") - Toast.makeText(this, error, Toast.LENGTH_SHORT).show() - return - } - - if (Settings.getFcmToken(this) == null) { - Timber.d("The FCM token is not set") - Toast.makeText(this, "Cannot find FCM token. Make sure you have Google Play Services installed", Toast.LENGTH_LONG).show() - return - } - - loginButton().isEnabled = false - val progressBar = findViewById(R.id.loginProgressIndicator) - progressBar.visibility = View.VISIBLE - - val apiKeyLayout = findViewById(R.id.loginApiKeyTextInputLayout) - apiKeyLayout.error = null - - val apiKey = findViewById(R.id.loginApiKeyTextInput) - apiKey.isEnabled = false - - val serverUrlLayout = findViewById(R.id.loginServerUrlLayout) - serverUrlLayout.error = null - - val serverUrl = findViewById(R.id.loginServerUrlInput) - serverUrl.isEnabled = false - - val phoneNumberLayout = findViewById(R.id.loginPhoneNumberLayoutSIM1) - phoneNumberLayout.error = null - - val phoneNumber = findViewById(R.id.loginPhoneNumberInputSIM1) - phoneNumber.isEnabled = false - - val phoneNumberLayoutSIM2 = findViewById(R.id.loginPhoneNumberLayoutSIM2) - phoneNumberLayoutSIM2.error = null - - val phoneNumberSIM2 = findViewById(R.id.loginPhoneNumberInputSIM2) - phoneNumberSIM2.isEnabled = false - - val countryCode = getCountryCode() - - val resetView = fun () { - apiKey.isEnabled = true - serverUrl.isEnabled = true - progressBar.visibility = View.INVISIBLE - phoneNumber.isEnabled = true - phoneNumberSIM2.isEnabled = true - loginButton().isEnabled = true - } - - if (!PhoneNumberValidator.isValidPhoneNumber(phoneNumber.text.toString().trim(), countryCode)) { - Timber.e("[SIM1] phone number [${phoneNumber.text.toString()}] is not valid") - resetView() - phoneNumberLayout.error = "Enter an international phone number in the E.164 format" - return - } - - if (SmsManagerService.isDualSIM(this) && !PhoneNumberValidator.isValidPhoneNumber(phoneNumberSIM2.text.toString().trim(), countryCode)) { - Timber.e("[SIM2] phone number [${phoneNumberSIM2.text.toString()}] is not valid") - resetView() - phoneNumberLayoutSIM2.error = "Enter an international phone number in the E.164 format" - return - } - - if(!URLUtil.isValidUrl(serverUrl.text.toString().trim())) { - Timber.e("url number [${serverUrl.text.toString()}] is not a valid URL") - resetView() - serverUrlLayout.error = "Server URL [${serverUrl.text.toString()}] is invalid" - return - } - - if (!URLUtil.isHttpsUrl(serverUrl.text.toString().trim())) { - Timber.e("url number [${serverUrl.text.toString()}] is not an https URL") - resetView() - serverUrlLayout.error = "Server URL [${serverUrl.text.toString()}] must be HTTPS" - return - } - - val liveData = MutableLiveData>() - liveData.observe(this) { authResult -> - run { - progressBar.visibility = View.INVISIBLE - if (authResult.first != null) { - resetView() - apiKeyLayout.error = authResult.first - return@run - } - - if (authResult.second != null) { - resetView() - serverUrlLayout.error = authResult.second - return@run - } - - Settings.setApiKeyAsync(this, apiKey.text.toString()) - Settings.setServerUrlAsync(this, serverUrl.text.toString().trim()) - - val e164PhoneNumber = PhoneNumberValidator.formatE164(phoneNumber.text.toString().trim(), countryCode) - Settings.setSIM1PhoneNumber(this, e164PhoneNumber) - - if(SmsManagerService.isDualSIM(this)) { - val sim2PhoneNumber = PhoneNumberValidator.formatE164(phoneNumberSIM2.text.toString().trim(), countryCode) - Settings.setSIM2PhoneNumber(this, sim2PhoneNumber) - } - - Timber.d("login successfully redirecting to main view") - redirectToMain() - } - } - - Thread { - val service = HttpSmsApiService(apiKey.text.toString(), URI(serverUrl.text.toString().trim())) - - var e164PhoneNumber = PhoneNumberValidator.formatE164(phoneNumber.text.toString().trim(), countryCode) - var response = service.updateFcmToken(e164PhoneNumber, Constants.SIM1, Settings.getFcmToken(this) ?: "") - if(response.second != null || response.third != null) { - Timber.e("error updating fcm token [${response.second}], third [${response.third}]") - liveData.postValue(Pair(response.second, response.third)) - return@Thread - } - - if (!SmsManagerService.isDualSIM(this)) { - Timber.d("single sim detected, no need to update sim2") - liveData.postValue(Pair(null, null)) - return@Thread - } - - e164PhoneNumber = PhoneNumberValidator.formatE164(phoneNumberSIM2.text.toString().trim(), countryCode) - response = service.updateFcmToken(e164PhoneNumber, Constants.SIM2, Settings.getFcmToken(this) ?: "") - - liveData.postValue(Pair(response.second, response.third)) - Timber.d("finished validating api URL") - }.start() - } - private fun redirectToMain() { if (!Settings.isLoggedIn(this)) { return } - finish() + finish() val switchActivityIntent = Intent(this, MainActivity::class.java) startActivity(switchActivityIntent) } - private fun loginButton(): MaterialButton { - return findViewById(R.id.loginButton) - } - private fun getCountryCode() : String { - // Get the TelephonyManager from the system services val tm = this.getSystemService(Context.TELEPHONY_SERVICE) as TelephonyManager - - // Get the network country ISO code and convert it to uppercase val code = tm.networkCountryIso.uppercase() - - // If the country code is empty, retrieve the country code from the device's locale if (code.isEmpty()) { return this.resources.configuration.locales.get(0).country.uppercase() } diff --git a/android/app/src/main/java/com/httpsms/MainActivity.kt b/android/app/src/main/java/com/httpsms/MainActivity.kt index 363e7c19a..ff7587db2 100644 --- a/android/app/src/main/java/com/httpsms/MainActivity.kt +++ b/android/app/src/main/java/com/httpsms/MainActivity.kt @@ -10,37 +10,29 @@ import android.content.pm.PackageManager import android.net.Uri import android.os.Build import android.os.Bundle -import android.os.PowerManager -import android.telephony.PhoneNumberUtils -import android.view.View -import android.widget.LinearLayout -import android.widget.TextView +import android.provider.Settings as ProviderSettings import android.widget.Toast +import androidx.activity.compose.setContent import androidx.activity.result.contract.ActivityResultContracts +import androidx.activity.viewModels import androidx.appcompat.app.AppCompatActivity -import androidx.lifecycle.MutableLiveData import androidx.work.Constraints import androidx.work.ExistingPeriodicWorkPolicy import androidx.work.NetworkType import androidx.work.PeriodicWorkRequestBuilder import androidx.work.WorkManager -import com.google.android.material.button.MaterialButton -import com.google.android.material.card.MaterialCardView -import com.google.android.material.progressindicator.LinearProgressIndicator import com.httpsms.services.StickyNotificationService +import com.httpsms.ui.main.MainScreen +import com.httpsms.ui.main.MainViewModel +import com.httpsms.ui.theme.HttpSmsTheme import com.httpsms.worker.HeartbeatWorker import timber.log.Timber -import java.time.Instant -import java.time.ZoneId -import java.time.ZoneOffset -import java.time.ZonedDateTime -import java.time.format.DateTimeFormatter -import java.util.* import java.util.concurrent.TimeUnit -import android.provider.Settings as ProviderSettings class MainActivity : AppCompatActivity() { + private val viewModel: MainViewModel by viewModels() + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -48,20 +40,42 @@ class MainActivity : AppCompatActivity() { redirectToLogin() - setContentView(R.layout.activity_main) + viewModel.initialize(this, getString(R.string.app_version, BuildConfig.VERSION_NAME)) + + setContent { + HttpSmsTheme { + MainScreen( + viewModel = viewModel, + onSettingsClick = { onSettingsClick() }, + onSmsPermissionClick = { + val intent = Intent(Intent.ACTION_VIEW, Uri.parse("https://httpsms.com/blog/grant-send-and-read-sms-permissions-on-android")) + startActivity(intent) + }, + onBatteryOptimizationClick = { + val intent = Intent() + intent.action = ProviderSettings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS + intent.data = Uri.parse("package:$packageName") + startActivity(intent) + }, + onHeartbeatClick = { + viewModel.sendHeartbeat(this) { error -> + if (error != null) { + Timber.w("heartbeat sending failed with [$error]") + Toast.makeText(this, error, Toast.LENGTH_LONG).show() + } else { + Toast.makeText(this, "Heartbeat sent successfully", Toast.LENGTH_SHORT).show() + } + } + } + ) + } + } createChannel() - - setCardContent(this) - registerListeners() refreshToken(this) startStickyNotification(this) scheduleHeartbeatWorker(this) - setVersion() - setHeartbeatListener(this) - setSmsPermissionListener() - setBatteryOptimizationListener() } override fun onStart() { @@ -74,35 +88,7 @@ class MainActivity : AppCompatActivity() { Timber.d( "on activity resume") redirectToLogin() refreshToken(this) - setCardContent(this) - setSmsPermissionListener() - setBatteryOptimizationListener() - } - - private fun setVersion() { - val appVersionView = findViewById(R.id.mainAppVersion) - appVersionView.text = getString(R.string.app_version, BuildConfig.VERSION_NAME) - } - - private fun setCardContent(context: Context) { - val titleText = findViewById(R.id.cardPhoneNumber) - titleText.text = PhoneNumberUtils.formatNumber(Settings.getSIM1PhoneNumber(this), Locale.getDefault().country) - if(!Settings.getActiveStatus(context, Constants.SIM1)) { - titleText.setCompoundDrawables(null, null, null, null) - } - - val titleTextSIM2 = findViewById(R.id.cardPhoneNumberSIM2) - titleTextSIM2.text = PhoneNumberUtils.formatNumber(Settings.getSIM2PhoneNumber(this), Locale.getDefault().country) - if(!Settings.getActiveStatus(context, Constants.SIM2)) { - titleTextSIM2.setCompoundDrawables(null, null, null, null) - } - - setLastHeartbeatTimestamp(context) - - if(!Settings.isDualSIM(context)) { - val sim2Card = findViewById(R.id.mainPhoneCardSIM2) - sim2Card.visibility = MaterialCardView.GONE - } + viewModel.updateState(this, getString(R.string.app_version, BuildConfig.VERSION_NAME)) } private fun requestPermissions(context:Context) { @@ -116,7 +102,7 @@ class MainActivity : AppCompatActivity() { Settings.setIncomingCallEventsEnabled(context, Constants.SIM2, false) } } - setSmsPermissionListener() + viewModel.updateState(context, getString(R.string.app_version, BuildConfig.VERSION_NAME)) } var permissions = arrayOf( @@ -229,10 +215,6 @@ class MainActivity : AppCompatActivity() { } } - private fun registerListeners() { - findViewById(R.id.mainSettingsButton).setOnClickListener { onSettingsClick() } - } - private fun onSettingsClick() { Timber.d("settings button clicked") val switchActivityIntent = Intent(this, SettingsActivity::class.java) @@ -248,28 +230,6 @@ class MainActivity : AppCompatActivity() { return true } - private fun setLastHeartbeatTimestamp(context: Context) { - val refreshTimestampView = findViewById(R.id.cardRefreshTime) - val timestamp = Settings.getHeartbeatTimestamp(context) - - if (timestamp == 0.toLong()) { - Timber.d("no heartbeat timestamp has been set") - refreshTimestampView.text = "--" - return - } - - val timestampZdt = ZonedDateTime.ofInstant(Instant.ofEpochMilli(timestamp), ZoneOffset.UTC) - val localTime = timestampZdt.withZoneSameInstant(ZoneId.systemDefault()) - Timber.d("heartbeat timestamp in UTC is [${timestampZdt}] and local is [$localTime]") - - refreshTimestampView.text = localTime.format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")) - - if (Settings.isDualSIM(context)) { - val refreshTimestampViewSIM2 = findViewById(R.id.cardRefreshTimeSIM2) - refreshTimestampViewSIM2.text = localTime.format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")) - } - } - private fun createChannel() { // Create the NotificationChannel val name = getString(R.string.notification_channel_default) @@ -282,109 +242,4 @@ class MainActivity : AppCompatActivity() { val notificationManager = getSystemService(NOTIFICATION_SERVICE) as NotificationManager notificationManager.createNotificationChannel(mChannel) } - - @SuppressLint("BatteryLife") - private fun setBatteryOptimizationListener() { - val pm = getSystemService(POWER_SERVICE) as PowerManager - val button = findViewById(R.id.batteryOptimizationButtonButton) - if (!pm.isIgnoringBatteryOptimizations(packageName)) { - button.visibility = View.VISIBLE - button.setOnClickListener { - val intent = Intent() - intent.action = ProviderSettings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS - intent.data = Uri.parse("package:$packageName") - startActivity(intent) - } - } else { - button.visibility = View.GONE - } - updatePermissionLayoutVisibility() - } - - private fun setSmsPermissionListener() { - val smsPermissions = arrayOf( - Manifest.permission.SEND_SMS, - Manifest.permission.RECEIVE_SMS, - Manifest.permission.READ_SMS - ) - val allGranted = smsPermissions.all { - checkSelfPermission(it) == PackageManager.PERMISSION_GRANTED - } - - val button = findViewById(R.id.smsPermissionButton) - if (!allGranted) { - button.visibility = View.VISIBLE - button.setOnClickListener { - val intent = Intent(Intent.ACTION_VIEW, Uri.parse("https://httpsms.com/blog/grant-send-and-read-sms-permissions-on-android")) - startActivity(intent) - } - } else { - button.visibility = View.GONE - } - updatePermissionLayoutVisibility() - } - - private fun updatePermissionLayoutVisibility() { - val smsButton = findViewById(R.id.smsPermissionButton) - val batteryButton = findViewById(R.id.batteryOptimizationButtonButton) - val layout = findViewById(R.id.batteryOptimizationLinearLayout) - - if (smsButton.visibility == View.GONE && batteryButton.visibility == View.GONE) { - layout.visibility = View.GONE - } else { - layout.visibility = View.VISIBLE - } - } - - private fun setHeartbeatListener(context: Context) { - findViewById(R.id.mainHeartbeatButton).setOnClickListener{onHeartbeatClick(context)} - } - - private fun onHeartbeatClick(context: Context) { - Timber.d("heartbeat button clicked") - val heartbeatButton = findViewById(R.id.mainHeartbeatButton) - heartbeatButton.isEnabled = false - - val progressBar = findViewById(R.id.mainProgressIndicator) - progressBar.visibility = View.VISIBLE - - val liveData = MutableLiveData() - liveData.observe(this) { exception -> - run { - progressBar.visibility = View.INVISIBLE - heartbeatButton.isEnabled = true - - if (exception != null) { - Timber.w("heartbeat sending failed with [$exception]") - Toast.makeText(context, exception, Toast.LENGTH_LONG).show() - return@run - } - Toast.makeText(context, "Heartbeat sent successfully", Toast.LENGTH_SHORT).show() - - setLastHeartbeatTimestamp(this) - } - } - - Thread { - val charging = Settings.isCharging(applicationContext) - var error: String? = null - try { - val phoneNumbers = mutableListOf() - phoneNumbers.add(Settings.getSIM1PhoneNumber(applicationContext)) - if (Settings.getActiveStatus(applicationContext, Constants.SIM2)) { - phoneNumbers.add(Settings.getSIM2PhoneNumber(applicationContext)) - } - val isStored = HttpSmsApiService.create(context).storeHeartbeat(phoneNumbers.toTypedArray(), charging) - if (!isStored) { - error = "Could not send heartbeat make sure the phone is connected to the internet" - } - Settings.setHeartbeatTimestampAsync(applicationContext, System.currentTimeMillis()) - } catch (exception: Exception) { - Timber.e(exception) - error = exception.javaClass.simpleName - } - liveData.postValue(error) - Timber.d("finished sending pulse") - }.start() - } } diff --git a/android/app/src/main/java/com/httpsms/SettingsActivity.kt b/android/app/src/main/java/com/httpsms/SettingsActivity.kt index cbc2831bc..37a8b3523 100644 --- a/android/app/src/main/java/com/httpsms/SettingsActivity.kt +++ b/android/app/src/main/java/com/httpsms/SettingsActivity.kt @@ -1,121 +1,33 @@ package com.httpsms -import android.content.Context import android.content.Intent import android.os.Bundle +import androidx.activity.compose.setContent +import androidx.activity.viewModels import androidx.appcompat.app.AppCompatActivity -import androidx.core.widget.doAfterTextChanged -import com.google.android.material.appbar.MaterialToolbar -import com.google.android.material.button.MaterialButton import com.google.android.material.dialog.MaterialAlertDialogBuilder -import com.google.android.material.switchmaterial.SwitchMaterial -import com.google.android.material.textfield.TextInputEditText -import com.google.android.material.textfield.TextInputLayout +import com.httpsms.ui.settings.SettingsScreen +import com.httpsms.ui.settings.SettingsViewModel +import com.httpsms.ui.theme.HttpSmsTheme import timber.log.Timber class SettingsActivity : AppCompatActivity() { + private val viewModel: SettingsViewModel by viewModels() + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - setContentView(R.layout.activity_settings) - fillSettings(this) - registerListeners() - } - - private fun fillSettings(context: Context) { - val debugLogs = findViewById(R.id.settingEnableDebugLogs) - debugLogs.isChecked = Settings.isDebugLogEnabled(context) - debugLogs.setOnCheckedChangeListener{ _, isChecked -> run { Settings.setDebugLogEnabled(context, isChecked) } } - - - val phoneNumber = findViewById(R.id.settingsSIM1Input) - phoneNumber.setText(Settings.getSIM1PhoneNumber(context)) - phoneNumber.isEnabled = false - - val sim1IncomingMessages = findViewById(R.id.settings_sim1_incoming_messages) - sim1IncomingMessages.isChecked = Settings.isIncomingMessageEnabled(context, Constants.SIM1) - - sim1IncomingMessages.setOnCheckedChangeListener{ _, isChecked -> run { Settings.setIncomingActiveSIM1(context, isChecked) } } - - val sim1OutgoingMessages = findViewById(R.id.settings_sim1_outgoing_messages) - sim1OutgoingMessages.isChecked = Settings.getActiveStatus(context, Constants.SIM1) - sim1OutgoingMessages.setOnCheckedChangeListener{ _, isChecked -> run { Settings.setActiveStatusAsync(context, isChecked, Constants.SIM1) } } - - if (!Settings.isDualSIM(context)) { - val layout = findViewById(R.id.settingsSIM2Layout) - layout.visibility = TextInputLayout.GONE - val sim2Switch = findViewById(R.id.settings_sim2_incoming_messages) - sim2Switch.visibility = SwitchMaterial.GONE - val outgoingSwitch = findViewById(R.id.settings_sim2_outgoing_messages) - outgoingSwitch.visibility = SwitchMaterial.GONE - } else { - val phoneNumberSIM2 = findViewById(R.id.settingsSIM2InputEdit) - phoneNumberSIM2.setText(Settings.getSIM2PhoneNumber(context)) - phoneNumberSIM2.isEnabled = false - - val sim2IncomingMessages = findViewById(R.id.settings_sim2_incoming_messages) - sim2IncomingMessages.isChecked = Settings.isIncomingMessageEnabled(context, Constants.SIM2) - sim2IncomingMessages.setOnCheckedChangeListener{ _, isChecked -> run { Settings.setIncomingActiveSIM2(context, isChecked) } } - - val sim2OutgoingMessages = findViewById(R.id.settings_sim2_outgoing_messages) - sim2OutgoingMessages.isChecked = Settings.getActiveStatus(context, Constants.SIM2) - sim2OutgoingMessages.setOnCheckedChangeListener{ _, isChecked -> run { Settings.setActiveStatusAsync(context, isChecked, Constants.SIM2) } } - } - - handleEncryptionSettings(context) - handleIncomingCallEvents(context) - } - - private fun handleIncomingCallEvents(context: Context) { - val enableIncomingCallEvents = findViewById(R.id.settingsSim1EnableIncomingCallEvents) - enableIncomingCallEvents.isChecked = Settings.isIncomingCallEventsEnabled(context, Constants.SIM1) - enableIncomingCallEvents.setOnCheckedChangeListener{ _, isChecked -> run { - Settings.setIncomingCallEventsEnabled(context, Constants.SIM1, isChecked) - }} - - val sim2IncomingCalls = findViewById(R.id.settingsSim2EnableIncomingCallEvents) - if (!Settings.isDualSIM(context)) { - sim2IncomingCalls.visibility = SwitchMaterial.GONE - return - } - - sim2IncomingCalls.isChecked = Settings.isIncomingCallEventsEnabled(context, Constants.SIM2) - sim2IncomingCalls.setOnCheckedChangeListener{ _, isChecked -> run { - Settings.setIncomingCallEventsEnabled(context, Constants.SIM2, isChecked) - }} - } - - private fun handleEncryptionSettings(context: Context) { - val encryptionKey = findViewById(R.id.settingsEncryptionKeyInputEdit) - val encryptReceivedMessages = findViewById(R.id.settingsEncryptReceivedMessages) - - val key = Settings.getEncryptionKey(context) - if(key.isNullOrEmpty()) { - encryptReceivedMessages.isEnabled = false - } else { - encryptionKey.setText(key.trim()) - } - - encryptionKey.doAfterTextChanged{ - if (it == null || it.toString().isEmpty()) { - Settings.setEncryptionKey(context, null) - Settings.setEncryptReceivedMessages(context, false) - encryptReceivedMessages.isChecked = false - encryptReceivedMessages.isEnabled = false - } else { - encryptReceivedMessages.isEnabled = true - Settings.setEncryptionKey(context, it.toString().trim()) + + viewModel.initialize(this) + + setContent { + HttpSmsTheme { + SettingsScreen( + viewModel = viewModel, + onBackClick = { onBackClicked() }, + onLogoutClick = { onLogoutClick() } + ) } } - - encryptReceivedMessages.isChecked = Settings.encryptReceivedMessages(context) - encryptReceivedMessages.setOnCheckedChangeListener{ _, isChecked -> run { - Settings.setEncryptReceivedMessages(context, isChecked) - }} - } - - private fun registerListeners() { - appToolbar().setOnClickListener { onBackClicked() } - findViewById(R.id.settingsLogoutButton).setOnClickListener { onLogoutClick() } } private fun onBackClicked() { @@ -129,10 +41,6 @@ class SettingsActivity : AppCompatActivity() { startActivity(switchActivityIntent) } - private fun appToolbar(): MaterialToolbar { - return findViewById(R.id.settings_toolbar) - } - private fun onLogoutClick() { Timber.d("logout button clicked") MaterialAlertDialogBuilder(this) @@ -141,20 +49,9 @@ class SettingsActivity : AppCompatActivity() { .setNeutralButton("Cancel"){ _, _ -> Timber.d("logout dialog canceled") } .setPositiveButton("Logout"){_, _ -> Timber.d("logging out user") - Settings.setApiKeyAsync(this, null) - Settings.setSIM1PhoneNumber(this, null) - Settings.setSIM2PhoneNumber(this, null) - Settings.setActiveStatusAsync(this, true, Constants.SIM1) - Settings.setActiveStatusAsync(this, true, Constants.SIM2) - Settings.setIncomingActiveSIM1(this, true) - Settings.setIncomingActiveSIM2(this, true) - Settings.setUserID(this, null) - Settings.setEncryptionKey(this, null) - Settings.setEncryptReceivedMessages(this, false) - Settings.setFcmTokenLastUpdateTimestampAsync(this, 0) - Settings.setIncomingCallEventsEnabled(this, Constants.SIM1, false) - Settings.setIncomingCallEventsEnabled(this, Constants.SIM2, false) - redirectToLogin() + viewModel.logout(this) { + redirectToLogin() + } } .show() } diff --git a/android/app/src/main/java/com/httpsms/ui/login/LoginActivityContent.kt b/android/app/src/main/java/com/httpsms/ui/login/LoginActivityContent.kt new file mode 100644 index 000000000..0fc5e4c9d --- /dev/null +++ b/android/app/src/main/java/com/httpsms/ui/login/LoginActivityContent.kt @@ -0,0 +1,217 @@ +package com.httpsms.ui.login + +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.ClickableText +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.QrCodeScanner +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalUriHandler +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.withStyle +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.httpsms.R +import com.httpsms.ui.theme.Blue500 +import com.httpsms.ui.theme.Pink500 + +@Composable +fun LoginScreen( + viewModel: LoginViewModel, + onQrScanClick: () -> Unit, + onLoginClick: () -> Unit +) { + val uiState by viewModel.uiState.collectAsState() + val uriHandler = LocalUriHandler.current + + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Spacer(modifier = Modifier.height(64.dp)) + + Image( + painter = painterResource(id = R.drawable.logo_cropped), + contentDescription = stringResource(id = R.string.img_http_sms_logo), + modifier = Modifier.size(100.dp) + ) + + Spacer(modifier = Modifier.height(32.dp)) + + val annotatedString = buildAnnotatedString { + val text = stringResource(id = R.string.get_your_api_key) + val linkText = "httpsms.com/settings" + val startIndex = text.indexOf(linkText) + + if (startIndex >= 0) { + append(text.substring(0, startIndex)) + + pushStringAnnotation(tag = "URL", annotation = "https://httpsms.com/settings") + withStyle(style = SpanStyle(color = Blue500, fontWeight = FontWeight.Bold)) { + append(linkText) + } + pop() + + append(text.substring(startIndex + linkText.length)) + } else { + append(text) + } + } + + ClickableText( + text = annotatedString, + onClick = { offset -> + annotatedString.getStringAnnotations(tag = "URL", start = offset, end = offset) + .firstOrNull()?.let { annotation -> + uriHandler.openUri(annotation.item) + } + }, + style = MaterialTheme.typography.bodyLarge.copy( + textAlign = TextAlign.Center, + fontSize = 20.sp, + lineHeight = 28.sp, + color = MaterialTheme.colorScheme.onBackground + ), + modifier = Modifier.fillMaxWidth() + ) + + Spacer(modifier = Modifier.height(24.dp)) + + OutlinedTextField( + value = uiState.apiKey, + onValueChange = { viewModel.onApiKeyChange(it) }, + label = { Text(stringResource(id = R.string.text_area_api_key)) }, + modifier = Modifier.fillMaxWidth(), + isError = uiState.apiKeyError != null, + supportingText = uiState.apiKeyError?.let { { Text(it) } }, + trailingIcon = { + IconButton(onClick = onQrScanClick) { + Icon( + imageVector = Icons.Default.QrCodeScanner, + contentDescription = "Scan QR Code" + ) + } + }, + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Text, + imeAction = ImeAction.Done + ), + enabled = !uiState.isLoading + ) + + Spacer(modifier = Modifier.height(8.dp)) + + OutlinedTextField( + value = uiState.phoneNumberSIM1, + onValueChange = { viewModel.onPhoneNumberSIM1Change(it) }, + label = { Text(stringResource(id = R.string.login_phone_number_sim1)) }, + placeholder = { Text(stringResource(id = R.string.login_phone_number_hint)) }, + modifier = Modifier.fillMaxWidth(), + isError = uiState.phoneNumberSIM1Error != null, + supportingText = uiState.phoneNumberSIM1Error?.let { { Text(it) } }, + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Phone, + imeAction = ImeAction.Done + ), + enabled = !uiState.isLoading + ) + + if (uiState.isDualSim) { + Spacer(modifier = Modifier.height(8.dp)) + OutlinedTextField( + value = uiState.phoneNumberSIM2, + onValueChange = { viewModel.onPhoneNumberSIM2Change(it) }, + label = { Text(stringResource(id = R.string.login_phone_number_sim2)) }, + placeholder = { Text(stringResource(id = R.string.login_phone_number_hint)) }, + modifier = Modifier.fillMaxWidth(), + isError = uiState.phoneNumberSIM2Error != null, + supportingText = uiState.phoneNumberSIM2Error?.let { { Text(it) } }, + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Phone, + imeAction = ImeAction.Done + ), + enabled = !uiState.isLoading + ) + } + + Spacer(modifier = Modifier.height(8.dp)) + + OutlinedTextField( + value = uiState.serverUrl, + onValueChange = { viewModel.onServerUrlChange(it) }, + label = { Text(stringResource(id = R.string.server_url)) }, + placeholder = { Text(stringResource(id = R.string.login_server_url_hint)) }, + modifier = Modifier.fillMaxWidth(), + isError = uiState.serverUrlError != null, + supportingText = uiState.serverUrlError?.let { { Text(it) } }, + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Uri, + imeAction = ImeAction.Done + ), + enabled = !uiState.isLoading + ) + + Spacer(modifier = Modifier.height(16.dp)) + + Button( + onClick = onLoginClick, + enabled = !uiState.isLoading, + modifier = Modifier.align(Alignment.CenterHorizontally), + colors = ButtonDefaults.buttonColors(containerColor = Blue500), + contentPadding = PaddingValues(horizontal = 32.dp, vertical = 16.dp) + ) { + Icon( + painter = painterResource(id = R.drawable.ic_login), + contentDescription = null, + modifier = Modifier.padding(end = 8.dp), + tint = Color.White + ) + Text( + text = stringResource(id = R.string.sign_in_button), + color = Color.White, + fontSize = 18.sp + ) + } + + if (uiState.isLoading) { + Spacer(modifier = Modifier.height(16.dp)) + CircularProgressIndicator( + modifier = Modifier.size(24.dp), + color = Pink500 + ) + } + } +} diff --git a/android/app/src/main/java/com/httpsms/ui/login/LoginViewModel.kt b/android/app/src/main/java/com/httpsms/ui/login/LoginViewModel.kt new file mode 100644 index 000000000..a4803e41e --- /dev/null +++ b/android/app/src/main/java/com/httpsms/ui/login/LoginViewModel.kt @@ -0,0 +1,221 @@ +package com.httpsms.ui.login + +import android.Manifest +import android.content.Context +import android.content.pm.PackageManager +import android.os.Build +import android.telephony.SubscriptionManager +import android.telephony.TelephonyManager +import android.webkit.URLUtil +import androidx.core.content.ContextCompat +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.httpsms.Constants +import com.httpsms.HttpSmsApiService +import com.httpsms.Settings +import com.httpsms.SmsManagerService +import com.httpsms.validators.PhoneNumberValidator +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import timber.log.Timber +import java.net.URI +import java.net.URISyntaxException + +data class LoginUiState( + val apiKey: String = "", + val phoneNumberSIM1: String = "", + val phoneNumberSIM2: String = "", + val serverUrl: String = "", + val isLoading: Boolean = false, + val apiKeyError: String? = null, + val phoneNumberSIM1Error: String? = null, + val phoneNumberSIM2Error: String? = null, + val serverUrlError: String? = null, + val isDualSim: Boolean = false, + val loginSuccess: Boolean = false +) + +class LoginViewModel : ViewModel() { + private val _uiState = MutableStateFlow(LoginUiState()) + val uiState = _uiState.asStateFlow() + + fun initialize(context: Context, defaultServerUrl: String) { + val isDualSim = SmsManagerService.isDualSIM(context) + val phoneNumberSIM1 = Settings.getSIM1PhoneNumber(context) + val phoneNumberSIM2 = Settings.getSIM2PhoneNumber(context) + + _uiState.value = _uiState.value.copy( + isDualSim = isDualSim, + phoneNumberSIM1 = phoneNumberSIM1, + phoneNumberSIM2 = phoneNumberSIM2, + serverUrl = defaultServerUrl + ) + + // Try to auto-detect if fields are empty + if (phoneNumberSIM1.isEmpty() || (isDualSim && phoneNumberSIM2.isEmpty())) { + autoDetectPhoneNumbers(context) + } + } + + fun autoDetectPhoneNumbers(context: Context) { + if (ContextCompat.checkSelfPermission(context, Manifest.permission.READ_PHONE_STATE) != PackageManager.PERMISSION_GRANTED) { + Timber.d("READ_PHONE_STATE permission not granted for auto-detecting phone numbers") + return + } + + val telephonyManager = context.getSystemService(Context.TELEPHONY_SERVICE) as TelephonyManager + + var detectedSIM1 = _uiState.value.phoneNumberSIM1 + var detectedSIM2 = _uiState.value.phoneNumberSIM2 + + try { + val subscriptionManager = if (Build.VERSION.SDK_INT >= 31) { + context.getSystemService(SubscriptionManager::class.java) + } else { + SubscriptionManager.from(context) + } + + val activeSubscriptions = try { subscriptionManager.activeSubscriptionInfoList } catch (e: Exception) { null } + + if (detectedSIM1.isEmpty()) { + val line1Number = try { telephonyManager.line1Number } catch (e: Exception) { null } + if (!line1Number.isNullOrEmpty()) { + detectedSIM1 = line1Number + } else if (activeSubscriptions != null && activeSubscriptions.isNotEmpty()) { + detectedSIM1 = activeSubscriptions[0].number ?: "" + } + } + + if (detectedSIM2.isEmpty() && activeSubscriptions != null && activeSubscriptions.size >= 2) { + detectedSIM2 = activeSubscriptions[1].number ?: "" + } + + Timber.d("Auto-detected numbers - SIM1: $detectedSIM1, SIM2: $detectedSIM2") + + } catch (e: SecurityException) { + Timber.e(e, "Security exception while auto-detecting phone numbers") + } + + _uiState.value = _uiState.value.copy( + phoneNumberSIM1 = detectedSIM1, + phoneNumberSIM2 = detectedSIM2, + isDualSim = SmsManagerService.isDualSIM(context) + ) + } + + fun onApiKeyChange(value: String) { + _uiState.value = _uiState.value.copy(apiKey = value, apiKeyError = null) + } + + fun onPhoneNumberSIM1Change(value: String) { + _uiState.value = _uiState.value.copy(phoneNumberSIM1 = value, phoneNumberSIM1Error = null) + } + + fun onPhoneNumberSIM2Change(value: String) { + _uiState.value = _uiState.value.copy(phoneNumberSIM2 = value, phoneNumberSIM2Error = null) + } + + fun onServerUrlChange(value: String) { + _uiState.value = _uiState.value.copy(serverUrl = value, serverUrlError = null) + } + + fun login(context: Context, countryCode: String, onGooglePlayServicesError: (String) -> Unit, onFcmTokenMissing: () -> Unit) { + val currentState = _uiState.value + + // Validation logic from LoginActivity.onLoginClick + if (Settings.getFcmToken(context) == null) { + onFcmTokenMissing() + return + } + + _uiState.value = currentState.copy(isLoading = true) + + viewModelScope.launch { + val apiKey = currentState.apiKey.trim() + val serverUrl = currentState.serverUrl.trim() + val phone1 = currentState.phoneNumberSIM1.trim() + val phone2 = currentState.phoneNumberSIM2.trim() + + if (!PhoneNumberValidator.isValidPhoneNumber(phone1, countryCode)) { + _uiState.value = _uiState.value.copy( + isLoading = false, + phoneNumberSIM1Error = "Enter an international phone number in the E.164 format" + ) + return@launch + } + + if (currentState.isDualSim && !PhoneNumberValidator.isValidPhoneNumber(phone2, countryCode)) { + _uiState.value = _uiState.value.copy( + isLoading = false, + phoneNumberSIM2Error = "Enter an international phone number in the E.164 format" + ) + return@launch + } + + if (!URLUtil.isValidUrl(serverUrl)) { + _uiState.value = _uiState.value.copy( + isLoading = false, + serverUrlError = "Server URL [$serverUrl] is invalid" + ) + return@launch + } + + if (!URLUtil.isHttpsUrl(serverUrl)) { + _uiState.value = _uiState.value.copy( + isLoading = false, + serverUrlError = "Server URL [$serverUrl] must be HTTPS" + ) + return@launch + } + + val authResult = try { + withContext(Dispatchers.IO) { + val service = HttpSmsApiService(apiKey, URI(serverUrl)) + val e164Phone1 = PhoneNumberValidator.formatE164(phone1, countryCode) + val response1 = service.updateFcmToken(e164Phone1, Constants.SIM1, Settings.getFcmToken(context) ?: "") + + if (response1.second != null || response1.third != null) { + return@withContext Pair(response1.second, response1.third) + } + + if (currentState.isDualSim) { + val e164Phone2 = PhoneNumberValidator.formatE164(phone2, countryCode) + val response2 = service.updateFcmToken(e164Phone2, Constants.SIM2, Settings.getFcmToken(context) ?: "") + return@withContext Pair(response2.second, response2.third) + } + + Pair(null, null) + } + } catch (e: URISyntaxException) { + Timber.e(e, "Invalid URI: $serverUrl") + Pair(null, "Server URL [$serverUrl] is invalid") + } catch (e: Exception) { + Timber.e(e, "Login error") + Pair(null, "An unexpected error occurred: ${e.message}") + } + + if (authResult.first != null) { + _uiState.value = _uiState.value.copy(isLoading = false, apiKeyError = authResult.first) + return@launch + } + + if (authResult.second != null) { + _uiState.value = _uiState.value.copy(isLoading = false, serverUrlError = authResult.second) + return@launch + } + + // Save settings + Settings.setApiKeyAsync(context, apiKey) + Settings.setServerUrlAsync(context, serverUrl) + Settings.setSIM1PhoneNumber(context, PhoneNumberValidator.formatE164(phone1, countryCode)) + if (currentState.isDualSim) { + Settings.setSIM2PhoneNumber(context, PhoneNumberValidator.formatE164(phone2, countryCode)) + } + + _uiState.value = _uiState.value.copy(isLoading = false, loginSuccess = true) + } + } +} diff --git a/android/app/src/main/java/com/httpsms/ui/main/MainActivityContent.kt b/android/app/src/main/java/com/httpsms/ui/main/MainActivityContent.kt new file mode 100644 index 000000000..087bf7921 --- /dev/null +++ b/android/app/src/main/java/com/httpsms/ui/main/MainActivityContent.kt @@ -0,0 +1,243 @@ +package com.httpsms.ui.main + +import android.telephony.PhoneNumberUtils +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.IntrinsicSize +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.BatteryAlert +import androidx.compose.material.icons.filled.CheckCircle +import androidx.compose.material.icons.filled.Favorite +import androidx.compose.material.icons.filled.Settings +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.LocalContentColor +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.httpsms.R +import com.httpsms.ui.theme.Blue500 +import com.httpsms.ui.theme.Pink500 +import java.util.Locale + +@Composable +fun MainScreen( + viewModel: MainViewModel, + onSettingsClick: () -> Unit, + onSmsPermissionClick: () -> Unit, + onBatteryOptimizationClick: () -> Unit, + onHeartbeatClick: () -> Unit +) { + val uiState by viewModel.uiState.collectAsState() + + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Spacer(modifier = Modifier.height(64.dp)) + + Image( + painter = painterResource(id = R.drawable.logo_cropped), + contentDescription = stringResource(id = R.string.img_http_sms_logo), + modifier = Modifier + .width(147.dp) + .height(92.dp) + ) + + Spacer(modifier = Modifier.height(24.dp)) + + PhoneCard( + phoneNumber = uiState.phoneNumberSIM1, + isActive = uiState.isActiveSIM1, + refreshTime = uiState.lastHeartbeatTime + ) + + if (uiState.isDualSim) { + Spacer(modifier = Modifier.height(24.dp)) + PhoneCard( + phoneNumber = uiState.phoneNumberSIM2, + isActive = uiState.isActiveSIM2, + refreshTime = uiState.lastHeartbeatTime + ) + } + + Spacer(modifier = Modifier.height(16.dp)) + + if (!uiState.isSmsPermissionGranted || !uiState.isBatteryOptimizationDisabled) { + Column(modifier = Modifier.fillMaxWidth()) { + if (!uiState.isSmsPermissionGranted) { + Button( + onClick = onSmsPermissionClick, + modifier = Modifier.align(Alignment.CenterHorizontally), + colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF4CAF50)), + contentPadding = PaddingValues(horizontal = 32.dp, vertical = 16.dp) + ) { + Text( + stringResource(id = R.string.enable_sms_permission), + color = Color.White, + fontSize = 18.sp + ) + Spacer(modifier = Modifier.width(8.dp)) + Icon( + painter = painterResource(id = R.drawable.open_in_new_24), + contentDescription = null, + tint = Color.White + ) + } + Spacer(modifier = Modifier.height(8.dp)) + } + + if (!uiState.isBatteryOptimizationDisabled) { + Button( + onClick = onBatteryOptimizationClick, + modifier = Modifier.align(Alignment.CenterHorizontally), + colors = ButtonDefaults.buttonColors(containerColor = Pink500), + contentPadding = PaddingValues(horizontal = 32.dp, vertical = 16.dp) + ) { + Icon( + imageVector = Icons.Default.BatteryAlert, + contentDescription = null, + tint = Color.White + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + stringResource(id = R.string.disable_battery_optimization), + color = Color.White, + fontSize = 18.sp + ) + } + } + } + } + + Spacer(modifier = Modifier.height(16.dp)) + + Column( + modifier = Modifier.width(IntrinsicSize.Max), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Button( + onClick = onHeartbeatClick, + modifier = Modifier.fillMaxWidth(), + enabled = !uiState.isHeartbeatLoading, + colors = ButtonDefaults.buttonColors(containerColor = Blue500), + contentPadding = PaddingValues(horizontal = 32.dp, vertical = 16.dp) + ) { + Icon( + imageVector = Icons.Default.Favorite, + contentDescription = null, + tint = if (uiState.isHeartbeatLoading) LocalContentColor.current else Pink500 + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + stringResource(id = R.string.send_heartbeat), + color = Color.White, + fontSize = 18.sp + ) + } + + if (uiState.isHeartbeatLoading) { + LinearProgressIndicator( + modifier = Modifier + .fillMaxWidth() + .padding(top = 4.dp), + color = Pink500 + ) + } + } + + Spacer(modifier = Modifier.height(16.dp)) + + Text( + text = uiState.appVersion, + fontSize = 14.sp, + color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.6f) + ) + + Spacer(modifier = Modifier.weight(1f)) + Spacer(modifier = Modifier.height(16.dp)) + + Button( + onClick = onSettingsClick, + colors = ButtonDefaults.buttonColors(containerColor = Color.Black), + contentPadding = PaddingValues(horizontal = 32.dp, vertical = 16.dp) + ) { + Icon(Icons.Default.Settings, contentDescription = null, tint = Color.White) + Spacer(modifier = Modifier.width(8.dp)) + Text( + stringResource(id = R.string.main_app_settings), + color = Color.White, + fontSize = 18.sp + ) + } + + Spacer(modifier = Modifier.height(16.dp)) + } +} + +@Composable +fun PhoneCard( + phoneNumber: String, + isActive: Boolean, + refreshTime: String +) { + Card( + modifier = Modifier.fillMaxWidth(), + elevation = CardDefaults.cardElevation(defaultElevation = 8.dp) + ) { + Column(modifier = Modifier.padding(16.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text = PhoneNumberUtils.formatNumber(phoneNumber, Locale.getDefault().country) ?: phoneNumber, + fontSize = 28.sp, + fontWeight = FontWeight.Medium, + modifier = Modifier.weight(1f), + color = MaterialTheme.colorScheme.onSurface + ) + if (isActive) { + Icon( + imageVector = Icons.Default.CheckCircle, + contentDescription = "Active", + tint = Color(0xFF70AB5C), + modifier = Modifier.size(24.dp) + ) + } + } + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = refreshTime, + fontSize = 16.sp, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f) + ) + } + } +} diff --git a/android/app/src/main/java/com/httpsms/ui/main/MainViewModel.kt b/android/app/src/main/java/com/httpsms/ui/main/MainViewModel.kt new file mode 100644 index 000000000..273cfe786 --- /dev/null +++ b/android/app/src/main/java/com/httpsms/ui/main/MainViewModel.kt @@ -0,0 +1,116 @@ +package com.httpsms.ui.main + +import android.Manifest +import android.content.Context +import android.content.pm.PackageManager +import android.os.PowerManager +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.httpsms.Constants +import com.httpsms.HttpSmsApiService +import com.httpsms.Settings +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import timber.log.Timber +import java.time.Instant +import java.time.ZoneId +import java.time.ZoneOffset +import java.time.ZonedDateTime +import java.time.format.DateTimeFormatter + +data class MainUiState( + val phoneNumberSIM1: String = "", + val isActiveSIM1: Boolean = false, + val phoneNumberSIM2: String = "", + val isActiveSIM2: Boolean = false, + val isDualSim: Boolean = false, + val lastHeartbeatTime: String = "--", + val isSmsPermissionGranted: Boolean = true, + val isBatteryOptimizationDisabled: Boolean = true, + val isHeartbeatLoading: Boolean = false, + val appVersion: String = "" +) + +class MainViewModel : ViewModel() { + private val _uiState = MutableStateFlow(MainUiState()) + val uiState = _uiState.asStateFlow() + + fun initialize(context: Context, appVersion: String) { + updateState(context, appVersion) + } + + fun updateState(context: Context, appVersion: String) { + val isDualSim = Settings.isDualSIM(context) + val phone1 = Settings.getSIM1PhoneNumber(context) ?: "" + val active1 = Settings.getActiveStatus(context, Constants.SIM1) + val phone2 = Settings.getSIM2PhoneNumber(context) ?: "" + val active2 = Settings.getActiveStatus(context, Constants.SIM2) + + val timestamp = Settings.getHeartbeatTimestamp(context) + val lastHeartbeat = if (timestamp == 0L) { + "--" + } else { + val timestampZdt = ZonedDateTime.ofInstant(Instant.ofEpochMilli(timestamp), ZoneOffset.UTC) + val localTime = timestampZdt.withZoneSameInstant(ZoneId.systemDefault()) + localTime.format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")) + } + + val smsPermissions = arrayOf( + Manifest.permission.SEND_SMS, + Manifest.permission.RECEIVE_SMS, + Manifest.permission.READ_SMS + ) + val allGranted = smsPermissions.all { + context.checkSelfPermission(it) == PackageManager.PERMISSION_GRANTED + } + + val pm = context.getSystemService(Context.POWER_SERVICE) as PowerManager + val batteryOptimized = pm.isIgnoringBatteryOptimizations(context.packageName) + + _uiState.value = _uiState.value.copy( + phoneNumberSIM1 = phone1, + isActiveSIM1 = active1, + phoneNumberSIM2 = phone2, + isActiveSIM2 = active2, + isDualSim = isDualSim, + lastHeartbeatTime = lastHeartbeat, + isSmsPermissionGranted = allGranted, + isBatteryOptimizationDisabled = batteryOptimized, + appVersion = appVersion + ) + } + + fun sendHeartbeat(context: Context, onComplete: (String?) -> Unit) { + _uiState.value = _uiState.value.copy(isHeartbeatLoading = true) + + viewModelScope.launch { + val result = withContext(Dispatchers.IO) { + val charging = Settings.isCharging(context) + try { + val phoneNumbers = mutableListOf() + phoneNumbers.add(Settings.getSIM1PhoneNumber(context)) + if (Settings.getActiveStatus(context, Constants.SIM2)) { + phoneNumbers.add(Settings.getSIM2PhoneNumber(context)) + } + val isStored = HttpSmsApiService.create(context).storeHeartbeat(phoneNumbers.toTypedArray(), charging) + if (!isStored) { + "Could not send heartbeat make sure the phone is connected to the internet" + } else { + Settings.setHeartbeatTimestampAsync(context, System.currentTimeMillis()) + null + } + } catch (exception: Exception) { + Timber.e(exception) + exception.javaClass.simpleName + } + } + + _uiState.value = _uiState.value.copy(isHeartbeatLoading = false) + updateState(context, _uiState.value.appVersion) + onComplete(result) + } + } +} diff --git a/android/app/src/main/java/com/httpsms/ui/settings/SettingsActivityContent.kt b/android/app/src/main/java/com/httpsms/ui/settings/SettingsActivityContent.kt new file mode 100644 index 000000000..3335eb2c2 --- /dev/null +++ b/android/app/src/main/java/com/httpsms/ui/settings/SettingsActivityContent.kt @@ -0,0 +1,221 @@ +package com.httpsms.ui.settings + +import android.app.Activity +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.automirrored.filled.Logout +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.SideEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +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.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.core.view.WindowCompat +import com.httpsms.R +import com.httpsms.ui.theme.LogoGreen + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun SettingsScreen( + viewModel: SettingsViewModel, + onBackClick: () -> Unit, + onLogoutClick: () -> Unit +) { + val uiState by viewModel.uiState.collectAsState() + val context = LocalContext.current + val isDarkTheme = isSystemInDarkTheme() + val primaryColor = if (isDarkTheme) Color.Black else LogoGreen + + val view = LocalView.current + if (!view.isInEditMode) { + SideEffect { + val window = (view.context as Activity).window + window.statusBarColor = primaryColor.toArgb() + WindowCompat.getInsetsController(window, view).isAppearanceLightStatusBars = false + } + } + + Scaffold( + topBar = { + TopAppBar( + title = { Text("App Settings") }, + navigationIcon = { + IconButton(onClick = onBackClick) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back") + } + }, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = primaryColor, + titleContentColor = Color.White, + navigationIconContentColor = Color.White + ) + ) + } + ) { paddingValues -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues) + .verticalScroll(rememberScrollState()) + .padding(16.dp) + ) { + // SIM 1 Settings + OutlinedTextField( + value = uiState.phoneNumberSIM1, + onValueChange = { }, + label = { Text(stringResource(id = R.string.settings_sim1)) }, + modifier = Modifier.fillMaxWidth(), + enabled = false + ) + + SwitchSetting( + text = stringResource(id = R.string.settings_outgoing_messages_sim1), + checked = uiState.isActiveSIM1, + onCheckedChange = { viewModel.setActiveSIM1(context, it) } + ) + + SwitchSetting( + text = stringResource(id = R.string.settings_incoming_messages_sim1), + checked = uiState.isIncomingSIM1Enabled, + onCheckedChange = { viewModel.setIncomingSIM1Enabled(context, it) } + ) + + SwitchSetting( + text = stringResource(id = R.string.enable_incoming_call_events_sim1), + checked = uiState.isIncomingCallEventsSIM1Enabled, + onCheckedChange = { viewModel.setIncomingCallEventsSIM1Enabled(context, it) } + ) + + if (uiState.isDualSim) { + Spacer(modifier = Modifier.height(16.dp)) + // SIM 2 Settings + OutlinedTextField( + value = uiState.phoneNumberSIM2, + onValueChange = { }, + label = { Text(stringResource(id = R.string.settings_sim_2)) }, + modifier = Modifier.fillMaxWidth(), + enabled = false + ) + + SwitchSetting( + text = stringResource(id = R.string.settings_outgoing_messages_sim2), + checked = uiState.isActiveSIM2, + onCheckedChange = { viewModel.setActiveSIM2(context, it) } + ) + + SwitchSetting( + text = stringResource(id = R.string.settings_incoming_messages_sim2), + checked = uiState.isIncomingSIM2Enabled, + onCheckedChange = { viewModel.setIncomingSIM2Enabled(context, it) } + ) + + SwitchSetting( + text = stringResource(id = R.string.enable_sim2_incoming_call_events), + checked = uiState.isIncomingCallEventsSIM2Enabled, + onCheckedChange = { viewModel.setIncomingCallEventsSIM2Enabled(context, it) } + ) + } + + Spacer(modifier = Modifier.height(16.dp)) + + OutlinedTextField( + value = uiState.encryptionKey, + onValueChange = { viewModel.setEncryptionKey(context, it) }, + label = { Text(stringResource(id = R.string.encryption_key)) }, + modifier = Modifier.fillMaxWidth() + ) + + SwitchSetting( + text = stringResource(id = R.string.encrypt_received_messages), + checked = uiState.isEncryptReceivedMessagesEnabled, + onCheckedChange = { viewModel.setEncryptReceivedMessagesEnabled(context, it) }, + enabled = uiState.encryptionKey.isNotEmpty() + ) + + SwitchSetting( + text = stringResource(id = R.string.enable_debug_logs), + checked = uiState.isDebugLogEnabled, + onCheckedChange = { viewModel.setDebugLogEnabled(context, it) } + ) + + Spacer(modifier = Modifier.height(24.dp)) + + Button( + onClick = onLogoutClick, + modifier = Modifier.align(Alignment.CenterHorizontally), + colors = ButtonDefaults.buttonColors(containerColor = Color.Black), + contentPadding = PaddingValues(horizontal = 32.dp, vertical = 16.dp) + ) { + Icon( + imageVector = Icons.AutoMirrored.Filled.Logout, + contentDescription = null, + tint = Color.White + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + stringResource(id = R.string.main_log_out), + color = Color.White, + fontSize = 18.sp + ) + } + } + } +} + +@Composable +fun SwitchSetting( + text: String, + checked: Boolean, + onCheckedChange: (Boolean) -> Unit, + enabled: Boolean = true +) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = text, + modifier = Modifier.weight(1f), + fontSize = 18.sp, + color = if (enabled) MaterialTheme.colorScheme.onBackground else MaterialTheme.colorScheme.onBackground.copy(alpha = 0.5f) + ) + Switch( + checked = checked, + onCheckedChange = onCheckedChange, + enabled = enabled + ) + } +} diff --git a/android/app/src/main/java/com/httpsms/ui/settings/SettingsViewModel.kt b/android/app/src/main/java/com/httpsms/ui/settings/SettingsViewModel.kt new file mode 100644 index 000000000..33a23e574 --- /dev/null +++ b/android/app/src/main/java/com/httpsms/ui/settings/SettingsViewModel.kt @@ -0,0 +1,114 @@ +package com.httpsms.ui.settings + +import android.content.Context +import androidx.lifecycle.ViewModel +import com.httpsms.Constants +import com.httpsms.Settings +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow + +data class SettingsUiState( + val isDebugLogEnabled: Boolean = false, + val phoneNumberSIM1: String = "", + val isIncomingSIM1Enabled: Boolean = false, + val isActiveSIM1: Boolean = false, + val isIncomingCallEventsSIM1Enabled: Boolean = false, + val isDualSim: Boolean = false, + val phoneNumberSIM2: String = "", + val isIncomingSIM2Enabled: Boolean = false, + val isActiveSIM2: Boolean = false, + val isIncomingCallEventsSIM2Enabled: Boolean = false, + val encryptionKey: String = "", + val isEncryptReceivedMessagesEnabled: Boolean = false +) + +class SettingsViewModel : ViewModel() { + private val _uiState = MutableStateFlow(SettingsUiState()) + val uiState = _uiState.asStateFlow() + + fun initialize(context: Context) { + _uiState.value = SettingsUiState( + isDebugLogEnabled = Settings.isDebugLogEnabled(context), + phoneNumberSIM1 = Settings.getSIM1PhoneNumber(context) ?: "", + isIncomingSIM1Enabled = Settings.isIncomingMessageEnabled(context, Constants.SIM1), + isActiveSIM1 = Settings.getActiveStatus(context, Constants.SIM1), + isIncomingCallEventsSIM1Enabled = Settings.isIncomingCallEventsEnabled(context, Constants.SIM1), + isDualSim = Settings.isDualSIM(context), + phoneNumberSIM2 = Settings.getSIM2PhoneNumber(context) ?: "", + isIncomingSIM2Enabled = Settings.isIncomingMessageEnabled(context, Constants.SIM2), + isActiveSIM2 = Settings.getActiveStatus(context, Constants.SIM2), + isIncomingCallEventsSIM2Enabled = Settings.isIncomingCallEventsEnabled(context, Constants.SIM2), + encryptionKey = Settings.getEncryptionKey(context) ?: "", + isEncryptReceivedMessagesEnabled = Settings.encryptReceivedMessages(context) + ) + } + + fun setDebugLogEnabled(context: Context, enabled: Boolean) { + Settings.setDebugLogEnabled(context, enabled) + _uiState.value = _uiState.value.copy(isDebugLogEnabled = enabled) + } + + fun setIncomingSIM1Enabled(context: Context, enabled: Boolean) { + Settings.setIncomingActiveSIM1(context, enabled) + _uiState.value = _uiState.value.copy(isIncomingSIM1Enabled = enabled) + } + + fun setActiveSIM1(context: Context, enabled: Boolean) { + Settings.setActiveStatusAsync(context, enabled, Constants.SIM1) + _uiState.value = _uiState.value.copy(isActiveSIM1 = enabled) + } + + fun setIncomingCallEventsSIM1Enabled(context: Context, enabled: Boolean) { + Settings.setIncomingCallEventsEnabled(context, Constants.SIM1, enabled) + _uiState.value = _uiState.value.copy(isIncomingCallEventsSIM1Enabled = enabled) + } + + fun setIncomingSIM2Enabled(context: Context, enabled: Boolean) { + Settings.setIncomingActiveSIM2(context, enabled) + _uiState.value = _uiState.value.copy(isIncomingSIM2Enabled = enabled) + } + + fun setActiveSIM2(context: Context, enabled: Boolean) { + Settings.setActiveStatusAsync(context, enabled, Constants.SIM2) + _uiState.value = _uiState.value.copy(isActiveSIM2 = enabled) + } + + fun setIncomingCallEventsSIM2Enabled(context: Context, enabled: Boolean) { + Settings.setIncomingCallEventsEnabled(context, Constants.SIM2, enabled) + _uiState.value = _uiState.value.copy(isIncomingCallEventsSIM2Enabled = enabled) + } + + fun setEncryptionKey(context: Context, key: String) { + val trimmedKey = key.trim() + if (trimmedKey.isEmpty()) { + Settings.setEncryptionKey(context, null) + Settings.setEncryptReceivedMessages(context, false) + _uiState.value = _uiState.value.copy(encryptionKey = "", isEncryptReceivedMessagesEnabled = false) + } else { + Settings.setEncryptionKey(context, trimmedKey) + _uiState.value = _uiState.value.copy(encryptionKey = trimmedKey) + } + } + + fun setEncryptReceivedMessagesEnabled(context: Context, enabled: Boolean) { + Settings.setEncryptReceivedMessages(context, enabled) + _uiState.value = _uiState.value.copy(isEncryptReceivedMessagesEnabled = enabled) + } + + fun logout(context: Context, onLogoutComplete: () -> Unit) { + Settings.setApiKeyAsync(context, null) + Settings.setSIM1PhoneNumber(context, null) + Settings.setSIM2PhoneNumber(context, null) + Settings.setActiveStatusAsync(context, true, Constants.SIM1) + Settings.setActiveStatusAsync(context, true, Constants.SIM2) + Settings.setIncomingActiveSIM1(context, true) + Settings.setIncomingActiveSIM2(context, true) + Settings.setUserID(context, null) + Settings.setEncryptionKey(context, null) + Settings.setEncryptReceivedMessages(context, false) + Settings.setFcmTokenLastUpdateTimestampAsync(context, 0) + Settings.setIncomingCallEventsEnabled(context, Constants.SIM1, false) + Settings.setIncomingCallEventsEnabled(context, Constants.SIM2, false) + onLogoutComplete() + } +} diff --git a/android/app/src/main/java/com/httpsms/ui/theme/Theme.kt b/android/app/src/main/java/com/httpsms/ui/theme/Theme.kt new file mode 100644 index 000000000..88c032fa6 --- /dev/null +++ b/android/app/src/main/java/com/httpsms/ui/theme/Theme.kt @@ -0,0 +1,75 @@ +package com.httpsms.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 + +val Blue500 = Color(0xFF2196F3) +val Pink500 = Color(0xFFE91E63) +val LogoGreen = Color(0xFF70AB5C) + +private val DarkColorScheme = darkColorScheme( + primary = Blue500, + secondary = Pink500, + tertiary = Color.White +) + +private val LightColorScheme = lightColorScheme( + primary = Blue500, + secondary = Pink500, + tertiary = Color.Black + + /* Other default colors to override + background = Color(0xFFFFFBFE), + surface = Color(0xFFFFFBFE), + onPrimary = Color.White, + onSecondary = Color.White, + onTertiary = Color.White, + onBackground = Color(0xFF1C1B1F), + onSurface = Color(0xFF1C1B1F), + */ +) + +@Composable +fun HttpSmsTheme( + darkTheme: Boolean = isSystemInDarkTheme(), + // Dynamic color is available on Android 12+ + dynamicColor: Boolean = true, + 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 + ) +} diff --git a/android/app/src/main/java/com/httpsms/ui/theme/Typography.kt b/android/app/src/main/java/com/httpsms/ui/theme/Typography.kt new file mode 100644 index 000000000..d8030fef1 --- /dev/null +++ b/android/app/src/main/java/com/httpsms/ui/theme/Typography.kt @@ -0,0 +1,17 @@ +package com.httpsms.ui.theme + +import androidx.compose.material3.Typography +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.sp + +val Typography = Typography( + bodyLarge = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Normal, + fontSize = 16.sp, + lineHeight = 24.sp, + letterSpacing = 0.5.sp + ) +) diff --git a/android/app/src/main/res/layout/activity_login.xml b/android/app/src/main/res/layout/activity_login.xml deleted file mode 100644 index 03552be57..000000000 --- a/android/app/src/main/res/layout/activity_login.xml +++ /dev/null @@ -1,188 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/android/app/src/main/res/layout/activity_main.xml b/android/app/src/main/res/layout/activity_main.xml deleted file mode 100644 index 758494754..000000000 --- a/android/app/src/main/res/layout/activity_main.xml +++ /dev/null @@ -1,232 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/android/app/src/main/res/layout/activity_settings.xml b/android/app/src/main/res/layout/activity_settings.xml deleted file mode 100644 index bfc078cae..000000000 --- a/android/app/src/main/res/layout/activity_settings.xml +++ /dev/null @@ -1,200 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/android/build.gradle.kts b/android/build.gradle.kts index ee8d1b8d2..3be829b64 100644 --- a/android/build.gradle.kts +++ b/android/build.gradle.kts @@ -5,13 +5,15 @@ buildscript { mavenCentral() } dependencies { - classpath("com.google.gms:google-services:4.4.2") + classpath("com.google.gms:google-services:4.5.0") } } plugins { id("com.android.application") version "9.2.1" apply false id("com.android.library") version "9.2.1" apply false + id("org.jetbrains.kotlin.android") version "2.4.0" apply false + id("org.jetbrains.kotlin.plugin.compose") version "2.4.0" apply false } tasks.register("clean") { diff --git a/web/app/pages/heartbeats/[id].vue b/web/app/pages/heartbeats/[id].vue index 679fd36be..8a05d6944 100644 --- a/web/app/pages/heartbeats/[id].vue +++ b/web/app/pages/heartbeats/[id].vue @@ -170,7 +170,7 @@ onMounted(async () => { - +

Every 15 minutes, the httpSMS app on your Android phone sends a heartbeat event to the httpsms API to show that it is alive. The From f03d163e7f0f61874c0d13f5b527eb6862066c38 Mon Sep 17 00:00:00 2001 From: Acho Arnold Date: Sat, 18 Jul 2026 10:38:05 +0300 Subject: [PATCH 10/45] fix(web): preserve thread archive view (#952) * docs: add design spec for per-phone unarchive thread on receive Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: bf78429e-573c-406c-9f7c-1d6e1bddbbb5 * docs: add implementation plan for unarchive thread on receive Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: bf78429e-573c-406c-9f7c-1d6e1bddbbb5 * docs: design message archive UI Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: bf7ad4f0-3e1b-4f2d-9587-fe5b0dfae6dc * docs: plan message archive UI Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: bf7ad4f0-3e1b-4f2d-9587-fe5b0dfae6dc * fix(web): highlight active thread Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: bf7ad4f0-3e1b-4f2d-9587-fe5b0dfae6dc * fix(web): preserve thread archive filter Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: bf7ad4f0-3e1b-4f2d-9587-fe5b0dfae6dc --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../2026-07-18-message-thread-archive-ui.md | 241 +++++ .../2026-07-18-unarchive-thread-on-receive.md | 848 ++++++++++++++++++ ...-07-18-message-thread-archive-ui-design.md | 37 + ...7-18-unarchive-thread-on-receive-design.md | 118 +++ web/app/components/MessageThread.vue | 1 + web/app/pages/threads/[id]/index.vue | 8 +- web/app/stores/threads.ts | 10 +- 7 files changed, 1255 insertions(+), 8 deletions(-) create mode 100644 docs/superpowers/plans/2026-07-18-message-thread-archive-ui.md create mode 100644 docs/superpowers/plans/2026-07-18-unarchive-thread-on-receive.md create mode 100644 docs/superpowers/specs/2026-07-18-message-thread-archive-ui-design.md create mode 100644 docs/superpowers/specs/2026-07-18-unarchive-thread-on-receive-design.md diff --git a/docs/superpowers/plans/2026-07-18-message-thread-archive-ui.md b/docs/superpowers/plans/2026-07-18-message-thread-archive-ui.md new file mode 100644 index 000000000..3803169c7 --- /dev/null +++ b/docs/superpowers/plans/2026-07-18-message-thread-archive-ui.md @@ -0,0 +1,241 @@ +# Message Thread Archive UI Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Highlight the active message thread with the primary color and make archive actions return to the current filtered thread list with a success notification. + +**Architecture:** Keep archive state transitions in the Pinia threads store: perform the API request, remove the moved thread from the visible collection, clear the selection, and notify. Keep route navigation in the thread page, and use Vuetify's built-in active-item color handling for the list styling. + +**Tech Stack:** Nuxt 4, Vue 3, Pinia 3, Vuetify 4, TypeScript, pnpm + +## Global Constraints + +- Preserve the current `archivedThreads` filter after archive and unarchive actions. +- Show exactly `Archived` or `Unarchived` as a success notification after a successful update. +- Do not change local thread state or navigate when the API request fails. +- Use existing dependencies and validation commands only; the web package has no configured automated test runner. +- Include the repository's `Co-authored-by` and `Copilot-Session` trailers in every commit. + +--- + +### Task 1: Active Thread Primary Color + +**Files:** +- Modify: `web/app/components/MessageThread.vue:94-100` + +**Interfaces:** +- Consumes: `threadsStore.threadId` and each thread's route-backed `v-list-item` +- Produces: Vuetify active-item styling through `color="primary"` + +- [ ] **Step 1: Run the source assertion to verify the active color is missing** + +Run from `web/`: + +```powershell +node -e "const fs=require('fs');const s=fs.readFileSync('app/components/MessageThread.vue','utf8');if(!/ +``` + +- [ ] **Step 3: Re-run the source assertion** + +Run from `web/`: + +```powershell +node -e "const fs=require('fs');const s=fs.readFileSync('app/components/MessageThread.vue','utf8');if(!/`nCopilot-Session: bf7ad4f0-3e1b-4f2d-9587-fe5b0dfae6dc" +``` + +### Task 2: Archive Without Switching Filters + +**Files:** +- Modify: `web/app/stores/threads.ts:78-91` +- Modify: `web/app/pages/threads/[id]/index.vue:146-166` + +**Interfaces:** +- Consumes: `updateThread({ threadId: string, isArchived: boolean }): Promise` +- Produces: successful archive updates that remove the moved thread, clear `threadId`, notify, and allow the page to navigate to `/threads` + +- [ ] **Step 1: Run source assertions to verify the required behavior is missing** + +Run from `web/`: + +```powershell +node -e "const fs=require('fs');const s=fs.readFileSync('app/stores/threads.ts','utf8');for(const x of [\"threads.value = threads.value.filter\",'threadId.value = null',\"payload.isArchived ? 'Archived' : 'Unarchived'\"])if(!s.includes(x))throw new Error('archive state transition missing: '+x)" +node -e "const fs=require('fs');const s=fs.readFileSync('app/pages/threads/[id]/index.vue','utf8');const matches=s.match(/await router\.push\('\/threads'\)/g)||[];if(matches.length<3)throw new Error('archive navigation missing')" +``` + +Expected: both commands FAIL because the store still switches `archivedThreads` and the archive actions do not navigate. + +- [ ] **Step 2: Replace the store's archive-filter switch and reload** + +Replace the statements after the successful `apiFetch` call in `updateThread` with: + +```ts +threads.value = threads.value.filter( + (thread) => thread.id !== payload.threadId, +) +threadId.value = null +notificationsStore.addNotification({ + message: payload.isArchived ? 'Archived' : 'Unarchived', + type: 'success', +}) +``` + +The complete function must remain: + +```ts +async function updateThread(payload: { + threadId: string + isArchived: boolean +}) { + await apiFetch(`/v1/message-threads/${payload.threadId}`, { + method: 'PUT', + body: { is_archived: payload.isArchived }, + }) + threads.value = threads.value.filter( + (thread) => thread.id !== payload.threadId, + ) + threadId.value = null + notificationsStore.addNotification({ + message: payload.isArchived ? 'Archived' : 'Unarchived', + type: 'success', + }) +} +``` + +- [ ] **Step 3: Route to the current filtered list after archive** + +Change `archiveThread` to: + +```ts +async function archiveThread() { + await threadsStore.updateThread({ + threadId: threadsStore.currentThread!.id, + isArchived: true, + }) + await router.push('/threads') +} +``` + +- [ ] **Step 4: Route to the current filtered list after unarchive** + +Change `unArchiveThread` to: + +```ts +async function unArchiveThread() { + await threadsStore.updateThread({ + threadId: threadsStore.currentThread!.id, + isArchived: false, + }) + await router.push('/threads') +} +``` + +- [ ] **Step 5: Re-run the source assertions** + +Run from `web/`: + +```powershell +node -e "const fs=require('fs');const s=fs.readFileSync('app/stores/threads.ts','utf8');for(const x of [\"threads.value = threads.value.filter\",'threadId.value = null',\"payload.isArchived ? 'Archived' : 'Unarchived'\"])if(!s.includes(x))throw new Error('archive state transition missing: '+x);if(s.includes('archivedThreads.value = payload.isArchived'))throw new Error('archive filter still switches')" +node -e "const fs=require('fs');const s=fs.readFileSync('app/pages/threads/[id]/index.vue','utf8');const matches=s.match(/await router\.push\('\/threads'\)/g)||[];if(matches.length<3)throw new Error('archive navigation missing')" +``` + +Expected: both commands exit with code 0. + +- [ ] **Step 6: Lint the changed behavior** + +Run from `web/`: + +```powershell +pnpm exec eslint app/stores/threads.ts "app/pages/threads/[id]/index.vue" +pnpm exec stylelint "app/pages/threads/[id]/index.vue" +pnpm exec prettier --check app/stores/threads.ts "app/pages/threads/[id]/index.vue" +``` + +Expected: all commands exit with code 0. + +- [ ] **Step 7: Commit** + +```powershell +git add web/app/stores/threads.ts "web/app/pages/threads/[id]/index.vue" +git commit -m "fix(web): preserve thread archive filter" -m "Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>`nCopilot-Session: bf7ad4f0-3e1b-4f2d-9587-fe5b0dfae6dc" +``` + +### Task 3: Production Validation + +**Files:** +- Verify: `web/app/components/MessageThread.vue` +- Verify: `web/app/stores/threads.ts` +- Verify: `web/app/pages/threads/[id]/index.vue` + +**Interfaces:** +- Consumes: completed UI and archive behavior from Tasks 1 and 2 +- Produces: lint-clean, production-generated frontend output + +- [ ] **Step 1: Run all targeted lint checks together** + +Run from `web/`: + +```powershell +pnpm exec eslint app/components/MessageThread.vue app/stores/threads.ts "app/pages/threads/[id]/index.vue" +pnpm exec stylelint app/components/MessageThread.vue "app/pages/threads/[id]/index.vue" +pnpm exec prettier --check app/components/MessageThread.vue app/stores/threads.ts "app/pages/threads/[id]/index.vue" +``` + +Expected: all commands exit with code 0. + +- [ ] **Step 2: Generate the production static site** + +Run from `web/`: + +```powershell +pnpm generate +``` + +Expected: Nuxt generation completes successfully and writes the static output. + +- [ ] **Step 3: Check the final diff** + +Run from the repository root: + +```powershell +git diff main...HEAD --check +git status --short +``` + +Expected: no whitespace errors and a clean working tree. diff --git a/docs/superpowers/plans/2026-07-18-unarchive-thread-on-receive.md b/docs/superpowers/plans/2026-07-18-unarchive-thread-on-receive.md new file mode 100644 index 000000000..70ec8969b --- /dev/null +++ b/docs/superpowers/plans/2026-07-18-unarchive-thread-on-receive.md @@ -0,0 +1,848 @@ +# Unarchive Thread on Receive Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Let an archived message thread automatically move back to the inbox when a new inbound message is received, configurable per phone number. + +**Architecture:** Add a per-phone boolean `UnarchiveThread` (default false). It is copied onto the `message.phone.received` event payload when a message is received, carried through the thread listener into `MessageThreadUpdateParams`, and applied by `MessageThreadService.UpdateThread`, which clears `IsArchived` only for inbound (received) messages when the flag is true. A per-phone toggle is exposed in the web settings page. + +**Tech Stack:** Go 1.x (Fiber v3, GORM, stacktrace, testify), Nuxt 4 / Vue 3 (Vuetify 4, Pinia), swaggo/swag, swagger-typescript-api. + +## Global Constraints + +- Field name is exactly `UnarchiveThread` (Go) / `unarchive_thread` (JSON) everywhere. +- Default value is `false` (opt-in). GORM tag: `gorm:"default:false"`. +- Only inbound messages (`entities.MessageStatusReceived`) may unarchive a thread. +- Wrap all Go errors with `github.com/palantir/stacktrace` (never return bare errors). +- Go formatting via `go-fumpt` (pre-commit). Run `go test ./...` from `api/`. +- After changing Swagger annotations run, in `api/`: `swag init --requiredByDefault --parseDependency --parseInternal`. +- After Swagger regen, regenerate web models in `web/`: `pnpm api:models`. +- Web: no semicolons, single quotes, 2-space indent (Prettier/ESLint). Lint via `pnpm lint`. +- The `docs/` directory is gitignored in this repo; this plan and its spec were committed with `git add -f`. Do NOT `-f` add source files — only the plan/spec docs need force-add. + +## File Structure + +- `api/pkg/entities/phone.go` — add `UnarchiveThread` field to `Phone`. +- `api/pkg/events/message_phone_received_event.go` — add `UnarchiveThread` to `MessagePhoneReceivedPayload`. +- `api/pkg/services/message_service.go` — populate `UnarchiveThread` on the received-event payload from the loaded phone. +- `api/pkg/services/message_thread_service.go` — add `UnarchiveThread` to `MessageThreadUpdateParams`; add pure helper `shouldUnarchive`; apply it in `UpdateThread`. +- `api/pkg/services/message_thread_service_test.go` — NEW unit tests for `shouldUnarchive`. +- `api/pkg/listeners/message_thread_listener.go` — pass `payload.UnarchiveThread` into params in `OnMessagePhoneReceived`. +- `api/pkg/requests/phone_update_request.go` — add `UnarchiveThread *bool` request field + partial-update mapping. +- `api/pkg/services/phone_service.go` — add `UnarchiveThread *bool` to `PhoneUpsertParams`; apply in `update`. +- `web/app/stores/phones.ts` — send `unarchive_thread` in `updatePhone` PUT body. +- `web/app/pages/settings/index.vue` — add a per-phone toggle bound to `activePhone.unarchive_thread`. +- `web/shared/types/api.ts` — regenerated (not hand-edited). +- `tests/unarchive_thread_integration_test.go` — NEW end-to-end integration test (package `tests`, runs against the live Docker stack). + +--- + +### Task 1: Add `UnarchiveThread` field to the Phone entity + +**Files:** +- Modify: `api/pkg/entities/phone.go` + +**Interfaces:** +- Consumes: nothing. +- Produces: `entities.Phone.UnarchiveThread bool` (JSON `unarchive_thread`). + +- [ ] **Step 1: Add the field** + +In `api/pkg/entities/phone.go`, inside the `Phone` struct, add the field just below `MissedCallAutoReply` (keep the existing blank-line grouping style): + +```go + MissedCallAutoReply *string `json:"missed_call_auto_reply" example:"This phone cannot receive calls. Please send an SMS instead." validate:"optional"` + + // UnarchiveThread moves an archived message thread back to the inbox when a new message is received on this phone. + UnarchiveThread bool `json:"unarchive_thread" gorm:"default:false" example:"false"` +``` + +- [ ] **Step 2: Verify it compiles** + +Run (from `api/`): `go build ./...` +Expected: no output, exit code 0. + +- [ ] **Step 3: Commit** + +```bash +git add api/pkg/entities/phone.go +git commit -m "feat(api): add UnarchiveThread setting to Phone entity" +``` + +--- + +### Task 2: Add `UnarchiveThread` to the received-message event payload + +**Files:** +- Modify: `api/pkg/events/message_phone_received_event.go` + +**Interfaces:** +- Consumes: nothing. +- Produces: `events.MessagePhoneReceivedPayload.UnarchiveThread bool` (JSON `unarchive_thread`). + +- [ ] **Step 1: Add the field** + +In `api/pkg/events/message_phone_received_event.go`, add the field to the `MessagePhoneReceivedPayload` struct (after `Attachments`): + +```go +type MessagePhoneReceivedPayload struct { + MessageID uuid.UUID `json:"message_id"` + UserID entities.UserID `json:"user_id"` + Owner string `json:"owner"` + Encrypted bool `json:"encrypted"` + Contact string `json:"contact"` + Timestamp time.Time `json:"timestamp"` + Content string `json:"content"` + SIM entities.SIM `json:"sim"` + Attachments []string `json:"attachments"` + UnarchiveThread bool `json:"unarchive_thread"` +} +``` + +- [ ] **Step 2: Verify it compiles** + +Run (from `api/`): `go build ./...` +Expected: exit code 0. + +- [ ] **Step 3: Commit** + +```bash +git add api/pkg/events/message_phone_received_event.go +git commit -m "feat(api): add UnarchiveThread to MessagePhoneReceivedPayload" +``` + +--- + +### Task 3: Populate `UnarchiveThread` on the payload in ReceiveMessage + +**Files:** +- Modify: `api/pkg/services/message_service.go` (function `ReceiveMessage`, around lines 333-362) + +**Interfaces:** +- Consumes: `entities.Phone.UnarchiveThread` (Task 1); `events.MessagePhoneReceivedPayload.UnarchiveThread` (Task 2); existing `service.phoneService.Load(ctx, userID, ownerE164 string) (*entities.Phone, error)`. +- Produces: received events now carry the phone's `UnarchiveThread` value. + +- [ ] **Step 1: Load the phone and set the flag** + +In `ReceiveMessage`, the owner E.164 string is `phonenumbers.Format(¶ms.Owner, phonenumbers.E164)`. Replace the `eventPayload := events.MessagePhoneReceivedPayload{...}` block so the owner string is computed once and the phone setting is looked up. Insert BEFORE the `eventPayload :=` assignment: + +```go + owner := phonenumbers.Format(¶ms.Owner, phonenumbers.E164) + + unarchiveThread := false + phone, err := service.phoneService.Load(ctx, params.UserID, owner) + if err != nil { + ctxLogger.Warn(stacktrace.Propagate(err, fmt.Sprintf("cannot load phone [%s] for user [%s] to resolve UnarchiveThread; defaulting to false", owner, params.UserID))) + } else { + unarchiveThread = phone.UnarchiveThread + } +``` + +Then update the payload literal to reuse `owner` and set the flag: + +```go + eventPayload := events.MessagePhoneReceivedPayload{ + MessageID: messageID, + UserID: params.UserID, + Encrypted: params.Encrypted, + Owner: owner, + Contact: params.Contact, + Timestamp: params.Timestamp, + Content: params.Content, + SIM: params.SIM, + Attachments: attachmentURLs, + UnarchiveThread: unarchiveThread, + } +``` + +Note: `err` is already declared earlier in `ReceiveMessage` (from the attachments upload), so use `=` not `:=` for the phone load, as shown. Confirm `stacktrace` is already imported in this file (it is). + +- [ ] **Step 2: Verify it compiles** + +Run (from `api/`): `go build ./...` +Expected: exit code 0. If you get "err redeclared" or "err not used", ensure the phone load uses `phone, err = ...` on its own line (not `:=`) and that `phone` is newly declared with `:=` — since `phone` is new and `err` exists, `phone, err := ...` is correct Go (at least one new var on the left). Prefer `phone, err := service.phoneService.Load(...)`. + +Correction to Step 1: use `phone, err := service.phoneService.Load(ctx, params.UserID, owner)` (mixed assignment is valid because `phone` is new). Keep the rest as written. + +- [ ] **Step 3: Run existing message service tests** + +Run (from `api/`): `go test ./pkg/services/ -run TestMessageService -v` +Expected: PASS (these are pure helper tests unaffected by this change). Also run `go build ./...` again to be safe. + +- [ ] **Step 4: Commit** + +```bash +git add api/pkg/services/message_service.go +git commit -m "feat(api): populate UnarchiveThread from phone on received event" +``` + +--- + +### Task 4: Thread unarchive decision helper + wiring (TDD) + +**Files:** +- Modify: `api/pkg/services/message_thread_service.go` +- Create: `api/pkg/services/message_thread_service_test.go` + +**Interfaces:** +- Consumes: `entities.MessageThread.IsArchived`; `entities.MessageStatusReceived`; `MessageThreadUpdateParams`. +- Produces: + - `MessageThreadUpdateParams.UnarchiveThread bool` + - `func (service *MessageThreadService) shouldUnarchive(thread *entities.MessageThread, params MessageThreadUpdateParams) bool` + +- [ ] **Step 1: Write the failing test** + +Create `api/pkg/services/message_thread_service_test.go`: + +```go +package services + +import ( + "testing" + + "github.com/NdoleStudio/httpsms/pkg/entities" + "github.com/stretchr/testify/assert" +) + +func TestShouldUnarchive(t *testing.T) { + service := &MessageThreadService{} + + archived := &entities.MessageThread{IsArchived: true} + notArchived := &entities.MessageThread{IsArchived: false} + + received := MessageThreadUpdateParams{Status: entities.MessageStatusReceived, UnarchiveThread: true} + receivedFlagOff := MessageThreadUpdateParams{Status: entities.MessageStatusReceived, UnarchiveThread: false} + sentFlagOn := MessageThreadUpdateParams{Status: entities.MessageStatusSent, UnarchiveThread: true} + + assert.True(t, service.shouldUnarchive(archived, received), "archived + inbound + flag on -> unarchive") + assert.False(t, service.shouldUnarchive(archived, receivedFlagOff), "flag off -> no unarchive") + assert.False(t, service.shouldUnarchive(archived, sentFlagOn), "outbound status -> no unarchive") + assert.False(t, service.shouldUnarchive(notArchived, received), "already unarchived -> no change") +} +``` + +Note: confirm `entities.MessageStatusSent` exists (it does — used in listeners). If not, substitute any non-received status constant such as `entities.MessageStatusPending`. + +- [ ] **Step 2: Run the test to verify it fails** + +Run (from `api/`): `go test ./pkg/services/ -run TestShouldUnarchive -v` +Expected: FAIL — compile error `service.shouldUnarchive undefined` and `params.UnarchiveThread` unknown field. + +- [ ] **Step 3: Add the param field and the helper** + +In `api/pkg/services/message_thread_service.go`, add the field to `MessageThreadUpdateParams`: + +```go +type MessageThreadUpdateParams struct { + Owner string + Status entities.MessageStatus + Contact string + Content string + UserID entities.UserID + MessageID uuid.UUID + Timestamp time.Time + UnarchiveThread bool +} +``` + +Then add the pure helper (place it directly above `UpdateThread`): + +```go +// shouldUnarchive reports whether an archived thread should be moved back to +// the inbox because a new inbound message was received and the phone has the +// UnarchiveThread setting enabled. +func (service *MessageThreadService) shouldUnarchive(thread *entities.MessageThread, params MessageThreadUpdateParams) bool { + return thread.IsArchived && params.UnarchiveThread && params.Status == entities.MessageStatusReceived +} +``` + +- [ ] **Step 4: Run the test to verify it passes** + +Run (from `api/`): `go test ./pkg/services/ -run TestShouldUnarchive -v` +Expected: PASS. + +- [ ] **Step 5: Apply the decision inside UpdateThread** + +In `UpdateThread`, the thread is updated via `thread.Update(params.Timestamp, params.MessageID, params.Content, params.Status)`. Immediately BEFORE the `if err = service.repository.Update(ctx, thread.Update(...))` call, add: + +```go + if service.shouldUnarchive(thread, params) { + thread.UpdateArchive(false) + ctxLogger.Info(fmt.Sprintf("unarchiving thread [%s] after inbound message [%s]", thread.ID, params.MessageID)) + } +``` + +`thread.UpdateArchive(false)` mutates the same `thread` pointer that `thread.Update(...)` mutates and persists, so the single `repository.Update` call saves both the new last message and the cleared archive flag. Do not add a second Update call. + +- [ ] **Step 6: Run the full services test package** + +Run (from `api/`): `go test ./pkg/services/ -v` +Expected: PASS (including `TestShouldUnarchive` and existing `TestMessageService*`). + +- [ ] **Step 7: Commit** + +```bash +git add api/pkg/services/message_thread_service.go api/pkg/services/message_thread_service_test.go +git commit -m "feat(api): unarchive thread on inbound message when enabled" +``` + +--- + +### Task 5: Pass the flag through the received-message listener + +**Files:** +- Modify: `api/pkg/listeners/message_thread_listener.go` (function `OnMessagePhoneReceived`) + +**Interfaces:** +- Consumes: `events.MessagePhoneReceivedPayload.UnarchiveThread` (Task 2); `MessageThreadUpdateParams.UnarchiveThread` (Task 4). +- Produces: inbound received events now request unarchiving. + +- [ ] **Step 1: Set the field on the params** + +In `OnMessagePhoneReceived`, update the `updateParams := services.MessageThreadUpdateParams{...}` literal to include the flag: + +```go + updateParams := services.MessageThreadUpdateParams{ + Owner: payload.Owner, + Contact: payload.Contact, + Timestamp: payload.Timestamp, + UserID: payload.UserID, + Status: entities.MessageStatusReceived, + Content: payload.Content, + MessageID: payload.MessageID, + UnarchiveThread: payload.UnarchiveThread, + } +``` + +Do NOT set `UnarchiveThread` on any other handler in this file (only inbound received messages unarchive). + +- [ ] **Step 2: Verify it compiles and tests pass** + +Run (from `api/`): `go build ./...` then `go test ./pkg/services/... ./pkg/listeners/...` +Expected: exit code 0 / PASS. + +- [ ] **Step 3: Commit** + +```bash +git add api/pkg/listeners/message_thread_listener.go +git commit -m "feat(api): forward UnarchiveThread flag from received event to thread update" +``` + +--- + +### Task 6: Accept `unarchive_thread` in the phone upsert request/service + +**Files:** +- Modify: `api/pkg/requests/phone_update_request.go` +- Modify: `api/pkg/services/phone_service.go` (`PhoneUpsertParams` struct and `update` method) + +**Interfaces:** +- Consumes: `entities.Phone.UnarchiveThread` (Task 1). +- Produces: `PhoneUpsertParams.UnarchiveThread *bool`; PUT `/v1/phones` persists the setting. + +- [ ] **Step 1: Add the request field** + +In `api/pkg/requests/phone_update_request.go`, add to the `PhoneUpsert` struct (after `MissedCallAutoReply`): + +```go + MissedCallAutoReply *string `json:"missed_call_auto_reply" example:"e.g. This phone cannot receive calls. Please send an SMS instead."` + + // UnarchiveThread moves an archived thread back to the inbox when a new message is received on this phone. + UnarchiveThread bool `json:"unarchive_thread" example:"false"` +``` + +- [ ] **Step 2: Map it as a partial-update field in ToUpsertParams** + +Still in `phone_update_request.go`, inside `ToUpsertParams`, after the `maxSendAttempts` block, add the presence-detected mapping (mirrors the existing pattern): + +```go + var unarchiveThread *bool + if _, exists := fields["unarchive_thread"]; exists { + unarchiveThread = &input.UnarchiveThread + } +``` + +Then add `UnarchiveThread: unarchiveThread,` to the returned `&services.PhoneUpsertParams{...}` literal. + +- [ ] **Step 3: Add the field to PhoneUpsertParams and apply it** + +In `api/pkg/services/phone_service.go`, add to `PhoneUpsertParams`: + +```go + UnarchiveThread *bool +``` + +In the `update` method, before `phone.SIM = params.SIM`, add: + +```go + if params.UnarchiveThread != nil { + phone.UnarchiveThread = *params.UnarchiveThread + } +``` + +- [ ] **Step 4: Verify build and tests** + +Run (from `api/`): `go build ./...` then `go test ./pkg/...` +Expected: exit code 0 / PASS. + +- [ ] **Step 5: Regenerate Swagger docs** + +Run (from `api/`): `swag init --requiredByDefault --parseDependency --parseInternal` +Expected: regenerates `api/docs/*` including the new `unarchive_thread` fields. If `swag` is not installed: `go install github.com/swaggo/swag/cmd/swag@latest` then re-run. + +- [ ] **Step 6: Commit** + +```bash +git add api/pkg/requests/phone_update_request.go api/pkg/services/phone_service.go api/docs +git commit -m "feat(api): accept unarchive_thread in phone upsert request" +``` + +--- + +### Task 7: Regenerate web API models + +**Files:** +- Modify (generated): `web/shared/types/api.ts` + +**Interfaces:** +- Consumes: regenerated Swagger from Task 6. +- Produces: `EntitiesPhone.unarchive_thread?: boolean` available to the web app. + +- [ ] **Step 1: Install web deps (if not already installed)** + +Run (from `web/`): `pnpm install` +Expected: completes without `ERR_PNPM_IGNORED_BUILDS`. + +- [ ] **Step 2: Regenerate models** + +Run (from `web/`): `pnpm api:models` +Expected: `web/shared/types/api.ts` now includes `unarchive_thread` on the phone type (search the file for `unarchive_thread`). + +- [ ] **Step 3: Verify the field exists** + +Run (from `web/`): `Select-String -Path shared/types/api.ts -Pattern unarchive_thread` +Expected: at least one match. + +- [ ] **Step 4: Commit** + +```bash +git add web/shared/types/api.ts +git commit -m "chore(web): regenerate api models with unarchive_thread" +``` + +--- + +### Task 8: Send `unarchive_thread` in the phones store update + +**Files:** +- Modify: `web/app/stores/phones.ts` (function `updatePhone`, around lines 48-62) + +**Interfaces:** +- Consumes: `EntitiesPhone.unarchive_thread` (Task 7). +- Produces: PUT `/v1/phones` body includes `unarchive_thread`. + +- [ ] **Step 1: Add the field to the PUT body** + +In `updatePhone`, inside the `body` object passed to `apiFetch('/v1/phones', { method: 'PUT', ... })`, add after `message_send_schedule_id`: + +```ts + message_send_schedule_id: phone.message_send_schedule_id ?? null, + unarchive_thread: phone.unarchive_thread ?? false, +``` + +- [ ] **Step 2: Lint** + +Run (from `web/`): `pnpm lint` +Expected: no new errors for `web/app/stores/phones.ts`. + +- [ ] **Step 3: Commit** + +```bash +git add web/app/stores/phones.ts +git commit -m "feat(web): send unarchive_thread in updatePhone" +``` + +--- + +### Task 9: Add the per-phone toggle to the settings UI + +**Files:** +- Modify: `web/app/pages/settings/index.vue` (phone settings card, near the `missed_call_auto_reply` VTextarea around lines 1680-1690) + +**Interfaces:** +- Consumes: `activePhone.unarchive_thread` (Task 7); `updatePhone` store action (Task 8). +- Produces: user-visible toggle that persists via the existing "Update Phone" button. + +- [ ] **Step 1: Add a VSwitch bound to activePhone.unarchive_thread** + +In `web/app/pages/settings/index.vue`, immediately AFTER the closing `/>` of the `missed_call_auto_reply` `` (line ~1690) and before the ``, add: + +```html + +``` + +Note: match the surrounding indentation exactly (this block sits inside the same `` as the textarea). If `activePhone` is typed and `unarchive_thread` is optional, `v-model` still works; the store update coerces with `?? false`. + +- [ ] **Step 2: Lint** + +Run (from `web/`): `pnpm lint` +Expected: no new errors for `settings/index.vue`. Auto-fix formatting if needed with `pnpm lintfix`. + +- [ ] **Step 3: Build the site to confirm the template compiles** + +Run (from `web/`): `pnpm run generate` +Expected: build completes without template/compile errors. (If `generate` is heavy, `pnpm dev` briefly and confirm the settings page renders is an acceptable alternative.) + +- [ ] **Step 4: Commit** + +```bash +git add web/app/pages/settings/index.vue +git commit -m "feat(web): add per-phone unarchive-thread toggle to settings" +``` + +--- + +### Task 10: Full validation + +**Files:** none (verification only). + +- [ ] **Step 1: API full test + build** + +Run (from `api/`): `go test ./...` then `go build ./...` +Expected: all PASS, exit code 0. + +- [ ] **Step 2: Web lint + test** + +Run (from `web/`): `pnpm lint` then `pnpm test` +Expected: PASS. + +- [ ] **Step 3: Manual smoke (optional, if a local stack is available)** + +Start the stack (`docker compose up --build`), open the web settings for a phone, enable "Unarchive thread on new message", save. Archive a thread, then simulate/receive an inbound message for that owner and confirm the thread returns to the inbox. Toggle off and confirm an inbound message leaves the thread archived. + +- [ ] **Step 4: Final commit (only if any fixups were needed)** + +```bash +git add -A +git commit -m "chore: finalize unarchive-thread-on-receive feature" +``` + +--- + +### Task 11: End-to-end integration test + +**Files:** +- Create: `tests/unarchive_thread_integration_test.go` + +**Context:** The `tests/` module is a black-box integration suite (package `tests`) +that runs against the full Docker stack (API on `localhost:8000`, CockroachDB, +Redis, phone emulator). It uses the external `github.com/NdoleStudio/httpsms-go` +client for convenience, but that client does NOT know about the new +`unarchive_thread` field — so this test sets the phone flag with a raw HTTP PUT +to `/v1/phones` and reads threads with raw HTTP GETs. It reuses existing helpers +from `helpers_test.go`: `apiBaseURL`, `userAPIKey`, `newAPIClient`, `setupPhone`, +`randomPhoneNumber`, `randomEncryptionKey`, `pollMessageStatus`. + +**Interfaces:** +- Consumes: the running API with all prior tasks deployed; endpoints + `PUT /v1/phones`, `POST /v1/messages/receive`, `GET /v1/message-threads`, + `PUT /v1/message-threads/{id}`. +- Produces: `TestUnarchiveThreadOnReceive_Enabled` and + `TestUnarchiveThreadOnReceive_Disabled`. + +- [ ] **Step 1: Write the integration test file** + +Create `tests/unarchive_thread_integration_test.go`: + +```go +package tests + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "testing" + "time" + + httpsms "github.com/NdoleStudio/httpsms-go" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +type integrationThread struct { + ID string `json:"id"` + Contact string `json:"contact"` + Owner string `json:"owner"` + IsArchived bool `json:"is_archived"` + LastMessageContent *string `json:"last_message_content"` +} + +// setUnarchiveThread flips the per-phone unarchive_thread flag via a raw PUT +// (the httpsms-go client has no field for it). +func setUnarchiveThread(ctx context.Context, t *testing.T, phoneNumber string, enabled bool) { + t.Helper() + + payload := map[string]interface{}{ + "phone_number": phoneNumber, + "sim": "SIM1", + "unarchive_thread": enabled, + } + body, err := json.Marshal(payload) + require.NoError(t, err) + + req, err := http.NewRequestWithContext(ctx, http.MethodPut, apiBaseURL+"/v1/phones", bytes.NewReader(body)) + require.NoError(t, err) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("x-api-key", userAPIKey) + + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + defer resp.Body.Close() + + respBody, err := io.ReadAll(resp.Body) + require.NoError(t, err) + require.Equal(t, http.StatusOK, resp.StatusCode, "set unarchive_thread failed: %s", string(respBody)) +} + +// receiveInbound submits an inbound message as the phone and returns the message ID. +func receiveInbound(ctx context.Context, t *testing.T, phoneAPIKey, from, to, content string, ts time.Time) string { + t.Helper() + + payload := map[string]interface{}{ + "from": from, + "to": to, + "content": content, + "sim": "SIM1", + "timestamp": ts.UTC().Format(time.RFC3339), + } + body, err := json.Marshal(payload) + require.NoError(t, err) + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, apiBaseURL+"/v1/messages/receive", bytes.NewReader(body)) + require.NoError(t, err) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("x-api-key", phoneAPIKey) + + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + defer resp.Body.Close() + + respBody, err := io.ReadAll(resp.Body) + require.NoError(t, err) + require.Equal(t, http.StatusOK, resp.StatusCode, "receive failed: %s", string(respBody)) + + var result httpsms.MessageResponse + require.NoError(t, json.Unmarshal(respBody, &result)) + id := result.Data.ID.String() + require.NotEmpty(t, id) + return id +} + +// fetchThreads returns threads for an owner filtered by archived state. +func fetchThreads(ctx context.Context, t *testing.T, owner string, archived bool) []integrationThread { + t.Helper() + + url := fmt.Sprintf("%s/v1/message-threads?owner=%s&is_archived=%t&limit=20", apiBaseURL, owner, archived) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + require.NoError(t, err) + req.Header.Set("x-api-key", userAPIKey) + + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + defer resp.Body.Close() + + respBody, err := io.ReadAll(resp.Body) + require.NoError(t, err) + require.Equal(t, http.StatusOK, resp.StatusCode, "fetch threads failed: %s", string(respBody)) + + var result struct { + Data []integrationThread `json:"data"` + } + require.NoError(t, json.Unmarshal(respBody, &result)) + return result.Data +} + +func findThreadByContact(threads []integrationThread, contact string) *integrationThread { + for i := range threads { + if threads[i].Contact == contact { + return &threads[i] + } + } + return nil +} + +// waitForThread polls the archived/unarchived thread list until a thread for the +// contact appears (optionally matching a last-message content), then returns it. +func waitForThread(ctx context.Context, t *testing.T, owner, contact string, archived bool, wantContent string, timeout time.Duration) *integrationThread { + t.Helper() + deadline := time.Now().Add(timeout) + for time.Now().Before(deadline) { + thread := findThreadByContact(fetchThreads(ctx, t, owner, archived), contact) + if thread != nil && (wantContent == "" || (thread.LastMessageContent != nil && *thread.LastMessageContent == wantContent)) { + return thread + } + time.Sleep(500 * time.Millisecond) + } + return nil +} + +// archiveThread archives a thread by ID. +func archiveThread(ctx context.Context, t *testing.T, threadID string) { + t.Helper() + + body, err := json.Marshal(map[string]interface{}{"is_archived": true}) + require.NoError(t, err) + + req, err := http.NewRequestWithContext(ctx, http.MethodPut, apiBaseURL+"/v1/message-threads/"+threadID, bytes.NewReader(body)) + require.NoError(t, err) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("x-api-key", userAPIKey) + + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + defer resp.Body.Close() + + respBody, err := io.ReadAll(resp.Body) + require.NoError(t, err) + require.Equal(t, http.StatusOK, resp.StatusCode, "archive thread failed: %s", string(respBody)) +} + +func TestUnarchiveThreadOnReceive_Enabled(t *testing.T) { + ctx := context.Background() + phone := setupPhone(ctx, t, 60) + setUnarchiveThread(ctx, t, phone.PhoneNumber, true) + + contact := randomPhoneNumber() + content1 := "first inbound " + randomEncryptionKey() + content2 := "second inbound " + randomEncryptionKey() + + // First inbound message creates the thread. + msgID1 := receiveInbound(ctx, t, phone.PhoneAPIKey, contact, phone.PhoneNumber, content1, time.Now().Add(-1*time.Minute)) + pollMessageStatus(ctx, t, msgID1, "received", 15*time.Second) + + thread := waitForThread(ctx, t, phone.PhoneNumber, contact, false, "", 15*time.Second) + require.NotNil(t, thread, "thread not created for contact %s", contact) + + // Archive it. + archiveThread(ctx, t, thread.ID) + archived := waitForThread(ctx, t, phone.PhoneNumber, contact, true, "", 10*time.Second) + require.NotNil(t, archived, "thread was not archived") + + // Second inbound message should unarchive it. + msgID2 := receiveInbound(ctx, t, phone.PhoneAPIKey, contact, phone.PhoneNumber, content2, time.Now()) + pollMessageStatus(ctx, t, msgID2, "received", 15*time.Second) + + unarchived := waitForThread(ctx, t, phone.PhoneNumber, contact, false, content2, 20*time.Second) + require.NotNil(t, unarchived, "thread was not unarchived after inbound message") + assert.False(t, unarchived.IsArchived) + require.NotNil(t, unarchived.LastMessageContent) + assert.Equal(t, content2, *unarchived.LastMessageContent) +} + +func TestUnarchiveThreadOnReceive_Disabled(t *testing.T) { + ctx := context.Background() + phone := setupPhone(ctx, t, 60) // unarchive_thread defaults to false; do not enable it + + contact := randomPhoneNumber() + content1 := "first inbound " + randomEncryptionKey() + content2 := "second inbound " + randomEncryptionKey() + + msgID1 := receiveInbound(ctx, t, phone.PhoneAPIKey, contact, phone.PhoneNumber, content1, time.Now().Add(-1*time.Minute)) + pollMessageStatus(ctx, t, msgID1, "received", 15*time.Second) + + thread := waitForThread(ctx, t, phone.PhoneNumber, contact, false, "", 15*time.Second) + require.NotNil(t, thread, "thread not created for contact %s", contact) + + archiveThread(ctx, t, thread.ID) + archived := waitForThread(ctx, t, phone.PhoneNumber, contact, true, "", 10*time.Second) + require.NotNil(t, archived, "thread was not archived") + + // Second inbound message must NOT unarchive it. Sync on the archived thread's + // last_message_content updating to content2 (proves the listener processed it), + // then assert it is still archived. + msgID2 := receiveInbound(ctx, t, phone.PhoneAPIKey, contact, phone.PhoneNumber, content2, time.Now()) + pollMessageStatus(ctx, t, msgID2, "received", 15*time.Second) + + stillArchived := waitForThread(ctx, t, phone.PhoneNumber, contact, true, content2, 20*time.Second) + require.NotNil(t, stillArchived, "archived thread did not reflect the second inbound message") + assert.True(t, stillArchived.IsArchived, "thread should remain archived when unarchive_thread is disabled") + + // And it must not have leaked into the unarchived list. + assert.Nil(t, findThreadByContact(fetchThreads(ctx, t, phone.PhoneNumber, false), contact), + "thread should not appear in the unarchived list") +} +``` + +- [ ] **Step 2: Build the test module (compile check without a running stack)** + +Run (from `tests/`): `go vet ./...` +Expected: exit code 0 (compiles). If `httpsms.MessageResponse` field access differs, adjust to match the version pinned in `tests/go.mod` (the existing `integration_test.go` already uses `httpsms.MessageResponse` and `result.Data.ID`). + +- [ ] **Step 3: Run the full integration suite against the Docker stack** + +Run (from `tests/`), following the README one-liner: + +```bash +bash generate-firebase-credentials.sh +export FIREBASE_CREDENTIALS=$(jq -c . firebase-credentials.json) +docker compose up -d --build --wait +docker compose wait seed +sleep 2 +go test -v -timeout 180s -run TestUnarchiveThreadOnReceive ./... +docker compose down -v +``` + +On Windows without bash/jq available, run the integration suite via the CI +workflow (`.github/workflows/integration-test.yml`) or a Linux/WSL shell; the +Step 2 `go vet` compile check is the local gate. + +Expected: both `TestUnarchiveThreadOnReceive_Enabled` and +`TestUnarchiveThreadOnReceive_Disabled` PASS. + +- [ ] **Step 4: Update the test coverage checklist in the README** + +In `tests/README.md`, under "## Test Coverage", add: + +```markdown +- [x] **Unarchive Thread on Receive E2E** — Archived thread returns to the inbox on inbound message when the phone's `unarchive_thread` setting is enabled, and stays archived when disabled +``` + +- [ ] **Step 5: Commit** + +```bash +git add tests/unarchive_thread_integration_test.go tests/README.md +git commit -m "test(integration): verify unarchive thread on receive" +``` + +--- + +## Self-Review + +**Spec coverage:** +- Data model field -> Task 1. ✓ +- Event payload field -> Task 2. ✓ +- Populate flag in ReceiveMessage via phoneService.Load -> Task 3 (with fallback to false on load error). ✓ +- Thread trigger (inbound-only, archived-only, same Update call) -> Task 4. ✓ +- Listener wiring, inbound-only -> Task 5. ✓ +- Phone upsert request + params partial update -> Task 6. ✓ +- Swagger regen -> Task 6 Step 5. ✓ +- Web: store PUT body -> Task 8; settings toggle -> Task 9; model regen -> Task 7. ✓ +- Testing (pure helper unit test, go test, web lint/test) -> Tasks 4 & 10. ✓ +- Integration test (live-stack E2E, enabled + disabled) -> Task 11. ✓ +- Out of scope (Android, global/per-thread config, auto-archive) -> not implemented. ✓ + +**Placeholder scan:** No TBD/TODO; all code steps include concrete code and exact commands. + +**Type consistency:** `UnarchiveThread` (Go) / `unarchive_thread` (JSON) used identically across entity, event, params, request, store, and template. Helper `shouldUnarchive(thread, params)` signature matches its test and its call site. `phoneService.Load(ctx, userID, ownerE164)` matches existing usage in `RespondToMissedCall`. diff --git a/docs/superpowers/specs/2026-07-18-message-thread-archive-ui-design.md b/docs/superpowers/specs/2026-07-18-message-thread-archive-ui-design.md new file mode 100644 index 000000000..6023fdce5 --- /dev/null +++ b/docs/superpowers/specs/2026-07-18-message-thread-archive-ui-design.md @@ -0,0 +1,37 @@ +# Message Thread Archive UI Design + +## Goal + +Improve message-thread selection styling and keep archive actions within the +current thread-list filter. + +## Active Thread Styling + +Add `color="primary"` to every thread `v-list-item` in +`web/app/components/MessageThread.vue`. Vuetify will apply the primary color +when the route-backed item is active without changing inactive items. + +## Archive and Unarchive Behavior + +`useThreadsStore.updateThread` will continue to send the existing +`PUT /v1/message-threads/:id` request. After a successful response, it will: + +1. Preserve the current `archivedThreads` filter. +2. Remove the moved thread from the currently displayed thread collection. +3. Clear the selected thread ID. +4. Show a success notification containing `Archived` or `Unarchived`. + +The thread page will route to `/threads` after the store update succeeds. +Archiving therefore returns to the unarchived list, while unarchiving returns +to the archived list. Neither action switches the list filter. + +## Error Handling + +API errors will continue to propagate from `apiFetch`. Local thread state, +notifications, and navigation will only change after a successful response. + +## Validation + +The web package has no configured automated tests. Validate the changed files +with the existing lint commands and run the existing static generation command +to confirm the production frontend builds successfully. diff --git a/docs/superpowers/specs/2026-07-18-unarchive-thread-on-receive-design.md b/docs/superpowers/specs/2026-07-18-unarchive-thread-on-receive-design.md new file mode 100644 index 000000000..948490044 --- /dev/null +++ b/docs/superpowers/specs/2026-07-18-unarchive-thread-on-receive-design.md @@ -0,0 +1,118 @@ +# Auto-unarchive Message Threads on Inbound Message (per-phone) + +- Date: 2026-07-18 +- Status: Approved (design) +- Scope: `api/` (Go backend) and `web/` (Nuxt frontend). Android app not touched. + +## Problem + +When a message thread is archived it stays archived even if the contact sends a +new inbound message. Users want an archived thread to move back to the +unarchived (inbox) state when a new message is received. This behaviour must be +configurable. + +## Decisions + +- **Granularity:** configurable per **phone number** (per `Phone`), consistent + with other per-phone settings such as `MessagesPerMinute` and + `MissedCallAutoReply`. +- **Trigger:** only **inbound** messages received from the contact + (`entities.MessageStatusReceived`). Outbound activity (sent/sending/delivered/ + scheduled/expired) does not unarchive a thread. +- **Default:** **disabled** (opt-in). Existing and new phones default to `false`. +- **Field name:** `UnarchiveThread` (JSON `unarchive_thread`). +- **Data flow:** the flag travels on the received-message event payload + (Option B), so `MessageThreadService` gains **no** new repository dependency. + +## Design + +### 1. Data model — `api/pkg/entities/phone.go` + +Add a boolean to the `Phone` entity: + +```go +UnarchiveThread bool `json:"unarchive_thread" gorm:"default:false" example:"false"` +``` + +GORM auto-migrates the column. Existing rows default to `false`. + +### 2. Event payload — `api/pkg/events/message_phone_received_event.go` + +Add the flag to `MessagePhoneReceivedPayload`: + +```go +UnarchiveThread bool `json:"unarchive_thread"` +``` + +### 3. Populate the flag — `api/pkg/services/message_service.go` + +In `ReceiveMessage`, before building `eventPayload`, load the owner phone using +the already-injected `phoneService` (same pattern as `RespondToMissedCall`, +which calls `service.phoneService.Load(ctx, payload.UserID, payload.Owner)`) and +copy `phone.UnarchiveThread` onto the payload. + +- The owner E.164 string is `phonenumbers.Format(¶ms.Owner, phonenumbers.E164)`. +- If the phone lookup fails, log and default `UnarchiveThread` to `false`; do not + fail message reception because of a missing/failed phone lookup. + +### 4. Thread service trigger — `api/pkg/services/message_thread_service.go` + +- Add `UnarchiveThread bool` to `MessageThreadUpdateParams`. +- In `UpdateThread`, after the thread is loaded, if the thread `IsArchived` is + true **and** `params.Status == entities.MessageStatusReceived` **and** + `params.UnarchiveThread` is true, set the thread's `IsArchived = false` as part + of the same update that persists the new last message. Reuse + `thread.UpdateArchive(false)` or set the field directly before + `repository.Update`. +- The existing early-return guards (out-of-order timestamp, already-delivered) + must not skip the unarchive when a genuinely new inbound message arrives; the + unarchive is applied on the same path as the normal thread update. + +### 5. Listener — `api/pkg/listeners/message_thread_listener.go` + +In `OnMessagePhoneReceived`, set `UnarchiveThread: payload.UnarchiveThread` on +the `MessageThreadUpdateParams`. No other listener passes this flag (only inbound +received messages should unarchive). + +### 6. Request/params — phone upsert + +- `api/pkg/requests/phone_update_request.go`: add `UnarchiveThread *bool` + (pointer, applied only when present in the JSON body — same partial-update + pattern as `MessagesPerMinute`, using the `fields` map check on key + `unarchive_thread`). +- `api/pkg/services/phone_service.go` `PhoneUpsertParams`: add + `UnarchiveThread *bool` and apply it in the upsert when non-nil. + +### 7. Web frontend — `web/` + +- `web/app/stores/phones.ts` `updatePhone`: include + `unarchive_thread: phone.unarchive_thread` in the PUT body. +- `web/app/pages/settings/index.vue`: add a toggle (switch/checkbox) for the + per-phone "Unarchive thread when a new message is received" setting, alongside + the existing per-phone settings. +- Regenerate TypeScript API models with `pnpm api:models` after the Swagger spec + is regenerated so `EntitiesPhone` in `web/shared/types/api.ts` includes + `unarchive_thread`. + +### 8. Swagger + +Run `swag init --requiredByDefault --parseDependency --parseInternal` in `api/` +after adding the annotation-affecting struct field so the generated docs and the +web model regeneration stay in sync. + +## Testing + +- **API unit test** (`message_thread_service` test): archived thread + inbound + received message + `UnarchiveThread=true` -> thread becomes unarchived; + `UnarchiveThread=false` -> stays archived; outbound status with the flag true + -> stays archived. +- **API**: `MessageService.ReceiveMessage` populates `UnarchiveThread` from the + loaded phone; a failed phone lookup yields `false` and does not error. +- Run `go test ./...` in `api/`. +- **Web**: `pnpm lint` and `pnpm test` in `web/`. + +## Out of scope + +- Android app settings UI. +- Global (user-level) or per-thread configuration. +- Auto-archiving behaviour (this design only unarchives). diff --git a/web/app/components/MessageThread.vue b/web/app/components/MessageThread.vue index 56e1d7c0f..029599180 100644 --- a/web/app/components/MessageThread.vue +++ b/web/app/components/MessageThread.vue @@ -94,6 +94,7 @@ function onInstallApp() { diff --git a/web/app/pages/threads/[id]/index.vue b/web/app/pages/threads/[id]/index.vue index 541a0d3a5..1fd11eae1 100644 --- a/web/app/pages/threads/[id]/index.vue +++ b/web/app/pages/threads/[id]/index.vue @@ -148,9 +148,7 @@ async function archiveThread() { threadId: threadsStore.currentThread!.id, isArchived: true, }) - setTimeout(() => { - selectedMenuItem.value = -1 - }, 1000) + await router.push('/threads') } async function unArchiveThread() { @@ -158,9 +156,7 @@ async function unArchiveThread() { threadId: threadsStore.currentThread!.id, isArchived: false, }) - setTimeout(() => { - selectedMenuItem.value = -1 - }, 1000) + await router.push('/threads') } async function resendMessage(message: EntitiesMessage) { diff --git a/web/app/stores/threads.ts b/web/app/stores/threads.ts index 7312153ce..aef2b2947 100644 --- a/web/app/stores/threads.ts +++ b/web/app/stores/threads.ts @@ -83,8 +83,14 @@ export const useThreadsStore = defineStore('threads', () => { method: 'PUT', body: { is_archived: payload.isArchived }, }) - archivedThreads.value = payload.isArchived - await loadThreads() + threads.value = threads.value.filter( + (thread) => thread.id !== payload.threadId, + ) + threadId.value = null + notificationsStore.addNotification({ + message: payload.isArchived ? 'Archived' : 'Unarchived', + type: 'success', + }) } async function deleteThread(id: string) { From 8010ce30a27e8f78ec2d5c41b7df80e905313bc5 Mon Sep 17 00:00:00 2001 From: Acho Arnold Date: Sat, 18 Jul 2026 11:48:04 +0300 Subject: [PATCH 11/45] feat: unarchive message thread on new inbound message (per-phone setting) (#954) * feat(api): add UnarchiveThread setting to Phone entity * feat(api): add UnarchiveThread to MessagePhoneReceivedPayload * feat(api): populate UnarchiveThread from phone on received event * feat(api): unarchive thread on inbound message when enabled Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * feat(api): forward UnarchiveThread flag from received event to thread update * feat(api): accept unarchive_thread in phone upsert request Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * chore(web): regenerate api models with unarchive_thread Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: bf78429e-573c-406c-9f7c-1d6e1bddbbb5 * feat(web): send unarchive_thread in updatePhone Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: bf78429e-573c-406c-9f7c-1d6e1bddbbb5 * feat(web): add per-phone unarchive-thread toggle to settings Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: bf78429e-573c-406c-9f7c-1d6e1bddbbb5 * test(integration): verify unarchive thread on receive Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: bf78429e-573c-406c-9f7c-1d6e1bddbbb5 * feat: fix english * feat: fix english * fix: address PR review comments on unarchive-thread - URL-escape the E.164 owner in the message-threads query (+ decodes to space) - Use native stacktrace.Propagate printf args (avoids go vet non-constant format warning) - Document why setUnarchiveThread must send the required sim field Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: bf78429e-573c-406c-9f7c-1d6e1bddbbb5 * perf: load unarchive flag lazily for archived threads Consult the phone's UnarchiveThread setting inside MessageThreadService.UpdateThread only when an inbound message lands on an archived thread, instead of loading the phone on every received message. Removes the per-message phone read in ReceiveMessage and drops the UnarchiveThread field from the received-message event payload. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: bf78429e-573c-406c-9f7c-1d6e1bddbbb5 * test: retry inbound receive while phone api key associates The phone-to-API-key association runs asynchronously via the phone_api_key event listener, so a freshly provisioned phone can return 401 on receive until the auth cache clears. Retry receiveInbound on that transient 401 instead of failing immediately. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: bf78429e-573c-406c-9f7c-1d6e1bddbbb5 * test: cover not-archived non-received unarchive branch Remove an unrelated webhook email payload formatting plan doc that was accidentally included in this feature branch, and add the missing shouldCheckUnarchive case for a non-archived thread with a non-received status so every branch of the predicate is exercised. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: bf78429e-573c-406c-9f7c-1d6e1bddbbb5 --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .gitignore | 1 - api/docs/docs.go | 16 +- api/docs/swagger.json | 16 +- api/docs/swagger.yaml | 14 +- api/pkg/di/container.go | 1 + api/pkg/entities/phone.go | 3 + api/pkg/handlers/message_handler.go | 4 +- api/pkg/requests/phone_update_request.go | 9 + api/pkg/services/message_service.go | 4 +- api/pkg/services/message_thread_service.go | 21 ++ .../services/message_thread_service_test.go | 23 ++ api/pkg/services/phone_service.go | 5 + ...26-07-05-affiliates-landing-page-design.md | 109 ++++++++ .../2026-07-13-getting-started-page-design.md | 88 +++++++ tests/README.md | 1 + tests/unarchive_thread_integration_test.go | 236 ++++++++++++++++++ web/app/pages/settings/index.vue | 9 + web/app/stores/phones.ts | 1 + web/shared/types/api.ts | 12 +- 19 files changed, 563 insertions(+), 10 deletions(-) create mode 100644 api/pkg/services/message_thread_service_test.go create mode 100644 docs/superpowers/specs/2026-07-05-affiliates-landing-page-design.md create mode 100644 docs/superpowers/specs/2026-07-13-getting-started-page-design.md create mode 100644 tests/unarchive_thread_integration_test.go diff --git a/.gitignore b/.gitignore index e15d590a9..8f4caf4b5 100644 --- a/.gitignore +++ b/.gitignore @@ -13,7 +13,6 @@ SECURITY_AUDIT_REPORT.md *.exe -docs/ .output .agents/ skills-lock.json diff --git a/api/docs/docs.go b/api/docs/docs.go index c57458255..c7fcd1145 100644 --- a/api/docs/docs.go +++ b/api/docs/docs.go @@ -3379,7 +3379,7 @@ const docTemplate = `{ }, "request_id": { "type": "string", - "example": "bulk-csv-a1B2c3D4e5" + "example": "bulk-httpsms-file.csv" }, "scheduled_count": { "type": "integer", @@ -3760,6 +3760,7 @@ const docTemplate = `{ "messages_per_minute", "phone_number", "sim", + "unarchive_thread", "updated_at", "user_id" ], @@ -3804,6 +3805,11 @@ const docTemplate = `{ "sim": { "$ref": "#/definitions/entities.SIM" }, + "unarchive_thread": { + "description": "UnarchiveThread moves an archived message thread back to the inbox when a new message is received on this phone.", + "type": "boolean", + "example": false + }, "updated_at": { "type": "string", "example": "2022-06-05T14:26:10.303278+03:00" @@ -4436,7 +4442,8 @@ const docTemplate = `{ "messages_per_minute", "missed_call_auto_reply", "phone_number", - "sim" + "sim", + "unarchive_thread" ], "properties": { "fcm_token": { @@ -4473,6 +4480,11 @@ const docTemplate = `{ "description": "SIM is the SIM slot of the phone in case the phone has more than 1 SIM slot", "type": "string", "example": "SIM1" + }, + "unarchive_thread": { + "description": "UnarchiveThread moves an archived thread back to the inbox when a new message is received on this phone.", + "type": "boolean", + "example": false } } }, diff --git a/api/docs/swagger.json b/api/docs/swagger.json index 009797ca9..25db6fc31 100644 --- a/api/docs/swagger.json +++ b/api/docs/swagger.json @@ -3376,7 +3376,7 @@ }, "request_id": { "type": "string", - "example": "bulk-csv-a1B2c3D4e5" + "example": "bulk-httpsms-file.csv" }, "scheduled_count": { "type": "integer", @@ -3757,6 +3757,7 @@ "messages_per_minute", "phone_number", "sim", + "unarchive_thread", "updated_at", "user_id" ], @@ -3801,6 +3802,11 @@ "sim": { "$ref": "#/definitions/entities.SIM" }, + "unarchive_thread": { + "description": "UnarchiveThread moves an archived message thread back to the inbox when a new message is received on this phone.", + "type": "boolean", + "example": false + }, "updated_at": { "type": "string", "example": "2022-06-05T14:26:10.303278+03:00" @@ -4433,7 +4439,8 @@ "messages_per_minute", "missed_call_auto_reply", "phone_number", - "sim" + "sim", + "unarchive_thread" ], "properties": { "fcm_token": { @@ -4470,6 +4477,11 @@ "description": "SIM is the SIM slot of the phone in case the phone has more than 1 SIM slot", "type": "string", "example": "SIM1" + }, + "unarchive_thread": { + "description": "UnarchiveThread moves an archived thread back to the inbox when a new message is received on this phone.", + "type": "boolean", + "example": false } } }, diff --git a/api/docs/swagger.yaml b/api/docs/swagger.yaml index bb0041717..892c9ae45 100644 --- a/api/docs/swagger.yaml +++ b/api/docs/swagger.yaml @@ -58,7 +58,7 @@ definitions: example: 30 type: integer request_id: - example: bulk-csv-a1B2c3D4e5 + example: bulk-httpsms-file.csv type: string scheduled_count: example: 50 @@ -388,6 +388,11 @@ definitions: type: string sim: $ref: '#/definitions/entities.SIM' + unarchive_thread: + description: UnarchiveThread moves an archived message thread back to the + inbox when a new message is received on this phone. + example: false + type: boolean updated_at: example: "2022-06-05T14:26:10.303278+03:00" type: string @@ -402,6 +407,7 @@ definitions: - messages_per_minute - phone_number - sim + - unarchive_thread - updated_at - user_id type: object @@ -908,6 +914,11 @@ definitions: 1 SIM slot example: SIM1 type: string + unarchive_thread: + description: UnarchiveThread moves an archived thread back to the inbox when + a new message is received on this phone. + example: false + type: boolean required: - fcm_token - max_send_attempts @@ -916,6 +927,7 @@ definitions: - missed_call_auto_reply - phone_number - sim + - unarchive_thread type: object requests.UserNotificationUpdate: properties: diff --git a/api/pkg/di/container.go b/api/pkg/di/container.go index f84f5b301..fafae1ae7 100644 --- a/api/pkg/di/container.go +++ b/api/pkg/di/container.go @@ -1111,6 +1111,7 @@ func (container *Container) MessageThreadService() (service *services.MessageThr container.Logger(), container.Tracer(), container.MessageThreadRepository(), + container.PhoneRepository(), container.EventDispatcher(), ) } diff --git a/api/pkg/entities/phone.go b/api/pkg/entities/phone.go index 97df66317..4f3c33f28 100644 --- a/api/pkg/entities/phone.go +++ b/api/pkg/entities/phone.go @@ -24,6 +24,9 @@ type Phone struct { MissedCallAutoReply *string `json:"missed_call_auto_reply" example:"This phone cannot receive calls. Please send an SMS instead." validate:"optional"` + // UnarchiveThread moves an archived message thread back to the inbox when a new message is received on this phone. + UnarchiveThread bool `json:"unarchive_thread" gorm:"default:false" example:"false"` + CreatedAt time.Time `json:"created_at" example:"2022-06-05T14:26:02.302718+03:00"` UpdatedAt time.Time `json:"updated_at" example:"2022-06-05T14:26:10.303278+03:00"` } diff --git a/api/pkg/handlers/message_handler.go b/api/pkg/handlers/message_handler.go index afe0127a4..9da0ef1e5 100644 --- a/api/pkg/handlers/message_handler.go +++ b/api/pkg/handlers/message_handler.go @@ -265,7 +265,7 @@ func (h *MessageHandler) Index(c fiber.Ctx) error { messages, err := h.service.GetMessages(ctx, request.ToGetParams(h.userIDFomContext(c))) if err != nil { - msg := fmt.Sprintf("cannot get messgaes with params [%+#v]", request) + msg := fmt.Sprintf("cannot get messages with params [%+#v]", request) ctxLogger.Error(stacktrace.Propagate(err, msg)) return h.responseInternalServerError(c) } @@ -383,7 +383,7 @@ func (h *MessageHandler) PostReceive(c fiber.Ctx) error { message, err := h.service.ReceiveMessage(ctx, request.ToMessageReceiveParams(h.userIDFomContext(c), c.OriginalURL())) if err != nil { - msg := fmt.Sprintf("cannot receive message with paylod [%s]", c.Body()) + msg := fmt.Sprintf("cannot receive message with payload [%s]", c.Body()) ctxLogger.Error(stacktrace.Propagate(err, msg)) return h.responseInternalServerError(c) } diff --git a/api/pkg/requests/phone_update_request.go b/api/pkg/requests/phone_update_request.go index 462d6428e..96b2882e1 100644 --- a/api/pkg/requests/phone_update_request.go +++ b/api/pkg/requests/phone_update_request.go @@ -29,6 +29,9 @@ type PhoneUpsert struct { MissedCallAutoReply *string `json:"missed_call_auto_reply" example:"e.g. This phone cannot receive calls. Please send an SMS instead."` + // UnarchiveThread moves an archived thread back to the inbox when a new message is received on this phone. + UnarchiveThread bool `json:"unarchive_thread" example:"false"` + // SIM is the SIM slot of the phone in case the phone has more than 1 SIM slot SIM string `json:"sim" example:"SIM1"` @@ -75,6 +78,11 @@ func (input *PhoneUpsert) ToUpsertParams(user entities.AuthContext, source strin maxSendAttempts = &input.MaxSendAttempts } + var unarchiveThread *bool + if _, exists := fields["unarchive_thread"]; exists { + unarchiveThread = &input.UnarchiveThread + } + var scheduleID *uuid.UUID if _, exists := fields["message_send_schedule_id"]; exists { if parsed, err := uuid.Parse(strings.TrimSpace(input.MessageSendScheduleID)); err == nil { @@ -92,6 +100,7 @@ func (input *PhoneUpsert) ToUpsertParams(user entities.AuthContext, source strin FcmToken: fcmToken, UserID: user.ID, SIM: entities.SIM(input.SIM), + UnarchiveThread: unarchiveThread, MessageSendScheduleID: scheduleID, } } diff --git a/api/pkg/services/message_service.go b/api/pkg/services/message_service.go index 56766c981..f54feafd7 100644 --- a/api/pkg/services/message_service.go +++ b/api/pkg/services/message_service.go @@ -346,11 +346,13 @@ func (service *MessageService) ReceiveMessage(ctx context.Context, params *Messa return nil, service.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, msg)) } + owner := phonenumbers.Format(¶ms.Owner, phonenumbers.E164) + eventPayload := events.MessagePhoneReceivedPayload{ MessageID: messageID, UserID: params.UserID, Encrypted: params.Encrypted, - Owner: phonenumbers.Format(¶ms.Owner, phonenumbers.E164), + Owner: owner, Contact: params.Contact, Timestamp: params.Timestamp, Content: params.Content, diff --git a/api/pkg/services/message_thread_service.go b/api/pkg/services/message_thread_service.go index d2027506f..31287ebc1 100644 --- a/api/pkg/services/message_thread_service.go +++ b/api/pkg/services/message_thread_service.go @@ -21,6 +21,7 @@ type MessageThreadService struct { logger telemetry.Logger tracer telemetry.Tracer repository repositories.MessageThreadRepository + phoneRepository repositories.PhoneRepository eventDispatcher *EventDispatcher } @@ -29,6 +30,7 @@ func NewMessageThreadService( logger telemetry.Logger, tracer telemetry.Tracer, repository repositories.MessageThreadRepository, + phoneRepository repositories.PhoneRepository, eventDispatcher *EventDispatcher, ) (s *MessageThreadService) { return &MessageThreadService{ @@ -36,6 +38,7 @@ func NewMessageThreadService( tracer: tracer, eventDispatcher: eventDispatcher, repository: repository, + phoneRepository: phoneRepository, } } @@ -50,6 +53,14 @@ type MessageThreadUpdateParams struct { Timestamp time.Time } +// shouldCheckUnarchive reports whether a thread update is a new inbound message +// landing on an archived thread. Only in that case is the phone's +// UnarchiveThread setting consulted, so the phone is not loaded on the common +// path where the thread is not archived. +func (service *MessageThreadService) shouldCheckUnarchive(thread *entities.MessageThread, params MessageThreadUpdateParams) bool { + return thread.IsArchived && params.Status == entities.MessageStatusReceived +} + // DeleteAllForUser deletes all entities.MessageThread for an entities.UserID. func (service *MessageThreadService) DeleteAllForUser(ctx context.Context, userID entities.UserID) error { ctx, span, ctxLogger := service.tracer.StartWithLogger(ctx, service.logger) @@ -92,6 +103,16 @@ func (service *MessageThreadService) UpdateThread(ctx context.Context, params Me return nil } + if service.shouldCheckUnarchive(thread, params) { + phone, phoneErr := service.phoneRepository.Load(ctx, params.UserID, params.Owner) + if phoneErr != nil { + ctxLogger.Warn(stacktrace.Propagate(phoneErr, "cannot load phone [%s] for user [%s] to resolve UnarchiveThread; leaving thread [%s] archived", params.Owner, params.UserID, thread.ID)) + } else if phone.UnarchiveThread { + thread.UpdateArchive(false) + ctxLogger.Info(fmt.Sprintf("unarchiving thread [%s] after inbound message [%s]", thread.ID, params.MessageID)) + } + } + if err = service.repository.Update(ctx, thread.Update(params.Timestamp, params.MessageID, params.Content, params.Status)); err != nil { msg := fmt.Sprintf("cannot update message thread with id [%s] after adding message [%s]", thread.ID, params.MessageID) return service.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, msg)) diff --git a/api/pkg/services/message_thread_service_test.go b/api/pkg/services/message_thread_service_test.go new file mode 100644 index 000000000..7e48ab433 --- /dev/null +++ b/api/pkg/services/message_thread_service_test.go @@ -0,0 +1,23 @@ +package services + +import ( + "testing" + + "github.com/NdoleStudio/httpsms/pkg/entities" + "github.com/stretchr/testify/assert" +) + +func TestShouldCheckUnarchive(t *testing.T) { + service := &MessageThreadService{} + + archived := &entities.MessageThread{IsArchived: true} + notArchived := &entities.MessageThread{IsArchived: false} + + received := MessageThreadUpdateParams{Status: entities.MessageStatusReceived} + sent := MessageThreadUpdateParams{Status: entities.MessageStatusSent} + + assert.True(t, service.shouldCheckUnarchive(archived, received), "archived + inbound -> consult phone setting") + assert.False(t, service.shouldCheckUnarchive(archived, sent), "outbound status -> no check") + assert.False(t, service.shouldCheckUnarchive(notArchived, received), "already unarchived -> no check") + assert.False(t, service.shouldCheckUnarchive(notArchived, sent), "not archived + outbound -> no check") +} diff --git a/api/pkg/services/phone_service.go b/api/pkg/services/phone_service.go index ae863d32b..2e80abfa1 100644 --- a/api/pkg/services/phone_service.go +++ b/api/pkg/services/phone_service.go @@ -104,6 +104,7 @@ type PhoneUpsertParams struct { WebhookURL *string MessageExpirationDuration *time.Duration MissedCallAutoReply *string + UnarchiveThread *bool SIM entities.SIM MessageSendScheduleID *uuid.UUID Source string @@ -312,6 +313,10 @@ func (service *PhoneService) update(phone *entities.Phone, params *PhoneUpsertPa phone.MissedCallAutoReply = params.MissedCallAutoReply } + if params.UnarchiveThread != nil { + phone.UnarchiveThread = *params.UnarchiveThread + } + phone.SIM = params.SIM phone.MessageSendScheduleID = params.MessageSendScheduleID diff --git a/docs/superpowers/specs/2026-07-05-affiliates-landing-page-design.md b/docs/superpowers/specs/2026-07-05-affiliates-landing-page-design.md new file mode 100644 index 000000000..312033520 --- /dev/null +++ b/docs/superpowers/specs/2026-07-05-affiliates-landing-page-design.md @@ -0,0 +1,109 @@ +# Affiliates Landing Page — Design + +**Date:** 2026-07-05 +**Component:** `web/` (Nuxt 4 SPA, Vuetify 4) + +## Goal + +Create a marketing landing page at `/affiliates` that convinces people to join the +httpSMS affiliate program, and replace the current external "Affiliates" footer link +(`https://httpsms.lemonsqueezy.com/affiliates`) with a `NuxtLink` to this new internal page. + +All primary calls-to-action link to the LemonSqueezy affiliate signup: +`https://affiliates.lemonsqueezy.com/programs/httpsms` (opens in a new tab). + +## Files + +- **New:** `web/app/pages/affiliates/index.vue` — the landing page. +- **Edit:** `web/app/layouts/website.vue` — change the footer "Affiliates" link from the + external `` to + ``, keeping the existing `mdiShieldStar` warning icon and + `text-white text-decoration-none footer-link` classes. + +## Conventions to follow + +- `&"}`) + html := string(rich) + + assert.Contains(t, plain, `&"}`) + html := string(rich) + + assert.Contains(t, plain, ` + + diff --git a/web/app/layouts/website.vue b/web/app/layouts/website.vue index 1aadb1b64..3b6565a5a 100644 --- a/web/app/layouts/website.vue +++ b/web/app/layouts/website.vue @@ -80,7 +80,11 @@ function goToPricing() { Blog  For Free - - Dashboard - + + Dashboard + + + diff --git a/web/app/middleware/redirectToThreads.ts b/web/app/middleware/redirectToThreads.ts new file mode 100644 index 000000000..a605286fb --- /dev/null +++ b/web/app/middleware/redirectToThreads.ts @@ -0,0 +1,11 @@ +import { STORAGE_KEY } from '~/stores/redirectPreference' + +export default defineNuxtRouteMiddleware(() => { + try { + if (localStorage.getItem(STORAGE_KEY) === 'true') { + return navigateTo('/threads', { replace: true }) + } + } catch (error) { + console.error(error) + } +}) diff --git a/web/app/pages/index.vue b/web/app/pages/index.vue index 524c6637b..9790b5e7f 100644 --- a/web/app/pages/index.vue +++ b/web/app/pages/index.vue @@ -28,6 +28,7 @@ import { definePageMeta({ layout: 'website', + middleware: ['redirect-to-threads'], }) useSeoMeta({ diff --git a/web/app/pages/settings/index.vue b/web/app/pages/settings/index.vue index ea1752533..1f381416b 100644 --- a/web/app/pages/settings/index.vue +++ b/web/app/pages/settings/index.vue @@ -47,6 +47,7 @@ const authStore = useAuthStore() const phonesStore = usePhonesStore() const billingStore = useBillingStore() const notificationsStore = useNotificationsStore() +const redirectPreferenceStore = useRedirectPreferenceStore() const firebaseUser = ref(null) const gravatarUrl = ref(null) @@ -773,6 +774,7 @@ async function deleteUserAccount() { await signOut(auth) authStore.resetState() phonesStore.resetState() + redirectPreferenceStore.resetState() notificationsStore.addNotification({ type: 'info', message: 'You have successfully logged out', diff --git a/web/app/stores/redirectPreference.ts b/web/app/stores/redirectPreference.ts new file mode 100644 index 000000000..030def894 --- /dev/null +++ b/web/app/stores/redirectPreference.ts @@ -0,0 +1,47 @@ +import { defineStore } from 'pinia' +import { ref } from 'vue' + +export const STORAGE_KEY = 'httpsms_redirect_to_threads' + +function readFlag(): boolean { + try { + return localStorage.getItem(STORAGE_KEY) === 'true' + } catch (error) { + console.error(error) + return false + } +} + +export const useRedirectPreferenceStore = defineStore( + 'redirectPreference', + () => { + const enabled = ref(readFlag()) + const dismissedThisSession = ref(false) + + function enable() { + try { + localStorage.setItem(STORAGE_KEY, 'true') + enabled.value = true + navigateTo('/threads', { replace: true }) + } catch (error) { + console.error(error) + } + } + + function dismiss() { + dismissedThisSession.value = true + } + + function resetState() { + enabled.value = false + dismissedThisSession.value = false + try { + localStorage.removeItem(STORAGE_KEY) + } catch (error) { + console.error(error) + } + } + + return { enabled, dismissedThisSession, enable, dismiss, resetState } + }, +) From 9444545e67e9cd904095fc1acc81c15d315f5d0b Mon Sep 17 00:00:00 2001 From: Acho Arnold Ewin Date: Sun, 19 Jul 2026 19:51:31 +0300 Subject: [PATCH 22/45] style: add arrow position --- web/app/components/RedirectPromptPopover.vue | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/web/app/components/RedirectPromptPopover.vue b/web/app/components/RedirectPromptPopover.vue index 859a71dac..c2de18200 100644 --- a/web/app/components/RedirectPromptPopover.vue +++ b/web/app/components/RedirectPromptPopover.vue @@ -31,7 +31,7 @@ const menuOpen = computed({ :open-on-click="false" :close-on-content-click="false" > - + Skip this page next time? @@ -49,10 +49,9 @@ const menuOpen = computed({ - Always open dashboard - + Always open dashboard From 78e1d183a13f5e43143fba2c6dae1d883c05dcc5 Mon Sep 17 00:00:00 2001 From: Acho Arnold Ewin Date: Sun, 19 Jul 2026 20:13:15 +0300 Subject: [PATCH 23/45] docs: add contacts feature design spec Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 8dd82cae-8bc6-4eaa-9b90-95073720c577 --- .../2026-07-19-contacts-feature-design.md | 252 ++++++++++++++++++ 1 file changed, 252 insertions(+) create mode 100644 docs/superpowers/specs/2026-07-19-contacts-feature-design.md diff --git a/docs/superpowers/specs/2026-07-19-contacts-feature-design.md b/docs/superpowers/specs/2026-07-19-contacts-feature-design.md new file mode 100644 index 000000000..58f9d9e40 --- /dev/null +++ b/docs/superpowers/specs/2026-07-19-contacts-feature-design.md @@ -0,0 +1,252 @@ +# Contacts Feature — Design + +Date: 2026-07-19 +Status: Approved (pending implementation plan) + +## 1. Summary + +Add a **Contacts** feature to httpSMS. Users can store contacts (name, emails, +phone numbers, and free-form properties) and manage them through a dedicated web +page and a REST API. Once contacts exist, the threads page displays the +contact's **name** instead of the raw phone number. + +Design priority (explicit user goal): resolve contact names into the threads +page with **as few database queries as possible**, and make **changing a +contact's name cheap** (no write amplification across threads). + +## 2. Goals + +- CRUD for contacts via a REST API under `/v1/contacts`. +- A single create endpoint that accepts **one or many** contacts in one request. +- Import contacts from **CSV** (Excel/XLSX is **not** supported for contacts). +- A contact has: `Name` (required), `Emails` (optional array), `PhoneNumbers` + (array, >= 1), and free-form `Properties` (`map[string]string`). +- Contacts are **global to the user account** (name shows across all the user's + owner phones). +- Threads display the resolved contact name (and expose full contact details) + when requested, everywhere the peer number is shown (threads list + thread + header/title). +- Dedicated Contacts web page with full CRUD + import. + +## 3. Non-goals / Explicit decisions + +- **No uniqueness constraint** on phone numbers. Multiple contacts may share a + phone number. There is **no** normalized `contact_phone_numbers` lookup table + and **no** DB-level unique index on numbers. +- When a thread's number matches **multiple** contacts, the **most recently + updated** contact wins for display. +- Thread-name resolution is **opt-in** per request (default off). +- `Properties` are API/JSON only; not part of the flat CSV template. + +## 4. Data model + +### 4.1 `Contact` entity (`api/pkg/entities/contact.go`) + +| Field | Type | GORM / notes | +| -------------- | ------------------- | --------------------------------------------- | +| `ID` | `uuid.UUID` | `primaryKey;type:uuid` | +| `UserID` | `entities.UserID` | indexed | +| `Name` | `string` | required | +| `Emails` | `pq.StringArray` | `gorm:"type:text[]"` `swaggertype:"array,string"`; optional, each validated | +| `PhoneNumbers` | `pq.StringArray` | `gorm:"type:text[]"` `swaggertype:"array,string"`; >= 1, each valid E.164 | +| `Properties` | `ContactProperties` | `gorm:"type:jsonb"`; free-form key/value map | +| `CreatedAt` | `time.Time` | | +| `UpdatedAt` | `time.Time` | | + +Follows the existing `pq.StringArray` convention used in `webhook.go` and +`phone_api_key.go`. Auto-migrated in `pkg/di/container.go` alongside the other +entities. + +### 4.2 `ContactProperties` custom type + +A small custom type to avoid adding a new dependency (`gorm.io/datatypes` is not +currently used): + +```go +// ContactProperties is a free-form key/value map persisted as a jsonb column. +type ContactProperties map[string]string + +func (p ContactProperties) Value() (driver.Value, error) // json.Marshal -> []byte +func (p *ContactProperties) Scan(src any) error // json.Unmarshal from []byte/string +``` + +Includes unit tests for `Value`/`Scan` round-trips (nil, empty, populated). + +### 4.3 `MessageThread` additions (`api/pkg/entities/message_thread.go`) + +Add a **non-persisted** field carrying the resolved contact: + +```go +ContactDetails *Contact `json:"contact_details,omitempty" gorm:"-"` +``` + +- `gorm:"-"` — never read/written to the DB. +- `omitempty` — absent from responses unless resolution attached it. +- Named `ContactDetails` (JSON `contact_details`) to avoid colliding with the + existing `Contact string json:"contact"` field, which holds the peer phone + number. + +## 5. API + +New `ContactHandler` mirroring existing handler/service/repository/validator +layering. Routes registered via the `handler.register(...)` helper (Fiber v3), +wired in the DI container. All routes are under `/v1/` and behind the standard +auth middleware chain. + +| Method | Route | Purpose | +| -------- | ------------------------ | ------------------------------------------------------------- | +| `GET` | `/v1/contacts` | List (paginated; `?query=` searches name / email / number) | +| `POST` | `/v1/contacts` | Create **one or many** — body is a JSON array of contacts | +| `POST` | `/v1/contacts/upload` | Import from CSV (`document` multipart form field; CSV only) | +| `PUT` | `/v1/contacts/:contactID`| Update name / emails / phone numbers / properties | +| `DELETE` | `/v1/contacts/:contactID`| Delete a contact | + +### 5.1 Create (one or many) + +Request body (array form or `{ "contacts": [...] }`): + +```json +{ + "contacts": [ + { + "name": "Alice Smith", + "emails": ["alice@example.com"], + "phone_numbers": ["+18005550199", "+18005550100"], + "properties": { "company": "Acme", "role": "CTO" } + } + ] +} +``` + +- Per-item validation with row-indexed error messages (mirroring bulk-message + validation style using `url.Values`). +- Batch capped at <= 1000 contacts. +- Cache invalidated **once** after the batch commits. + +### 5.2 CSV import (CSV only) + +- Reuses the bulk-message CSV parsing pattern (`csvutil`; <= 500 KB; <= 1000 + rows). **Excel/XLSX is not supported** — only `text/csv` / `.csv` is accepted; + any other file type returns a validation error. +- Columns: `Name, Emails, PhoneNumbers`. `Emails` and `PhoneNumbers` cells hold + multiple values separated by `;` (or `,`). `Properties` is not part of the + flat template. +- Published template: `httpsms-contacts.csv` (same location/convention as the + bulk-message template). + +### 5.3 Validation rules + +- `Name` required and non-empty per contact. +- Each phone number must parse as E.164 (`nyaruka/phonenumbers`); >= 1 required. +- Each email (if present) must be a valid email. +- `properties` keys/values are free-form strings. + +## 6. Thread name resolution + caching + +### 6.1 Contact map cache + +- A per-user map `phone_number -> *Contact` is built from **all** the user's + contacts. On phone-number collisions, the **most recently updated** contact + wins (sort by `UpdatedAt` ascending while building so later writes overwrite). +- Serialized as JSON in `cache.Cache` under key `contacts.map.`, + TTL ~24h. +- `cache.Cache` exposes only `Get`/`Set` (no delete), so **invalidation = + overwrite** the key with an empty marker after any contact mutation; the next + request that needs it lazily rebuilds from the DB. + +### 6.2 `GetThreads` flow + +1. Load threads (existing single query, unchanged). +2. **If** the request's `Contacts` flag is true: fetch the user's contact map + (cache hit → 0 DB queries; miss → one query to rebuild), then attach + `ContactDetails` to each thread in memory by looking up `thread.contact`. +3. If the flag is false/absent: skip resolution entirely (no cache/DB work); + behavior identical to today. + +Cost profile: + +- Threads read: **1 DB query** (unchanged). +- Contact resolution: usually a **cache hit → 0 DB queries**; DB touched only on + cache miss or right after a contact write. +- Contact **name change**: single-row `UPDATE` + cache invalidation. + **Zero thread writes / zero propagation.** + +### 6.3 Opt-in filter + +- `GET /v1/message-threads` gains an optional query param `?contacts=true` + bound onto `requests.MessageThreadIndex` (`Contacts bool`, default `false`). +- Threaded through `MessageThreadGetParams` to the service, controlling whether + step 2 above runs. + +## 7. Frontend (Nuxt 4 / Pinia) + +- **`useContactsStore`** (`web/app/stores/contacts.ts`): `loadContacts`, + `saveContacts` (array), `updateContact`, `deleteContact`, `uploadCsv`. +- **Contacts page** (`web/app/pages/contacts/index.vue`) — layout modeled on the + provided reference (Plunk-style): + - **Header row**: large `Contacts` title (`text-display-large`, no glowing + gradient) with a subtitle like `Manage your contacts. {total} total`, and, + aligned top-right, an outlined **Import CSV** button and a filled primary + **Add Contact** button. + - **Search bar** below the header filtering by name / email / phone number + (drives the `?query=` API param, debounced). + - **Data table** (`VDataTable`) with columns: `Name`, `Phone Numbers`, + `Emails`, `Created`, `Updated`, and a right-aligned **Actions** column with + per-row **edit** (pencil, `mdiPencil`) and **delete** (trash, + `mdiDelete`) icon buttons. + - Timestamps rendered relatively (e.g. "31 minutes ago") via `useFilters()`. + - Pagination for large lists. + - **Modals** (all `VDialog` with `opacity="0.9"`, Close button + `color="warning"`): + - **Add / Edit Contact** dialog — form for `Name`, repeatable + `PhoneNumbers`, repeatable `Emails`, and free-form `Properties` + (key/value rows). Same dialog component for create and edit. + - **Delete Contact** confirmation dialog. + - **Import CSV** dialog — file input accepting `.csv` only, a link to the + `httpsms-contacts.csv` template, and inline row-indexed error display. + - Hyperlinks use `text-decoration-none hover:text-decoration-underline`. +- **Threads UI**: + - `threads` store `loadThreads` passes `contacts: true`. + - `MessageThread.vue` title and `MessageThreadHeader.vue` render + `thread.contact_details?.name ?? formatPhoneNumber(thread.contact)`; the + avatar uses the resolved name's first letter when present. + - Filter helpers pulled from `useFilters()` in `