mirror of
https://github.com/status-im/status-app.git
synced 2026-08-27 07:01:14 +00:00
fix(ios): pause/resume services with the app lifecycle and re-point media URLs on rebind (#21687)
* fix(media): re-point cached media URLs when the media server restarts (#47) iOS suspends the app and kills status-go's local media server; on resume it rebinds on a NEW ephemeral port and emits mediaserver.started, which the Nim side never handled — every cached https://localhost:<oldport>/ image URL (chat images, avatars, stickers, link previews, community icons) went permanently stale. Key decisions: - New MediaServerStartedSignal decoded in signals_manager (the enum member already existed, so the cheap-triage scan needs no change); null-event envelopes decode to port 0 and every rewrite helper treats port <= 0 as a no-op. - One shared helper, withMediaServerPort (app_service/common/ media_server_url.nim): rewrites the port ONLY for http(s):// localhost|127.0.0.1|0.0.0.0:<port> URLs, splicing around the authority so path/query stay byte-identical; everything else (remote URLs, data URIs, qrc/file paths, port-less URLs) passes through untouched, so callers apply it blindly. Desktop stays a no-op: the signal never fires there, and an unchanged port rewrites nothing. - Subscribers rewrite in place + notify: message models (chat + pinned) re-emit image-carrying roles via dataChanged; link-preview thumbnails are QObjects and emit urlChanged directly; section model refreshes community image/banner/icon; contacts service rewrites its ContactDetails cache and re-emits SIGNAL_CONTACT_UPDATED per changed contact, plus the user-profile singleton's own avatar (its setters already no-op on equal values). - Dropped the contacts service's imageServerUrl field: it was write-only dead code (fetched once, never read). Files: src/app/core/signals/{signals_manager,types}.nim, src/app/core/signals/remote_signals/mediaserver.nim (new), src/app_service/common/media_server_url.nim (new), src/app_service/service/contacts/service.nim, src/app_service/service/message/dto/{link_preview,link_preview_thumbnail}.nim, src/app/modules/shared_models/{message_item,message_model,link_preview_model,section_model}.nim, chat_section chat_content + messages + main module/controller/io_interface wiring, test/nim/{media_server_url_test (new),message_model_test, signals_manager_test}.nim Verified: media_server_url_test 9/9 OK, signals_manager_test 6/6 OK (incl. 2 new mediaserver.started tests) via make nim-test-run USE_SYSTEM_NIM=1. message_model_test does NOT link in this arm64 container — pre-existing nimqml/LTO link failure, reproduced identically on clean HEAD with a fresh nimcache; the 3 new model tests compile but need CI/another host to run. Device repro (background 10+ min, resume, images reload) remains for the human pass. * fix(build): normalize two 'import Nimqml' casings to the module's real name discord_message_item and message_transaction_parameters_item imported Nimqml (capital N) while the vendored module file is nimqml.nim. On a case-insensitive checkout (Docker-on-Mac bind mount) Nim treats the two spellings as distinct modules whose nimcache artifacts collide on one file, so any test pulling in these items (e.g. message_model_test) fails to link with undefined nimqml symbols. Found while verifying #47. * refactor(media): deduplicate the media-URL refresh template and drop dead code Review follow-up to ca5e5c92c, no behavior change: - Extract the thrice-duplicated compare-rewrite-flag template into refreshMediaServerUrl in media_server_url.nim; message_item, section_model and contacts/service now share one definition. - Drop the why-comment copy-pasted verbatim into three controllers; the explanation lives on MediaServerStartedSignal and the helper module. - Remove backend getImageServerURL, dead since its only caller (setImageServerUrl) was deleted on this branch. - Whitespace: trailing newline in signals/types.nim, blank line before the appended suite in message_model_test.nim. Verified: media_server_url_test, message_model_test and signals_manager_test all pass (nim-test-run recipe); app wiring compile-checked via app/modules/main/module + contacts service + section_model with --compileOnly. * fix(ios): drive PauseServices/ResumeServices from the app lifecycle (#51) On iOS status-go runs in-process and nothing drove the pausable-services lifecycle: services never paused on backgrounding (battery cost) and never resumed on foregrounding, so the media server's listening socket iOS kills during suspension stayed dead and every cached localhost media URL failed until app restart (device-confirmed: post-resume image loads get Connection refused while in-process RPC still works). Resuming now re-runs the full recovery chain: ResumeServices -> ServiceRegistry -> mediaserver ToForeground() rebind -> mediaserver.started -> #47/#49's URL refresh. Key decisions: - Lifecycle source is StatusQ's UrlSchemeEvent applicationStateChanged watcher (already the iOS foreground seam): new appBackgrounded signal emitted ONLY on Qt::ApplicationSuspended — Inactive dips from share sheets/system alerts never pause; appForegrounded fires on every return to Active, so ServicesPauseBridge latches (`paused`) and resumes only when it actually paused. Connections are synchronous (AutoConnection, same thread): iOS freezes the process right after the state change, a queued pause slot might never run. - Bridge wired only under `when defined(ios)`: Android's service process already drives pause/resume from binder visibility (UI process must not double-drive), desktop is never suspended. - Same contract as StatusGoService.java: fetch the service list from PausableServices() at each transition (late-registered services picked up; empty list — node not running, e.g. login screen — drives nothing), then PauseServices/ResumeServices with the JSON name array. The three libstatus C exports are bound in new backend/pausable_services.nim (vendor/nim-status-go doesn't cover them); errors logged, not fatal. - Backend calls injected into the bridge as a seam (PausableServicesCalls), so the Nim test drives the real StatusQ signal emitters against recorders. Files: src/app/core/services_pause_bridge.nim (new), src/backend/pausable_services.nim (new), src/nim_status_client.nim, src/statusq_bridge.nim, ui/StatusQ/{include/StatusQ/urlschemeevent.h, src/{urlschemeevent,externc}.cpp}, Makefile (test target), test/nim/services_pause_bridge_test.nim (new) Verified: services_pause_bridge_test 15/15 OK; full tests-nim-linux 464 OK / 0 failed (chat_section_model_test fails to compile identically on the clean base — pre-existing, unrelated); nim_status_client Linux path compile-checked (--compileOnly, exit 0); StatusQ recompiles clean; libstatus.so exports PausableServices/PauseServices/ResumeServices (linked by the test). Device criteria (iPhone: photo -> background 10+ min -> resume -> images recover; Android S21: exactly one service-driven pause/resume, no duplicates) remain for the human pass — iOS paths can't run in this container. * fix(tests): reset the fetch counter per case in services_pause_bridge unittest setup vars are module-scope globals; the int's constant initializer runs once, so fetches leaked across cases and the two absolute-count assertions saw the running total. * fix: Potential fix for pull request finding * fix(statusq): handle the full application-state enum in watchApplicationState Inactive is an expected transient dip and stays a no-op; anything else unhandled now logs a qWarning. * fix(tests): empty pausable set no longer latches paused, so foregrounding skips the fetch
This commit is contained in:
@@ -20,6 +20,7 @@ NIM_TESTS_LINK_STATUSQ := \
|
||||
send_handler_adaptors_bench \
|
||||
send_handler_lookup_bench \
|
||||
send_modal_instantiation_bench \
|
||||
services_pause_bridge_test \
|
||||
signal_handler_test \
|
||||
swap_key_harvest_bench \
|
||||
swap_modal_instantiation_bench \
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
## App-lifecycle → status-go pausable-services bridge (iOS).
|
||||
##
|
||||
## On iOS status-go runs in-process and nothing drove PauseServices/
|
||||
## ResumeServices: services never paused on backgrounding (battery cost) and —
|
||||
## worse — never resumed on foregrounding, so the media server's listening
|
||||
## socket iOS kills during suspension stayed dead and every cached
|
||||
## https://localhost:<port>/ media URL failed until app restart. Resuming
|
||||
## re-runs the whole recovery chain: ResumeServices →
|
||||
## ServiceRegistry.ResumeMultiple → httpServer.ToForeground() → media server
|
||||
## rebinds → mediaserver.started signal → the media-URL refresh.
|
||||
##
|
||||
## Wired only on iOS: the Android service process already drives pause/resume
|
||||
## from binder UI-visibility changes (StatusGoService.java) — the UI process
|
||||
## must not double-drive it — and desktop apps are never suspended.
|
||||
##
|
||||
## Flap handling: appBackgrounded is emitted only for Qt::ApplicationSuspended
|
||||
## (a real backgrounding), never for the Inactive dips share sheets and system
|
||||
## alerts cause; appForegrounded fires on EVERY return to Active, so resume
|
||||
## runs only when a pause was actually driven (`paused` latch).
|
||||
##
|
||||
## Like the Android service, the service list is fetched from
|
||||
## PausableServices() at each transition, so services registered in status-go
|
||||
## later are picked up without client changes; an empty list (node not
|
||||
## running, e.g. backgrounded on the login screen) drives nothing.
|
||||
|
||||
import nimqml, chronicles
|
||||
import ./custom_urls/url_scheme_event
|
||||
|
||||
logScope:
|
||||
topics = "services-pause-bridge"
|
||||
|
||||
type PausableServicesCalls* = object
|
||||
## Seam over the backend's pausable-services mobile API
|
||||
## (backend/pausable_services), injectable for tests.
|
||||
pausableServiceNames*: proc(): seq[string]
|
||||
pauseServices*: proc(names: seq[string])
|
||||
resumeServices*: proc(names: seq[string])
|
||||
|
||||
QtObject:
|
||||
type ServicesPauseBridge* = ref object of QObject
|
||||
calls: PausableServicesCalls
|
||||
paused: bool
|
||||
|
||||
proc delete*(self: ServicesPauseBridge) =
|
||||
self.QObject.delete
|
||||
|
||||
proc onAppBackgrounded*(self: ServicesPauseBridge) {.slot.} =
|
||||
if self.paused:
|
||||
return
|
||||
let names = self.calls.pausableServiceNames()
|
||||
if names.len == 0:
|
||||
return
|
||||
self.paused = true
|
||||
info "app backgrounded, pausing services", names
|
||||
self.calls.pauseServices(names)
|
||||
|
||||
proc onAppForegrounded*(self: ServicesPauseBridge) {.slot.} =
|
||||
if not self.paused:
|
||||
return
|
||||
self.paused = false
|
||||
let names = self.calls.pausableServiceNames()
|
||||
if names.len == 0:
|
||||
return
|
||||
info "app foregrounded, resuming services", names
|
||||
self.calls.resumeServices(names)
|
||||
|
||||
proc setup(self: ServicesPauseBridge, urlSchemeEvent: UrlSchemeEvent) =
|
||||
self.QObject.setup
|
||||
# Both objects live on the main thread, so AutoConnection resolves to a
|
||||
# direct (synchronous) call — required on backgrounding: iOS freezes the
|
||||
# process soon after the state change is delivered, and a queued slot
|
||||
# might never run before suspension, leaving services un-paused.
|
||||
discard QObject.connect(urlSchemeEvent, SIGNAL("appBackgrounded()"),
|
||||
self, SLOT("onAppBackgrounded()"), ConnectionType.AutoConnection)
|
||||
discard QObject.connect(urlSchemeEvent, SIGNAL("appForegrounded()"),
|
||||
self, SLOT("onAppForegrounded()"), ConnectionType.AutoConnection)
|
||||
|
||||
proc newServicesPauseBridge*(urlSchemeEvent: UrlSchemeEvent,
|
||||
calls: PausableServicesCalls): ServicesPauseBridge =
|
||||
new(result)
|
||||
result.calls = calls
|
||||
result.paused = false
|
||||
result.setup(urlSchemeEvent)
|
||||
@@ -0,0 +1,16 @@
|
||||
import json
|
||||
|
||||
import base
|
||||
import signal_type
|
||||
|
||||
type MediaServerStartedSignal* = ref object of Signal
|
||||
## Emitted by status-go when the local media server (re)binds a port.
|
||||
## On mobile the process suspension kills the listener and the restart
|
||||
## picks a new ephemeral port, so every cached media URL goes stale.
|
||||
port*: int
|
||||
|
||||
proc fromEvent*(T: type MediaServerStartedSignal, jsonSignal: JsonNode): MediaServerStartedSignal =
|
||||
result = MediaServerStartedSignal()
|
||||
result.signalType = SignalType.MediaServerStarted
|
||||
if jsonSignal["event"].kind != JNull:
|
||||
result.port = jsonSignal["event"]{"port"}.getInt()
|
||||
@@ -106,6 +106,7 @@ QtObject:
|
||||
of SignalType.HistoryRequestStarted: HistoryRequestStartedSignal.fromEvent(jsonSignal)
|
||||
of SignalType.MailserverAvailable: MailserverAvailableSignal.fromEvent(jsonSignal)
|
||||
of SignalType.MailserverNotWorking: MailserverNotWorkingSignal.fromEvent(jsonSignal)
|
||||
of SignalType.MediaServerStarted: MediaServerStartedSignal.fromEvent(jsonSignal)
|
||||
of SignalType.HistoryArchivesProtocolEnabled: HistoryArchivesSignal.historyArchivesProtocolEnabledFromEvent(jsonSignal)
|
||||
of SignalType.HistoryArchivesProtocolDisabled: HistoryArchivesSignal.historyArchivesProtocolDisabledFromEvent(jsonSignal)
|
||||
of SignalType.CreatingHistoryArchives: HistoryArchivesSignal.creatingHistoryArchivesFromEvent(jsonSignal)
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
{.used.}
|
||||
|
||||
import ./remote_signals/[base, community, connection_status_change, connector, discovery_summary, envelope, expired, mailserver, messages,
|
||||
import ./remote_signals/[base, community, connection_status_change, connector, discovery_summary, envelope, expired, mailserver, mediaserver, messages,
|
||||
signal_type, wallet, whisper_filter, update_available, status_updates, backed_up_profile,
|
||||
backed_up_settings, pairing, node, networks, back_up_completed]
|
||||
|
||||
export base, community, connection_status_change, connector, discovery_summary, envelope, expired, mailserver, messages,
|
||||
export base, community, connection_status_change, connector, discovery_summary, envelope, expired, mailserver, mediaserver, messages,
|
||||
signal_type, wallet, whisper_filter, update_available, status_updates, backed_up_profile,
|
||||
backed_up_settings, back_up_completed, pairing, node, networks
|
||||
backed_up_settings, back_up_completed, pairing, node, networks
|
||||
|
||||
@@ -56,6 +56,10 @@ proc delete*(self: Controller) =
|
||||
self.events.disconnect()
|
||||
|
||||
proc init*(self: Controller) =
|
||||
self.events.on(SignalType.MediaServerStarted.event) do(e: Args):
|
||||
let args = MediaServerStartedSignal(e)
|
||||
self.delegate.onMediaServerStarted(args.port)
|
||||
|
||||
self.events.on(SIGNAL_PINNED_MESSAGES_LOADED) do(e:Args):
|
||||
let args = PinnedMessagesLoadedArgs(e)
|
||||
if(self.chatId != args.chatId or args.pinnedMessages.len == 0):
|
||||
|
||||
@@ -30,6 +30,9 @@ method onNotificationsUpdated*(self: AccessInterface, hasUnreadMessages: bool, n
|
||||
method newPinnedMessagesLoaded*(self: AccessInterface, pinnedMessages: seq[PinnedMessageDto], reactions: seq[ReactionDto]) {.base.} =
|
||||
raise newException(ValueError, "No implementation available")
|
||||
|
||||
method onMediaServerStarted*(self: AccessInterface, port: int) {.base.} =
|
||||
raise newException(ValueError, "No implementation available")
|
||||
|
||||
method onUnpinMessage*(self: AccessInterface, messageId: string) {.base.} =
|
||||
raise newException(ValueError, "No implementation available")
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ import ../../../../../../app_service/service/mailservers/service as mailservers_
|
||||
import ../../../../../../app_service/service/wallet_account/service as wallet_account_service
|
||||
import ../../../../../../app_service/service/shared_urls/service as shared_urls_service
|
||||
import ../../../../../core/eventemitter
|
||||
import ../../../../../core/signals/types as signal_types
|
||||
import ../../../../../core/unique_event_emitter
|
||||
|
||||
logScope:
|
||||
@@ -53,6 +54,10 @@ proc delete*(self: Controller) =
|
||||
self.events.disconnect()
|
||||
|
||||
proc init*(self: Controller) =
|
||||
self.events.on(SignalType.MediaServerStarted.event) do(e: Args):
|
||||
let args = MediaServerStartedSignal(e)
|
||||
self.delegate.onMediaServerStarted(args.port)
|
||||
|
||||
self.events.on(SIGNAL_MESSAGES_LOADED) do(e:Args):
|
||||
let args = MessagesLoadedArgs(e)
|
||||
if self.chatId != args.chatId:
|
||||
|
||||
@@ -75,6 +75,9 @@ method onMessageDelivered*(self: AccessInterface, messageId: string) {.base.} =
|
||||
method updateContactDetails*(self: AccessInterface, contactId: string) {.base.} =
|
||||
raise newException(ValueError, "No implementation available")
|
||||
|
||||
method onMediaServerStarted*(self: AccessInterface, port: int) {.base.} =
|
||||
raise newException(ValueError, "No implementation available")
|
||||
|
||||
method onMessageEdited*(self: AccessInterface, message: MessageDto) {.base.} =
|
||||
raise newException(ValueError, "No implementation available")
|
||||
|
||||
|
||||
@@ -453,6 +453,9 @@ method updateContactDetails*(self: Module, contactId: string) =
|
||||
|
||||
item.linkPreviewModel.setContactInfo(updatedContact)
|
||||
|
||||
method onMediaServerStarted*(self: Module, port: int) =
|
||||
self.view.model().updateMediaServerPort(port)
|
||||
|
||||
method deleteMessage*(self: Module, messageId: string) =
|
||||
self.controller.deleteMessage(messageId)
|
||||
|
||||
|
||||
@@ -213,6 +213,9 @@ method newPinnedMessagesLoaded*(self: Module, pinnedMessages: seq[PinnedMessageD
|
||||
return
|
||||
self.view.pinnedModel().insertItemsBasedOnClock(viewItems)
|
||||
|
||||
method onMediaServerStarted*(self: Module, port: int) =
|
||||
self.view.pinnedModel().updateMediaServerPort(port)
|
||||
|
||||
method unpinMessage*(self: Module, messageId: string) =
|
||||
self.controller.unpinMessage(messageId)
|
||||
|
||||
|
||||
@@ -104,6 +104,10 @@ proc delete*(self: Controller) =
|
||||
discard
|
||||
|
||||
proc init*(self: Controller) =
|
||||
self.events.on(SignalType.MediaServerStarted.event) do(e: Args):
|
||||
let args = MediaServerStartedSignal(e)
|
||||
self.delegate.onMediaServerStarted(args.port)
|
||||
|
||||
self.events.on(SIGNAL_ACTIVE_CHATS_LOADED) do(e:Args):
|
||||
self.delegate.onChatsLoaded(
|
||||
self.events,
|
||||
|
||||
@@ -99,6 +99,9 @@ method onChatsLoaded*(
|
||||
) {.base.} =
|
||||
raise newException(ValueError, "No implementation available")
|
||||
|
||||
method onMediaServerStarted*(self: AccessInterface, port: int) {.base.} =
|
||||
raise newException(ValueError, "No implementation available")
|
||||
|
||||
method onCommunityDataLoaded*(
|
||||
self: AccessInterface,
|
||||
events: EventEmitter,
|
||||
|
||||
@@ -1018,6 +1018,9 @@ method onChatsLoaded*[T](
|
||||
self.pendingProfileMigrationCheck = false
|
||||
self.checkAndPerformProfileMigrationIfNeeded()
|
||||
|
||||
method onMediaServerStarted*[T](self: Module[T], port: int) =
|
||||
self.view.model().updateMediaServerPort(port)
|
||||
|
||||
method onCommunityDataLoaded*[T](
|
||||
self: Module[T],
|
||||
events: EventEmitter,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import Nimqml, json, std/strformat
|
||||
import nimqml, json, std/strformat
|
||||
|
||||
import ../../../app_service/service/message/dto/message
|
||||
|
||||
|
||||
@@ -334,6 +334,13 @@ QtObject:
|
||||
updateRoleWithValue(isLocalData, true)
|
||||
updateRoleWithValue(loadingLocalData, false)
|
||||
|
||||
proc updateMediaServerPort*(self: Model, port: int) =
|
||||
# Thumbnails are QObjects with notify signals, so QML picks up the URL
|
||||
# change without a dataChanged emission.
|
||||
for item in self.items:
|
||||
if item.linkPreview != nil:
|
||||
item.linkPreview.updateMediaServerPort(port)
|
||||
|
||||
proc setContactInfo*(self: Model, contactDetails: ContactDetails) =
|
||||
for row, item in self.items:
|
||||
if item.linkPreview.setContactInfo(contactDetails):
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import json, std/strformat, strutils
|
||||
import app/global/global_singleton
|
||||
import ../../../app_service/common/media_server_url
|
||||
import ../../../app_service/common/types
|
||||
import ../../../app_service/service/contacts/dto/contact_details
|
||||
import ../../../app_service/service/message/dto/message
|
||||
@@ -698,3 +699,26 @@ proc quotedMessageAlbumImagesCount*(self: Item): int {.inline.} =
|
||||
|
||||
proc `quotedMessageAlbumImagesCount=`*(self: Item, value: int) {.inline.} =
|
||||
self.quotedMessageAlbumImagesCount = value
|
||||
|
||||
proc updateMediaServerPort*(self: Item, port: int): bool =
|
||||
## Re-points every cached media-server URL of this message at the freshly
|
||||
## bound port (the media server restarts on a new ephemeral port when the
|
||||
## mobile OS suspends and resumes the app). Non-media URLs are untouched.
|
||||
## Returns true when at least one field changed; link-preview thumbnails
|
||||
## notify QML directly and do not affect the return value.
|
||||
refreshMediaServerUrl(self.senderIcon, port, result)
|
||||
refreshMediaServerUrl(self.messageImage, port, result)
|
||||
refreshMediaServerUrl(self.sticker, port, result)
|
||||
refreshMediaServerUrl(self.quotedMessageAuthorAvatar, port, result)
|
||||
refreshMediaServerUrl(self.quotedMessageAuthorDetails.dto.image.thumbnail, port, result)
|
||||
refreshMediaServerUrl(self.quotedMessageAuthorDetails.dto.image.large, port, result)
|
||||
refreshMediaServerUrl(self.deletedByContactDetails.dto.image.thumbnail, port, result)
|
||||
refreshMediaServerUrl(self.deletedByContactDetails.dto.image.large, port, result)
|
||||
for i in 0 ..< self.albumMessageImages.len:
|
||||
refreshMediaServerUrl(self.albumMessageImages[i], port, result)
|
||||
for i in 0 ..< self.quotedMessageAlbumMessageImages.len:
|
||||
refreshMediaServerUrl(self.quotedMessageAlbumMessageImages[i], port, result)
|
||||
for i in 0 ..< self.messageAttachments.len:
|
||||
refreshMediaServerUrl(self.messageAttachments[i], port, result)
|
||||
if self.linkPreviewModel != nil:
|
||||
self.linkPreviewModel.updateMediaServerPort(port)
|
||||
|
||||
@@ -642,6 +642,26 @@ QtObject:
|
||||
for i in 0 ..< self.items.len:
|
||||
yield self.items[i]
|
||||
|
||||
proc updateMediaServerPort*(self: Model, port: int) =
|
||||
## Re-points every cached media-server URL at the freshly bound port
|
||||
## after a media-server restart (mobile suspend/resume). Rows whose
|
||||
## URLs changed re-emit their image-carrying roles so delegates reload.
|
||||
for i in 0 ..< self.items.len:
|
||||
if not self.items[i].updateMediaServerPort(port):
|
||||
continue
|
||||
let index = self.createIndex(i, 0, nil)
|
||||
defer: index.delete
|
||||
self.dataChanged(index, index, @[
|
||||
ModelRole.SenderIcon.int,
|
||||
ModelRole.MessageImage.int,
|
||||
ModelRole.Sticker.int,
|
||||
ModelRole.DeletedByContactIcon.int,
|
||||
ModelRole.QuotedMessageAuthorThumbnailImage.int,
|
||||
ModelRole.QuotedMessageAlbumMessageImages.int,
|
||||
ModelRole.AlbumMessageImages.int,
|
||||
ModelRole.MessageAttachments.int,
|
||||
])
|
||||
|
||||
iterator modelContactUpdateIterator*(self: Model, contactId: string): Item =
|
||||
for i in 0 ..< self.items.len:
|
||||
let senderMatches = self.items[i].senderId == contactId
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import Nimqml, json, std/strformat
|
||||
import nimqml, json, std/strformat
|
||||
|
||||
QtObject:
|
||||
type
|
||||
|
||||
@@ -5,6 +5,7 @@ import json
|
||||
import section_item, member_model, member_item
|
||||
import ../main/communities/tokens/models/[token_item, token_model]
|
||||
import model_utils
|
||||
import ../../../app_service/common/media_server_url
|
||||
import ../../../app_service/common/types
|
||||
import app/global/global_singleton
|
||||
|
||||
@@ -277,6 +278,23 @@ QtObject:
|
||||
updateItemRolesAndNotify self.getItemIndex(id):
|
||||
updateRole(muted)
|
||||
|
||||
proc updateMediaServerPort*(self: SectionModel, port: int) =
|
||||
## Re-points cached media-server URLs (community images/banners/icons)
|
||||
## at the freshly bound port after a media-server restart.
|
||||
for i in 0 ..< self.items.len:
|
||||
var changed = false
|
||||
refreshMediaServerUrl(self.items[i].image, port, changed)
|
||||
refreshMediaServerUrl(self.items[i].bannerImageData, port, changed)
|
||||
refreshMediaServerUrl(self.items[i].icon, port, changed)
|
||||
if changed:
|
||||
let dataIndex = self.createIndex(i, 0, nil)
|
||||
defer: dataIndex.delete
|
||||
self.dataChanged(dataIndex, dataIndex, @[
|
||||
ModelRole.Image.int,
|
||||
ModelRole.BannerImageData.int,
|
||||
ModelRole.Icon.int,
|
||||
])
|
||||
|
||||
proc editItem*(self: SectionModel, item: SectionItem) =
|
||||
updateItemRolesAndNotify self.getItemIndex(item.id):
|
||||
updateRolesFromItem(item,
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import std/[strutils, uri]
|
||||
|
||||
# status-go serves chat/profile/community media over a local HTTP(S) server
|
||||
# and embeds its ephemeral port in every URL it marshals. When that server
|
||||
# restarts on a new port (mobile suspend/resume) it emits the
|
||||
# `mediaserver.started` signal; subscribers use this helper to re-point
|
||||
# cached URLs at the new port.
|
||||
|
||||
const MEDIA_SERVER_HOSTS = ["localhost", "127.0.0.1", "0.0.0.0"]
|
||||
|
||||
proc withMediaServerPort*(url: string, port: int): string =
|
||||
## Rewrites the port of a loopback media-server URL. Anything else —
|
||||
## empty strings, data URIs, remote URLs, port-less or already-current
|
||||
## URLs — is returned unchanged, so callers can apply it blindly to any
|
||||
## cached URL field.
|
||||
if url.len == 0 or port <= 0:
|
||||
return url
|
||||
let u = parseUri(url)
|
||||
if u.scheme != "http" and u.scheme != "https":
|
||||
return url
|
||||
if u.hostname notin MEDIA_SERVER_HOSTS:
|
||||
return url
|
||||
if u.port.len == 0 or u.port == $port:
|
||||
return url
|
||||
# Splice around the authority instead of re-serializing the parsed URI,
|
||||
# so the rest of the URL stays byte-identical (round-tripping through
|
||||
# parseUri can re-encode path/query characters).
|
||||
let hostPort = u.hostname & ":" & u.port
|
||||
let idx = url.find(hostPort)
|
||||
if idx < 0:
|
||||
return url
|
||||
url[0 ..< idx] & u.hostname & ":" & $port & url[idx + hostPort.len .. ^1]
|
||||
|
||||
template refreshMediaServerUrl*(field, port, changed: untyped) =
|
||||
## Re-points `field` in place via `withMediaServerPort` and sets `changed`
|
||||
## to true when the URL actually changed. `changed` is left untouched
|
||||
## otherwise, so one flag can accumulate over several fields.
|
||||
let updated = withMediaServerPort(field, port)
|
||||
if updated != field:
|
||||
field = updated
|
||||
changed = true
|
||||
@@ -8,6 +8,7 @@ import app/core/tasks/[qt, threadpool]
|
||||
import ../../common/types as common_types
|
||||
import ../../common/conversion as service_conversion
|
||||
import ../../common/activity_center
|
||||
import ../../common/media_server_url
|
||||
|
||||
import ../settings/service as settings_service
|
||||
import ../network/service as network_service
|
||||
@@ -111,7 +112,6 @@ QtObject:
|
||||
contactsStatus: Table[string, StatusUpdateDto] # [contact_id, StatusUpdateDto]
|
||||
events: EventEmitter
|
||||
closingApp: bool
|
||||
imageServerUrl: string
|
||||
|
||||
# Forward declaration
|
||||
proc getContactById*(self: Service, id: string): ContactsDto
|
||||
@@ -119,6 +119,7 @@ QtObject:
|
||||
proc requestContactInfo*(self: Service, pubkey: string)
|
||||
proc constructContactDetails(self: Service, contactDto: ContactsDto, isCurrentUser: bool = false): ContactDetails
|
||||
proc parseContactsResponse*(self: Service, contacts: JsonNode, fromBackup: bool = false)
|
||||
proc onMediaServerStarted(self: Service, port: int)
|
||||
|
||||
proc delete*(self: Service)
|
||||
proc newService*(
|
||||
@@ -268,14 +269,31 @@ QtObject:
|
||||
if receivedData.statusUpdates.len > 0:
|
||||
self.updateAndEmitStatuses(receivedData.statusUpdates)
|
||||
|
||||
proc setImageServerUrl(self: Service) =
|
||||
try:
|
||||
let response = status_contacts.getImageServerURL()
|
||||
self.imageServerUrl = response.result.getStr()
|
||||
except Exception as e:
|
||||
let errDesription = e.msg
|
||||
error "error: ", errDesription
|
||||
return
|
||||
self.events.on(SignalType.MediaServerStarted.event) do(e: Args):
|
||||
let args = MediaServerStartedSignal(e)
|
||||
self.onMediaServerStarted(args.port)
|
||||
|
||||
proc onMediaServerStarted(self: Service, port: int) =
|
||||
## The media server rebinds to a new ephemeral port when the mobile OS
|
||||
## suspends/resumes the app. Re-point every cached contact image URL and
|
||||
## notify consumers so avatars keep loading (issue #47).
|
||||
var changedContactIds: seq[string]
|
||||
for contactId, details in self.contacts.mpairs:
|
||||
var changed = false
|
||||
refreshMediaServerUrl(details.icon, port, changed)
|
||||
refreshMediaServerUrl(details.dto.image.thumbnail, port, changed)
|
||||
refreshMediaServerUrl(details.dto.image.large, port, changed)
|
||||
if changed:
|
||||
changedContactIds.add(contactId)
|
||||
|
||||
for contactId in changedContactIds:
|
||||
self.events.emit(SIGNAL_CONTACT_UPDATED, ContactArgs(contactId: contactId))
|
||||
|
||||
# The logged-in user's own avatar is cached in the user profile
|
||||
# singleton; its setters emit imageChanged only when the value differs.
|
||||
let userProfile = singletonInstance.userProfile
|
||||
userProfile.setThumbnailImage(withMediaServerPort(userProfile.getThumbnailImage(), port))
|
||||
userProfile.setLargeImage(withMediaServerPort(userProfile.getLargeImage(), port))
|
||||
|
||||
proc onLoggedInUserNameChange*(self: Service) {.slot.} =
|
||||
let data = Args()
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import json, std/strformat, tables
|
||||
import ./status_link_preview, ./standard_link_preview
|
||||
import ./status_contact_link_preview, ./status_community_link_preview, ./status_community_channel_link_preview
|
||||
import ./link_preview_thumbnail
|
||||
import ../../contacts/dto/contact_details
|
||||
include ../../../common/json_utils
|
||||
|
||||
@@ -130,6 +131,25 @@ proc getCommunityId*(self: LinkPreview): string =
|
||||
return self.statusCommunityChannelPreview.getCommunity().getCommunityId()
|
||||
return ""
|
||||
|
||||
proc updateMediaServerPort*(self: LinkPreview, port: int) =
|
||||
## Media-server-served thumbnails embed the server's ephemeral port;
|
||||
## re-point them after a media-server restart. Safe on any preview:
|
||||
## only loopback media URLs are rewritten.
|
||||
template refresh(thumbnail: LinkPreviewThumbnail) =
|
||||
if thumbnail != nil:
|
||||
thumbnail.updateMediaServerPort(port)
|
||||
|
||||
if self.standardPreview != nil:
|
||||
refresh(self.standardPreview.getThumbnail())
|
||||
if self.statusContactPreview != nil:
|
||||
refresh(self.statusContactPreview.getIcon())
|
||||
if self.statusCommunityPreview != nil:
|
||||
refresh(self.statusCommunityPreview.getIcon())
|
||||
refresh(self.statusCommunityPreview.getBanner())
|
||||
if self.statusCommunityChannelPreview != nil and self.statusCommunityChannelPreview.getCommunity() != nil:
|
||||
refresh(self.statusCommunityChannelPreview.getCommunity().getIcon())
|
||||
refresh(self.statusCommunityChannelPreview.getCommunity().getBanner())
|
||||
|
||||
proc setContactInfo*(self: LinkPreview, contactDetails: ContactDetails): bool =
|
||||
if self.previewType == PreviewType.StatusContactPreview:
|
||||
return self.statusContactPreview.setContactInfo(contactDetails)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import json, std/strformat, nimqml, chronicles
|
||||
import ../../../common/media_server_url
|
||||
include ../../../common/json_utils
|
||||
|
||||
QtObject:
|
||||
@@ -82,6 +83,14 @@ QtObject:
|
||||
"dataUri": self.dataUri
|
||||
}
|
||||
|
||||
proc updateMediaServerPort*(self: LinkPreviewThumbnail, port: int) =
|
||||
## Re-points a media-server-served thumbnail at the freshly bound port
|
||||
## (no-op for remote/data-URI thumbnails).
|
||||
let updated = withMediaServerPort(self.url, port)
|
||||
if updated != self.url:
|
||||
self.url = updated
|
||||
self.urlChanged()
|
||||
|
||||
proc update*(self: LinkPreviewThumbnail, width: int, height: int, url: string, dataUri: string) =
|
||||
if self.width != width:
|
||||
self.width = width
|
||||
|
||||
@@ -65,10 +65,6 @@ proc sendContactUpdate*(publicKey, ensName, thumbnail: string): RpcResponse[Json
|
||||
let payload = %* [publicKey, ensName, thumbnail]
|
||||
result = callPrivateRPC("sendContactUpdate".prefix, payload)
|
||||
|
||||
proc getImageServerURL*(): RpcResponse[JsonNode] =
|
||||
let payload = %* []
|
||||
result = callPrivateRPC("imageServerURL".prefix, payload)
|
||||
|
||||
proc markAsTrusted*(pubkey: string): RpcResponse[JsonNode] =
|
||||
let payload = %* [pubkey]
|
||||
result = callPrivateRPC("markAsTrusted".prefix, payload)
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
## Pausable-services mobile API (PausableServices/PauseServices/ResumeServices).
|
||||
## These are libstatus C exports generated by status-go's
|
||||
## tools/generate-cbindings from the public functions in mobile/status.go;
|
||||
## vendor/nim-status-go does not bind them yet, so they are declared here with
|
||||
## the same importc pattern as status_go/impl.nim.
|
||||
|
||||
import json, chronicles
|
||||
|
||||
logScope:
|
||||
topics = "rpc-pausable-services"
|
||||
|
||||
proc statusGoPausableServices(): cstring {.importc: "PausableServices".}
|
||||
proc statusGoPauseServices(namesJson: cstring): cstring {.importc: "PauseServices".}
|
||||
proc statusGoResumeServices(namesJson: cstring): cstring {.importc: "ResumeServices".}
|
||||
|
||||
proc parsePausableServiceNames*(response: string): seq[string] =
|
||||
## Service names out of PausableServices()' response — a JSON array of
|
||||
## {name, state} objects. Empty for anything else: "null" when the node is
|
||||
## not running, error objects, malformed JSON (mirrors the Android service's
|
||||
## fetchPausableServiceNames guard).
|
||||
if response.len == 0:
|
||||
return @[]
|
||||
try:
|
||||
let parsed = parseJson(response)
|
||||
if parsed.kind != JArray:
|
||||
return @[]
|
||||
for item in parsed.getElems():
|
||||
let name = item{"name"}.getStr()
|
||||
if name.len > 0:
|
||||
result.add(name)
|
||||
except CatchableError:
|
||||
result = @[]
|
||||
|
||||
proc apiResponseError*(response: string): string =
|
||||
## The error field of a mobile-API {"error": ...} response; "" for success
|
||||
## or an unparsable response.
|
||||
try:
|
||||
result = parseJson(response){"error"}.getStr()
|
||||
except CatchableError:
|
||||
result = ""
|
||||
|
||||
proc pausableServiceNames*(): seq[string] =
|
||||
## Names of all pausable services currently registered in status-go;
|
||||
## empty when the node is not running.
|
||||
parsePausableServiceNames($statusGoPausableServices())
|
||||
|
||||
proc pauseServices*(names: seq[string]) =
|
||||
let err = apiResponseError($statusGoPauseServices(cstring($(%names))))
|
||||
if err.len > 0:
|
||||
warn "PauseServices returned an error", err
|
||||
|
||||
proc resumeServices*(names: seq[string]) =
|
||||
let err = apiResponseError($statusGoResumeServices(cstring($(%names))))
|
||||
if err.len > 0:
|
||||
warn "ResumeServices returned an error", err
|
||||
@@ -13,6 +13,10 @@ import app/core/signal_handler
|
||||
import app/core/custom_urls/url_scheme_event
|
||||
import app/global/single_instance
|
||||
|
||||
when defined(ios):
|
||||
import app/core/services_pause_bridge
|
||||
import backend/pausable_services as backend_pausable_services
|
||||
|
||||
import seaqt/qguiapplication
|
||||
import seaqt/qsslconfiguration
|
||||
import seaqt/qsslcertificate
|
||||
@@ -292,6 +296,19 @@ proc mainProc() =
|
||||
# init url manager before app controller
|
||||
statusFoundation.initUrlSchemeManager(urlSchemeEvent, singleInstance, openUri)
|
||||
|
||||
when defined(ios):
|
||||
# iOS runs status-go in-process, so the app lifecycle must drive the
|
||||
# pausable services itself: backgrounding pauses them, foregrounding
|
||||
# resumes them — which rebinds the media server iOS kills during
|
||||
# suspension and re-emits mediaserver.started (the media-URL refresh).
|
||||
# Android's service process drives this from binder visibility instead;
|
||||
# desktop apps are never suspended.
|
||||
let servicesPauseBridge = newServicesPauseBridge(urlSchemeEvent,
|
||||
PausableServicesCalls(
|
||||
pausableServiceNames: backend_pausable_services.pausableServiceNames,
|
||||
pauseServices: backend_pausable_services.pauseServices,
|
||||
resumeServices: backend_pausable_services.resumeServices))
|
||||
|
||||
let appController = newAppController(statusFoundation)
|
||||
|
||||
let isProductionQVariant = newQVariant(if defined(production): true else: false)
|
||||
@@ -346,6 +363,8 @@ proc mainProc() =
|
||||
signalsManagerQVariant.delete()
|
||||
appController.delete()
|
||||
statusFoundation.delete()
|
||||
when defined(ios):
|
||||
servicesPauseBridge.delete()
|
||||
singleInstance.delete()
|
||||
app.delete()
|
||||
|
||||
|
||||
@@ -19,6 +19,8 @@ proc statusq_urlscheme_create*(): pointer {.cdecl, importc.}
|
||||
proc statusq_urlscheme_set_instance*(obj: pointer) {.cdecl, importc.}
|
||||
proc statusq_urlscheme_install_event_filter*(obj: pointer) {.cdecl, importc.}
|
||||
proc statusq_urlscheme_emit_deeplink*(obj: pointer, url: cstring) {.cdecl, importc.}
|
||||
proc statusq_urlscheme_emit_appforegrounded*(obj: pointer) {.cdecl, importc.}
|
||||
proc statusq_urlscheme_emit_appbackgrounded*(obj: pointer) {.cdecl, importc.}
|
||||
proc statusq_urlscheme_delete*(obj: pointer) {.cdecl, importc.}
|
||||
|
||||
when defined(monitoring):
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import unittest
|
||||
|
||||
import app_service/common/media_server_url
|
||||
|
||||
# status-go mints absolute media URLs (https://localhost:<port>/...) at
|
||||
# marshal time. When the media server restarts on a new port (iOS
|
||||
# background/resume), cached URLs must be re-pointed — and only those:
|
||||
# the helper must be a safe no-op for every other string so callers can
|
||||
# apply it blindly to any cached URL field (issue #47).
|
||||
|
||||
suite "withMediaServerPort":
|
||||
|
||||
test "rewrites the port of a localhost media URL, preserving path and query":
|
||||
check withMediaServerPort("https://localhost:34567/messages/images?messageId=0xabc", 40000) ==
|
||||
"https://localhost:40000/messages/images?messageId=0xabc"
|
||||
|
||||
test "rewrites http and 127.0.0.1 forms too":
|
||||
check withMediaServerPort("http://127.0.0.1:1234/accountImages?keyUid=0x1&imageName=thumbnail", 999) ==
|
||||
"http://127.0.0.1:999/accountImages?keyUid=0x1&imageName=thumbnail"
|
||||
|
||||
test "keeps an already-current port untouched":
|
||||
let url = "https://localhost:40000/messages/images?messageId=0xabc"
|
||||
check withMediaServerPort(url, 40000) == url
|
||||
|
||||
test "no-op for empty strings and non-positive ports":
|
||||
check withMediaServerPort("", 40000) == ""
|
||||
check withMediaServerPort("https://localhost:34567/x", 0) == "https://localhost:34567/x"
|
||||
check withMediaServerPort("https://localhost:34567/x", -1) == "https://localhost:34567/x"
|
||||
|
||||
test "no-op for data URIs":
|
||||
let dataUri = "data:image/png;base64,iVBORw0KGgo="
|
||||
check withMediaServerPort(dataUri, 40000) == dataUri
|
||||
|
||||
test "no-op for remote URLs, with or without an explicit port":
|
||||
check withMediaServerPort("https://example.com/img.png", 40000) ==
|
||||
"https://example.com/img.png"
|
||||
check withMediaServerPort("https://example.com:8080/img.png", 40000) ==
|
||||
"https://example.com:8080/img.png"
|
||||
|
||||
test "no-op for local URLs without an explicit port":
|
||||
# The media server always embeds its ephemeral port; a port-less
|
||||
# localhost URL is not one of its URLs.
|
||||
check withMediaServerPort("https://localhost/img.png", 40000) ==
|
||||
"https://localhost/img.png"
|
||||
|
||||
test "no-op for non-http schemes and plain paths":
|
||||
check withMediaServerPort("qrc:/imports/assets/x.svg", 40000) == "qrc:/imports/assets/x.svg"
|
||||
check withMediaServerPort("file:///tmp/x.png", 40000) == "file:///tmp/x.png"
|
||||
check withMediaServerPort("/tmp/x.png", 40000) == "/tmp/x.png"
|
||||
|
||||
test "a port embedded in the query string is not touched":
|
||||
check withMediaServerPort("https://localhost:34567/proxy?u=http://localhost:34567/a", 40000) ==
|
||||
"https://localhost:40000/proxy?u=http://localhost:34567/a"
|
||||
@@ -390,3 +390,65 @@ suite "mark message as unread":
|
||||
# and marker is insert last at position : position('0xb') - 1 equals to position 2 here
|
||||
check(model.items[2].seen == true)
|
||||
check(model.items[3].seen == false)
|
||||
|
||||
# The status-go media server restarts on a new ephemeral port when iOS
|
||||
# suspends/resumes the app; cached absolute media URLs must be re-pointed
|
||||
# at the new port while every other URL stays untouched (issue #47).
|
||||
suite "media server port refresh":
|
||||
const oldBase = "https://localhost:34567"
|
||||
const newBase = "https://localhost:40000"
|
||||
const remoteAvatar = "https://cdn.example.com/avatar.png"
|
||||
|
||||
proc createImageMessageItem(id: string, clock: int64): Item =
|
||||
return message_model.createMessageItemFromDtos(
|
||||
message = MessageDto(
|
||||
id: id,
|
||||
clock: clock,
|
||||
contentType: ContentType.Image,
|
||||
image: oldBase & "/messages/images?messageId=" & id,
|
||||
albumId: "album-1",
|
||||
sticker: Sticker(url: oldBase & "/ipfs?hash=0xdeadbeef"),
|
||||
),
|
||||
communityId = "",
|
||||
sender = ContactDetails(
|
||||
icon: oldBase & "/contactImages?publicKey=0xsender&imageName=thumbnail",
|
||||
),
|
||||
isCurrentUser = false,
|
||||
renderedMessageText = "",
|
||||
clearText = "",
|
||||
albumImages = @[oldBase & "/messages/images?messageId=" & id],
|
||||
albumMessageIds = @[id],
|
||||
quotedMessageAuthorDetails = ContactDetails(
|
||||
icon: remoteAvatar,
|
||||
dto: ContactsDto(image: Images(thumbnail: remoteAvatar)),
|
||||
),
|
||||
)
|
||||
|
||||
test "updateMediaServerPort re-points cached local media URLs":
|
||||
let model = newModel()
|
||||
model.insertItemsBasedOnClock(@[createImageMessageItem("0x1", 1)])
|
||||
|
||||
model.updateMediaServerPort(40000)
|
||||
|
||||
let item = model.items[0]
|
||||
check(item.messageImage == newBase & "/messages/images?messageId=0x1")
|
||||
check(item.senderIcon == newBase & "/contactImages?publicKey=0xsender&imageName=thumbnail")
|
||||
check(item.albumMessageImages == @[newBase & "/messages/images?messageId=0x1"])
|
||||
check(item.sticker == newBase & "/ipfs?hash=0xdeadbeef")
|
||||
|
||||
test "updateMediaServerPort leaves remote URLs untouched":
|
||||
let model = newModel()
|
||||
model.insertItemsBasedOnClock(@[createImageMessageItem("0x1", 1)])
|
||||
|
||||
model.updateMediaServerPort(40000)
|
||||
|
||||
check(model.items[0].quotedMessageAuthorAvatar == remoteAvatar)
|
||||
|
||||
test "updateMediaServerPort is idempotent":
|
||||
let model = newModel()
|
||||
model.insertItemsBasedOnClock(@[createImageMessageItem("0x1", 1)])
|
||||
|
||||
model.updateMediaServerPort(40000)
|
||||
model.updateMediaServerPort(40000)
|
||||
|
||||
check(model.items[0].messageImage == newBase & "/messages/images?messageId=0x1")
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
## Tests for the iOS app-lifecycle → pausable-services bridge (issue #51):
|
||||
## driven over the real StatusQ appBackgrounded/appForegrounded signals,
|
||||
## with the backend seam replaced by recorders.
|
||||
## - a real backgrounding (Qt::ApplicationSuspended → appBackgrounded) pauses
|
||||
## the full pausable set fetched from the backend; the matching
|
||||
## foregrounding resumes it — the chain that makes the media server rebind
|
||||
## and emit mediaserver.started (the #47 URL refresh trigger);
|
||||
## - foreground flaps with no preceding pause (share sheets and system alerts
|
||||
## dip the app to Inactive and back — appForegrounded fires on every return
|
||||
## to Active) drive nothing;
|
||||
## - repeated backgrounded events pause once; each pause resumes at most once;
|
||||
## - an empty pausable set (node not running, e.g. backgrounded on the login
|
||||
## screen) drives no pause and no resume;
|
||||
## - the second suite covers the backend response parsing (PausableServices
|
||||
## returns "null" when the node is down; Pause/ResumeServices return
|
||||
## {"error": ...} envelopes).
|
||||
|
||||
import unittest
|
||||
import nimqml
|
||||
import app/core/custom_urls/url_scheme_event
|
||||
import app/core/services_pause_bridge
|
||||
import backend/pausable_services as backend_pausable_services
|
||||
import statusq_bridge
|
||||
# Selective import: pulling all of gen_qcoreapplication would re-export the seaqt
|
||||
# gen_qobject_types.QObject and make nimqml's QObject ambiguous (see url_scheme_event_test).
|
||||
from seaqt/qcoreapplication import QCoreApplication, create
|
||||
|
||||
discard QCoreApplication.create() # one app for the whole suite
|
||||
|
||||
suite "services_pause_bridge":
|
||||
setup:
|
||||
let urlSchemeEvent = newUrlSchemeEvent()
|
||||
var names = @["mediaserver", "messenger", "localbackups"]
|
||||
# unittest's setup vars live at module scope: a `= 0` constant initializer is
|
||||
# emitted once and not re-run per case, so the counter must be reset here
|
||||
# explicitly (unlike the seqs, whose `@[]` runtime init already resets).
|
||||
var fetches: int
|
||||
fetches = 0
|
||||
var pauseCalls: seq[seq[string]] = @[]
|
||||
var resumeCalls: seq[seq[string]] = @[]
|
||||
let bridge = newServicesPauseBridge(urlSchemeEvent, PausableServicesCalls(
|
||||
pausableServiceNames: proc(): seq[string] =
|
||||
inc fetches
|
||||
names,
|
||||
pauseServices: proc(serviceNames: seq[string]) =
|
||||
pauseCalls.add(serviceNames),
|
||||
resumeServices: proc(serviceNames: seq[string]) =
|
||||
resumeCalls.add(serviceNames)))
|
||||
|
||||
teardown:
|
||||
bridge.delete()
|
||||
|
||||
test "backgrounding pauses the full pausable set":
|
||||
statusq_urlscheme_emit_appbackgrounded(urlSchemeEvent.vptr)
|
||||
|
||||
check pauseCalls == @[@["mediaserver", "messenger", "localbackups"]]
|
||||
check resumeCalls.len == 0
|
||||
|
||||
test "foregrounding after a pause resumes the services":
|
||||
statusq_urlscheme_emit_appbackgrounded(urlSchemeEvent.vptr)
|
||||
statusq_urlscheme_emit_appforegrounded(urlSchemeEvent.vptr)
|
||||
|
||||
check pauseCalls == @[@["mediaserver", "messenger", "localbackups"]]
|
||||
check resumeCalls == @[@["mediaserver", "messenger", "localbackups"]]
|
||||
|
||||
test "foregrounding without a preceding pause drives nothing":
|
||||
# Share sheets, system alerts and app start all re-enter Active without a
|
||||
# suspension; resuming never-paused services would be noise.
|
||||
statusq_urlscheme_emit_appforegrounded(urlSchemeEvent.vptr)
|
||||
statusq_urlscheme_emit_appforegrounded(urlSchemeEvent.vptr)
|
||||
|
||||
check fetches == 0
|
||||
check pauseCalls.len == 0
|
||||
check resumeCalls.len == 0
|
||||
|
||||
test "repeated backgrounded events pause once":
|
||||
statusq_urlscheme_emit_appbackgrounded(urlSchemeEvent.vptr)
|
||||
statusq_urlscheme_emit_appbackgrounded(urlSchemeEvent.vptr)
|
||||
|
||||
check pauseCalls.len == 1
|
||||
|
||||
test "each pause resumes at most once":
|
||||
statusq_urlscheme_emit_appbackgrounded(urlSchemeEvent.vptr)
|
||||
statusq_urlscheme_emit_appforegrounded(urlSchemeEvent.vptr)
|
||||
statusq_urlscheme_emit_appforegrounded(urlSchemeEvent.vptr)
|
||||
|
||||
check pauseCalls.len == 1
|
||||
check resumeCalls.len == 1
|
||||
|
||||
test "full background/foreground cycles pause and resume each time":
|
||||
statusq_urlscheme_emit_appbackgrounded(urlSchemeEvent.vptr)
|
||||
statusq_urlscheme_emit_appforegrounded(urlSchemeEvent.vptr)
|
||||
statusq_urlscheme_emit_appbackgrounded(urlSchemeEvent.vptr)
|
||||
statusq_urlscheme_emit_appforegrounded(urlSchemeEvent.vptr)
|
||||
|
||||
check pauseCalls.len == 2
|
||||
check resumeCalls.len == 2
|
||||
|
||||
test "the pausable set is re-fetched at each transition":
|
||||
# Mirrors the Android service: services registered in status-go later are
|
||||
# picked up without client changes.
|
||||
statusq_urlscheme_emit_appbackgrounded(urlSchemeEvent.vptr)
|
||||
names = @["mediaserver", "messenger", "localbackups", "downloader"]
|
||||
statusq_urlscheme_emit_appforegrounded(urlSchemeEvent.vptr)
|
||||
|
||||
check pauseCalls == @[@["mediaserver", "messenger", "localbackups"]]
|
||||
check resumeCalls ==
|
||||
@[@["mediaserver", "messenger", "localbackups", "downloader"]]
|
||||
|
||||
test "an empty pausable set drives no pause and no resume":
|
||||
# Node not running: backgrounded on the login screen. Nothing was paused,
|
||||
# so the paused latch stays clear and the foreground event returns before
|
||||
# fetching — only the backgrounding fetches.
|
||||
names = @[]
|
||||
statusq_urlscheme_emit_appbackgrounded(urlSchemeEvent.vptr)
|
||||
statusq_urlscheme_emit_appforegrounded(urlSchemeEvent.vptr)
|
||||
|
||||
check fetches == 1
|
||||
check pauseCalls.len == 0
|
||||
check resumeCalls.len == 0
|
||||
|
||||
suite "pausable_services backend parsing":
|
||||
test "the backend procs satisfy the bridge seam (nim_status_client wiring)":
|
||||
# Mirrors the mainProc construction exactly: the plain backend procs must
|
||||
# convert to the closure-typed seam fields, and forcing their codegen
|
||||
# proves the PausableServices/PauseServices/ResumeServices importc symbols
|
||||
# link against libstatus' generated C exports. Not invoked: no node runs
|
||||
# here.
|
||||
let calls = PausableServicesCalls(
|
||||
pausableServiceNames: backend_pausable_services.pausableServiceNames,
|
||||
pauseServices: backend_pausable_services.pauseServices,
|
||||
resumeServices: backend_pausable_services.resumeServices)
|
||||
check not calls.pausableServiceNames.isNil
|
||||
check not calls.pauseServices.isNil
|
||||
check not calls.resumeServices.isNil
|
||||
|
||||
test "a service list parses to its names":
|
||||
check backend_pausable_services.parsePausableServiceNames(
|
||||
"""[{"name":"mediaserver","state":"running"},
|
||||
{"name":"messenger","state":"paused"}]""") ==
|
||||
@["mediaserver", "messenger"]
|
||||
|
||||
test "node-not-running null response parses to no names":
|
||||
# mobile/status.go marshals the nil PausableServices() slice to "null".
|
||||
check backend_pausable_services.parsePausableServiceNames("null").len == 0
|
||||
|
||||
test "empty, error-object and malformed responses parse to no names":
|
||||
check backend_pausable_services.parsePausableServiceNames("").len == 0
|
||||
check backend_pausable_services.parsePausableServiceNames("[]").len == 0
|
||||
check backend_pausable_services.parsePausableServiceNames(
|
||||
"""{"error":"node stopped"}""").len == 0
|
||||
check backend_pausable_services.parsePausableServiceNames(
|
||||
"not json").len == 0
|
||||
|
||||
test "entries without a usable name are skipped":
|
||||
check backend_pausable_services.parsePausableServiceNames(
|
||||
"""[{"name":"mediaserver"},{"state":"running"},{"name":""}]""") ==
|
||||
@["mediaserver"]
|
||||
|
||||
test "api response error envelope parses":
|
||||
check backend_pausable_services.apiResponseError("""{"error":""}""") == ""
|
||||
check backend_pausable_services.apiResponseError(
|
||||
"""{"error":"service not found in registry: \"x\""}""") ==
|
||||
"service not found in registry: \"x\""
|
||||
check backend_pausable_services.apiResponseError("garbage") == ""
|
||||
@@ -4,6 +4,7 @@ import app/core/eventemitter
|
||||
import app/core/signals/signals_manager
|
||||
import app/core/signals/signal_type_scan
|
||||
import app/core/signals/remote_signals/signal_type
|
||||
import app/core/signals/remote_signals/mediaserver
|
||||
|
||||
# Exercises the real ManageSignals dispatch path (`processSignal`) to prove that
|
||||
# the cheap-triage guard skips unhandled types before parsing
|
||||
@@ -56,6 +57,33 @@ suite "SignalsManager - unhandled signal-type skipping":
|
||||
check dispatchedCount == 1
|
||||
check unhandledSignalCount() == 1
|
||||
|
||||
test "mediaserver.started decodes the port and dispatches a typed signal":
|
||||
# iOS restarts the media server on resume; the new port must survive the
|
||||
# decode so subscribers can rewrite cached media URLs (issue #47).
|
||||
let emitter = createEventEmitter()
|
||||
var receivedPort = 0
|
||||
emitter.on(SignalType.MediaServerStarted.event) do(a: Args):
|
||||
receivedPort = MediaServerStartedSignal(a).port
|
||||
|
||||
let manager = newSignalsManager(emitter)
|
||||
manager.processSignal("""{"type":"mediaserver.started","event":{"port":43210}}""")
|
||||
|
||||
check receivedPort == 43210
|
||||
|
||||
test "mediaserver.started with a null event dispatches with port 0":
|
||||
let emitter = createEventEmitter()
|
||||
var dispatched = false
|
||||
var receivedPort = -1
|
||||
emitter.on(SignalType.MediaServerStarted.event) do(a: Args):
|
||||
dispatched = true
|
||||
receivedPort = MediaServerStartedSignal(a).port
|
||||
|
||||
let manager = newSignalsManager(emitter)
|
||||
manager.processSignal("""{"type":"mediaserver.started","event":null}""")
|
||||
|
||||
check dispatched
|
||||
check receivedPort == 0
|
||||
|
||||
test "a handled type in a whitespaced envelope still dispatches (scan-miss fallback)":
|
||||
# The fast substring scan assumes status-go's compact `"type":"` byte token.
|
||||
# If the marshaling format ever changes (e.g. a space after the colon), the
|
||||
|
||||
@@ -12,6 +12,9 @@ namespace Status
|
||||
|
||||
public:
|
||||
void emitDeepLinkToQt(const QString& url);
|
||||
void emitAppForegroundedToQt();
|
||||
void emitAppBackgroundedToQt();
|
||||
void watchApplicationState();
|
||||
static void setInstance(UrlSchemeEvent* instance);
|
||||
|
||||
void registerUrlHandler();
|
||||
@@ -24,6 +27,12 @@ namespace Status
|
||||
|
||||
signals:
|
||||
void urlActivated(const QString& url);
|
||||
void appForegrounded();
|
||||
// Emitted only on Qt::ApplicationSuspended — a real backgrounding
|
||||
// (iOS applicationDidEnterBackground). Qt::ApplicationInactive is
|
||||
// NOT backgrounded: share sheets and system alerts briefly
|
||||
// deactivate the app without suspending it.
|
||||
void appBackgrounded();
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -115,6 +115,7 @@ Q_DECL_EXPORT void statusq_invoke_method_queued(void* obj, const char* method, c
|
||||
Q_DECL_EXPORT void* statusq_urlscheme_create() {
|
||||
auto* ev = new Status::UrlSchemeEvent();
|
||||
ev->registerUrlHandler();
|
||||
ev->watchApplicationState();
|
||||
return ev;
|
||||
}
|
||||
|
||||
@@ -130,6 +131,14 @@ Q_DECL_EXPORT void statusq_urlscheme_emit_deeplink(void* obj, const char* url) {
|
||||
static_cast<Status::UrlSchemeEvent*>(obj)->emitDeepLinkToQt(QString::fromUtf8(url));
|
||||
}
|
||||
|
||||
Q_DECL_EXPORT void statusq_urlscheme_emit_appforegrounded(void* obj) {
|
||||
static_cast<Status::UrlSchemeEvent*>(obj)->emitAppForegroundedToQt();
|
||||
}
|
||||
|
||||
Q_DECL_EXPORT void statusq_urlscheme_emit_appbackgrounded(void* obj) {
|
||||
static_cast<Status::UrlSchemeEvent*>(obj)->emitAppBackgroundedToQt();
|
||||
}
|
||||
|
||||
Q_DECL_EXPORT void statusq_urlscheme_delete(void* obj) {
|
||||
static_cast<QObject*>(obj)->deleteLater();
|
||||
}
|
||||
|
||||
@@ -9,7 +9,9 @@ using namespace Status;
|
||||
#include <QJniObject>
|
||||
#endif // Q_OS_ANDROID
|
||||
|
||||
#include <QDebug>
|
||||
#include <QDesktopServices>
|
||||
#include <QGuiApplication>
|
||||
|
||||
void UrlSchemeEvent::registerUrlHandler()
|
||||
{
|
||||
@@ -43,6 +45,44 @@ bool UrlSchemeEvent::eventFilter(QObject* obj, QEvent* event)
|
||||
return QObject::eventFilter(obj, event);
|
||||
}
|
||||
|
||||
void UrlSchemeEvent::watchApplicationState()
|
||||
{
|
||||
// appBackgrounded/appForegrounded drive the iOS pausable-services
|
||||
// bridge (src/app/core/services_pause_bridge.nim): pause on suspension,
|
||||
// resume — and media-server rebind — on return to the foreground.
|
||||
// Under QCoreApplication (unit tests) there is no application state; skip.
|
||||
if (auto* app = qobject_cast<QGuiApplication*>(QCoreApplication::instance())) {
|
||||
connect(app, &QGuiApplication::applicationStateChanged, this,
|
||||
[this](Qt::ApplicationState state) {
|
||||
switch (state) {
|
||||
case Qt::ApplicationActive:
|
||||
emit appForegrounded();
|
||||
break;
|
||||
case Qt::ApplicationSuspended:
|
||||
emit appBackgrounded();
|
||||
break;
|
||||
case Qt::ApplicationInactive:
|
||||
// Transient dip (share sheets, system alerts, app
|
||||
// switcher) — deliberately not a backgrounding.
|
||||
break;
|
||||
default:
|
||||
qWarning() << "Unhandled application state:" << state;
|
||||
break;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
void UrlSchemeEvent::emitAppForegroundedToQt()
|
||||
{
|
||||
emit appForegrounded();
|
||||
}
|
||||
|
||||
void UrlSchemeEvent::emitAppBackgroundedToQt()
|
||||
{
|
||||
emit appBackgrounded();
|
||||
}
|
||||
|
||||
void UrlSchemeEvent::emitDeepLinkToQt(const QString& url)
|
||||
{
|
||||
if (url.isEmpty()) return;
|
||||
|
||||
Reference in New Issue
Block a user