Compare commits

...
457 Commits
Author SHA1 Message Date
Vitor Pamplona bb01918120 v1.05.0 2026-01-08 16:21:30 -05:00
Vitor Pamplona f2bda2e5ec No need to set items directly. This happens later. 2026-01-08 15:23:11 -05:00
Vitor Pamplona dd259bab33 Separates currentFilters from lastSeenFilters to correctly close subscriptions with relays when ending them on NostrClient. 2026-01-08 10:00:42 -05:00
Vitor Pamplona 19d3c5a10e Merge branch 'main' of https://github.com/vitorpamplona/amethyst
* 'main' of https://github.com/vitorpamplona/amethyst:
  New Crowdin translations by GitHub Action
2026-01-07 16:42:16 -05:00
Vitor Pamplona d5ba3ffc04 Simplifies NIP-05 processing 2026-01-07 16:38:51 -05:00
Vitor Pamplona 882cf6ee59 Moving OpenGraph parser to commons 2026-01-07 16:34:15 -05:00
Vitor Pamplona cba4d25424 Moves follows and publish to their own packages 2026-01-07 16:16:01 -05:00
Vitor PamplonaandGitHub 1176093567 Merge pull request #1647 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-01-07 15:37:36 -05:00
Crowdin Bot b5a9b346c6 New Crowdin translations by GitHub Action 2026-01-07 20:35:11 +00:00
Vitor Pamplona 6b8036eac4 Forcing notify relay to be either the NIP-65 or DM relays 2026-01-07 15:33:16 -05:00
Vitor Pamplona 139e9fd3ef Re-adds name to the profile edit page. 2026-01-07 14:52:40 -05:00
Vitor Pamplona 259b476139 Merge branch 'main' of https://github.com/vitorpamplona/amethyst
* 'main' of https://github.com/vitorpamplona/amethyst:
  New Crowdin translations by GitHub Action
2026-01-07 14:43:46 -05:00
Vitor Pamplona 92f07c5615 Remove deprecated field 2026-01-07 14:33:56 -05:00
Vitor Pamplona dceabbb51e delete duplicated file 2026-01-07 14:33:45 -05:00
Vitor Pamplona 96ae047271 Removes unnecessary file from commons 2026-01-07 14:31:23 -05:00
Vitor PamplonaandGitHub ab99dc34f3 Merge pull request #1646 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-01-07 14:04:01 -05:00
Crowdin Bot 331124c92a New Crowdin translations by GitHub Action 2026-01-07 18:42:55 +00:00
Vitor Pamplona 13aaaeacd8 Fixes imports 2026-01-07 13:40:24 -05:00
Vitor Pamplona 64c237a4e5 Merge branch 'main' of https://github.com/vitorpamplona/amethyst
* 'main' of https://github.com/vitorpamplona/amethyst:
  New Crowdin translations by GitHub Action
2026-01-07 13:27:00 -05:00
Vitor Pamplona 60dafd9267 Moves reactions to packages for their nips 2026-01-07 13:26:47 -05:00
Vitor Pamplona 1783fbab59 Separating ListChange 2026-01-07 13:18:17 -05:00
David KasparandGitHub 78d614129a Merge pull request #1645 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-01-07 17:26:51 +00:00
Crowdin Bot 5051eb6d40 New Crowdin translations by GitHub Action 2026-01-07 17:25:00 +00:00
Vitor Pamplona cf174c7734 Removes dependency on okhttp for the HTMLParser 2026-01-07 12:22:57 -05:00
Vitor Pamplona 0e59ea570e Merge branch 'main' of https://github.com/vitorpamplona/amethyst
* 'main' of https://github.com/vitorpamplona/amethyst:
  New Crowdin translations by GitHub Action
2026-01-07 12:09:40 -05:00
Vitor Pamplona e3832ee19b Moves Filters from Common to Desktop 2026-01-07 12:06:28 -05:00
Vitor Pamplona 5cb0231cf9 Fixes the choice of padding to match what it was on Amethyst 2026-01-07 12:06:11 -05:00
Vitor PamplonaandGitHub ed5246e8c3 Merge pull request #1644 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-01-07 11:57:24 -05:00
Vitor Pamplona 806bc2729a Fixes places for the test and common code 2026-01-07 11:51:45 -05:00
Crowdin Bot e05aa611eb New Crowdin translations by GitHub Action 2026-01-07 16:43:22 +00:00
Vitor PamplonaandGitHub 9014424f0d Merge pull request #1643 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-01-07 11:42:50 -05:00
Crowdin Bot e84eca2bca New Crowdin translations by GitHub Action 2026-01-07 16:42:01 +00:00
Vitor Pamplona dd9d7ac0c8 Merge remote-tracking branch 'origin/nrobi144/desktop-phase1'
* origin/nrobi144/desktop-phase1:
  Fixes FilledFilter test
  moves keystore to commons to avoid unnecessary dependencies on Quartz
  Fixes missing tagsAll
2026-01-07 11:41:30 -05:00
Vitor PamplonaandGitHub 71448d9712 Merge pull request #1642 from nrobi144/nrobi144/desktop-phase1
Desktop Phase 1 - Compose Multiplatform Support
2026-01-07 11:40:46 -05:00
Vitor Pamplona d3e0cdd135 Fixes FilledFilter test 2026-01-07 11:16:26 -05:00
Vitor Pamplona ce01e8f70c moves keystore to commons to avoid unnecessary dependencies on Quartz 2026-01-07 11:09:45 -05:00
Vitor Pamplona 7a7ce69709 Fixes missing tagsAll 2026-01-07 10:54:09 -05:00
Vitor PamplonaandGitHub 9232da7421 Merge branch 'main' into nrobi144/desktop-phase1 2026-01-07 10:43:15 -05:00
Vitor Pamplona dcbe7df007 Fixes test 2026-01-07 10:07:26 -05:00
Vitor PamplonaandGitHub 48bd284ef1 Update AccountManager.kt 2026-01-07 09:24:40 -05:00
Vitor PamplonaandGitHub 41cb143e0d Merge branch 'main' into nrobi144/desktop-phase1 2026-01-07 09:23:57 -05:00
nrobi144 a9141763e1 previous PR fixes 2026-01-07 07:12:00 +02:00
Vitor PamplonaandGitHub 60ce65470f Merge pull request #1641 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-01-06 19:02:45 -05:00
Crowdin Bot deb5da4866 New Crowdin translations by GitHub Action 2026-01-07 00:00:42 +00:00
Vitor Pamplona b65546487d Adjusts tests to be more flexible when reading NIPs from NIP-11 2026-01-06 18:57:56 -05:00
Vitor Pamplona 109150e693 These logs make everything go slow :( 2026-01-06 18:53:18 -05:00
Vitor Pamplona 93b139da62 Fixes test case 2026-01-06 18:39:51 -05:00
Vitor Pamplona f33e2fbcfb Disables top bar reappearance to avoid the feed moving when coming back from page to page. 2026-01-06 18:06:24 -05:00
Vitor Pamplona 3c85220c7c fixes package name 2026-01-06 18:05:30 -05:00
Vitor Pamplona cde8a343cb Merge branch 'main' of https://github.com/vitorpamplona/amethyst
* 'main' of https://github.com/vitorpamplona/amethyst:
  remove unused imports
2026-01-06 17:39:04 -05:00
Vitor Pamplona e26fb94933 Improvements on NIP-11
- Support for self, privacy_policy, terms_of_service, grasps
- New UI for the Relay Information Screen
- Improvements to the Debug Message
- Compose-stable objects
- Clickable elements for NIPs, external links, grasps, etc
2026-01-06 17:38:47 -05:00
David KasparandGitHub 83af151c0d Merge pull request #1639 from davotoula/main
remove unused imports
2026-01-06 17:48:02 +00:00
David KasparandGitHub 185979f82c Merge branch 'vitorpamplona:main' into main 2026-01-06 17:11:18 +00:00
Vitor Pamplona 36b50576d9 Merge branch 'main' of https://github.com/vitorpamplona/amethyst
* 'main' of https://github.com/vitorpamplona/amethyst:
  New Crowdin translations by GitHub Action
2026-01-06 10:09:23 -05:00
Vitor Pamplona fd47391afe Decreases the icon even further 2026-01-06 10:08:29 -05:00
nrobi144 fd83560852 key storage implementation 2026-01-06 17:08:28 +02:00
davotoula d837fbb4c9 remove unused imports 2026-01-06 15:49:35 +01:00
David KasparandGitHub 2d1a151195 Merge pull request #1638 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-01-06 14:36:18 +00:00
Crowdin Bot fe0723889d New Crowdin translations by GitHub Action 2026-01-06 14:34:17 +00:00
Vitor Pamplona 173a40394e Smaller icon to avoid interfering with Trust scores 2026-01-06 09:32:23 -05:00
nrobi144 df9d648988 key storage spotless 2026-01-06 06:50:31 +02:00
nrobi144 4339247815 key storage 2026-01-06 06:49:59 +02:00
Vitor Pamplona 40f9622722 Checks kind size before throwing 2026-01-05 19:41:23 -05:00
Vitor Pamplona 8a5e05c0ac Simple check to make sure events are valid. 2026-01-05 19:26:24 -05:00
Vitor Pamplona 69c53aca06 Improving error logs 2026-01-05 19:26:00 -05:00
Vitor Pamplona 2c4279621d Makes the Kotlin Serialization lenient to avoid unnecessary crashes 2026-01-05 17:18:01 -05:00
Vitor Pamplona 1835c2238e removes unecessary log 2026-01-05 16:01:39 -05:00
Vitor Pamplona a9df6fcc61 Adds the default bookmark option to be inside the bookmark list screen 2026-01-05 13:38:24 -05:00
Vitor Pamplona 2fc7e505a3 Update stability analiser 2026-01-05 09:36:46 -05:00
nrobi144 2079493fc4 spotless 2026-01-05 13:43:54 +02:00
nrobi144 7d3ac9ec8a add avatars 2026-01-05 13:43:00 +02:00
nrobi144 55d59fcfd2 fix follows not working 2026-01-05 13:12:39 +02:00
nrobi144 115c622dca fix profile not loading 2026-01-05 06:46:19 +02:00
Vitor Pamplona a151bba3d0 Improves stability for video ui 2026-01-04 16:41:58 -05:00
Vitor Pamplona fe1e0e46c3 Fixes warnings on test case 2026-01-04 16:38:21 -05:00
Vitor Pamplona fdc751f9e5 Updates Vico 2026-01-04 16:38:06 -05:00
Vitor Pamplona eeafeeff17 Moves to new banner by Purple Painer 2026-01-04 14:40:45 -05:00
Vitor PamplonaandGitHub 0d665c41d2 Merge pull request #1637 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-01-04 13:42:21 -05:00
Crowdin Bot 69dd0b8e5e New Crowdin translations by GitHub Action 2026-01-04 18:38:38 +00:00
Vitor PamplonaandGitHub 9f8a28c6d0 Merge pull request #1636 from davotoula/reduce-sonarcube-warnings
Reduce sonarcube warnings
2026-01-04 13:37:26 -05:00
davotoula 59fb175637 refactor VoiceMessagePreview.kt 2026-01-04 12:32:08 +01:00
davotoula a4925de4a1 cleaner code 2026-01-04 12:27:07 +01:00
davotoula 0d923b5caa optimise imports 2026-01-04 12:19:00 +01:00
Vitor Pamplona 7b13b9e72a Merge branch 'main' of https://github.com/vitorpamplona/amethyst
* 'main' of https://github.com/vitorpamplona/amethyst:
  New Crowdin translations by GitHub Action
2026-01-03 18:18:50 -05:00
Vitor Pamplona d7888c57fa Creating additional configuration options and amplifying tests to a combination of all those options 2026-01-03 18:18:36 -05:00
David KasparandGitHub 010a51949c Merge pull request #1635 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-01-03 21:09:02 +00:00
Crowdin Bot c878b07458 New Crowdin translations by GitHub Action 2026-01-03 19:54:51 +00:00
Vitor Pamplona 98ccb5e0ae Merge branch 'main' of https://github.com/vitorpamplona/amethyst
* 'main' of https://github.com/vitorpamplona/amethyst:
  New Crowdin translations by GitHub Action
2026-01-03 14:52:08 -05:00
nrobi144 64eec9844b bugfixes 2026-01-03 13:00:02 +02:00
nrobi144 f800e20b05 reuse some filtering logic 2026-01-03 06:15:37 +02:00
Vitor Pamplona 7a34b8480e Fixes count not working for LIMIT queries 2026-01-02 17:22:18 -05:00
Vitor Pamplona 51f9fdd58c Adds a simple query to the count filter 2026-01-02 16:58:59 -05:00
Vitor Pamplona 989c63a717 Expose query plans with the current hasher 2026-01-02 16:57:49 -05:00
Vitor Pamplona 48e791c634 removes logs 2026-01-02 16:17:58 -05:00
David KasparandGitHub e722f1eb61 Merge pull request #1632 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-01-02 21:01:45 +00:00
Crowdin Bot 98ba083172 New Crowdin translations by GitHub Action 2026-01-02 19:56:51 +00:00
Vitor Pamplona 307cff87e7 Adds more settings to pick different ways to build the SQL database 2026-01-02 14:54:30 -05:00
nrobi144 36bb89fd36 update desktop with refactored classes 2026-01-02 14:54:24 +02:00
nrobi144 2751855186 extract actions from amethyst 2026-01-02 13:29:55 +02:00
nrobi144 6581fe18a3 initial desktop phase1 fixes 2026-01-02 07:52:36 +02:00
nrobi144 146e4e5533 initial desktop phase1 2026-01-02 07:51:31 +02:00
Vitor Pamplona 7c63d654f2 Inverts the name of the isFilledFilter function 2026-01-01 21:29:20 -05:00
David KasparandGitHub 19d66f37d4 Merge pull request #1631 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-01-01 21:17:15 +00:00
Crowdin Bot 4c9df0bfef New Crowdin translations by GitHub Action 2026-01-01 20:09:47 +00:00
Vitor Pamplona 7b919ba44a added description 2026-01-01 15:07:27 -05:00
Vitor Pamplona 291db1f30b duplicates kinds and pubkey on the tags table to take complex queries under 100 microseconds 2026-01-01 14:57:48 -05:00
Vitor Pamplona 238d4e0004 No need for the fts module anymore 2026-01-01 14:52:11 -05:00
Vitor Pamplona f6d920366d Fixes some of the tests 2026-01-01 12:53:02 -05:00
Vitor Pamplona aac880c575 Range queries are not effective as the first argument, switching to kind, key, created_at index, which also almost invalidates the need for the Replaceable idx and is only kept for the unique key consistency 2026-01-01 12:51:43 -05:00
Vitor Pamplona 8affb2564b explains the fk index on tags 2026-01-01 12:49:12 -05:00
Vitor Pamplona b7c7eabf2a Explains limit index 2026-01-01 12:48:48 -05:00
Vitor Pamplona f48eba6a8e No need for this index since it will be covered by addressable_idx 2026-01-01 12:48:22 -05:00
Vitor Pamplona 307703cbb7 Explains ID index 2026-01-01 12:47:35 -05:00
Vitor Pamplona 45df452a38 Adds stable to the desktop account manager 2026-01-01 12:37:54 -05:00
Vitor Pamplona 9d67d5d6dc Implements a raw add on to the query builder 2026-01-01 12:37:44 -05:00
Vitor Pamplona 1978c8e17e Faster way of coding equals to null in the sql builder 2026-01-01 12:37:13 -05:00
Vitor Pamplona 507256e0dd Fixes Or builder 2026-01-01 12:36:26 -05:00
Vitor Pamplona 90f38c3a58 adds a isRegular filter for the Kind alias 2026-01-01 12:22:20 -05:00
Vitor Pamplona 6553012372 Uses aliases for the Filter class 2026-01-01 12:22:07 -05:00
nrobi144 5c4db83b40 run spotless 2026-01-01 12:56:52 +02:00
nrobi144 9b330164a7 use typealiases as references to reduce files changed 2026-01-01 12:41:13 +02:00
nrobi144 4bc7e86448 fixes imports 2026-01-01 07:55:31 +02:00
nrobi144 3750c7e26e fixes 2026-01-01 07:32:44 +02:00
nrobi144 eb47c19172 move models to commons 2026-01-01 07:26:39 +02:00
Vitor Pamplona 393366d9cc Adds size of the DB as a function to the lib 2025-12-31 18:41:20 -05:00
Vitor Pamplona 5d7c84d357 Setting higher cache size 2025-12-31 16:53:41 -05:00
Vitor Pamplona 6713c3bd50 Adds performance and flexibility by exposing Raw methods 2025-12-31 13:39:17 -05:00
Vitor Pamplona 4f7972df90 Removes logs 2025-12-31 13:37:02 -05:00
Vitor Pamplona 780ea487aa Adds a test case to simulate reports 2025-12-31 09:42:54 -05:00
Vitor Pamplona 4f1ad11d79 Moves SQLBuilder to its own class 2025-12-31 09:27:33 -05:00
nrobi144 f3b8589fea update claude.md 2025-12-31 13:00:25 +02:00
nrobi144 628f86e1a1 update claude.md 2025-12-31 07:46:50 +02:00
nrobi144 65859a9269 update claude.md 2025-12-31 07:13:27 +02:00
Vitor Pamplona 7b696f5f54 Adds created_at to tags to speed up tag-ordered queries 2025-12-30 16:23:11 -05:00
Vitor Pamplona 5eb9d230e2 Moves current test to a better name of Notifications
Adds a Followers test
2025-12-30 13:00:03 -05:00
Vitor Pamplona c3d741ebd1 Implements AND queries for tags: https://github.com/nostr-protocol/nips/pull/1365 2025-12-30 12:29:04 -05:00
Vitor Pamplona df97b7c444 Improves efficiency of the filter class for matching and checking errors 2025-12-30 12:09:05 -05:00
Vitor Pamplona 333285747b Migrates to using indexes instead tagNames to avoid non-ASCII chars reaching the DB 2025-12-30 12:06:22 -05:00
Vitor Pamplona 2a8e7cbe94 Fixes duplicated results 2025-12-30 12:03:09 -05:00
nrobi144 f180fd39e1 finish skills 2025-12-30 15:45:02 +02:00
nrobi144 f654af9d8a initial skills 2025-12-30 15:18:48 +02:00
Vitor Pamplona 258c4e0111 Moves BundledUpdate (which should cease to exist at some point) to Amethyst's module 2025-12-29 19:42:47 -05:00
Vitor Pamplona 54ce0a5a92 Moves mutable time to Amethyst 2025-12-29 19:37:45 -05:00
Vitor Pamplona c5133e98f8 - Fixes Preview Tag
- Fixes Email Pattern
2025-12-29 18:57:56 -05:00
Vitor Pamplona e9d07b3939 Aligns Android studio with gradle files 2025-12-29 17:37:43 -05:00
Vitor Pamplona d586c39008 Getting rid of some idea files in the repo 2025-12-29 16:21:19 -05:00
Vitor Pamplona 31d7e135cb Updates kotlin, compose, multiplatform, activity, serialization, media3, mockk, secp256, tor, androidxCamera, stdlib 2025-12-29 14:23:48 -05:00
Vitor PamplonaandGitHub 6054c5700d Merge pull request #1625 from nrobi144/feat/desktop-multiplatform
Create desktopApp module
2025-12-29 10:41:58 -05:00
Vitor PamplonaandGitHub d2c8991916 Merge pull request #1627 from greenart7c3/payto_part1
Payment targets - part 1
2025-12-29 09:31:45 -05:00
greenart7c3 ff0466872a Create PaymentTargetEvent 2025-12-29 06:26:25 -03:00
Vitor Pamplona 163632249e Merge branch 'main' of https://github.com/vitorpamplona/amethyst
* 'main' of https://github.com/vitorpamplona/amethyst:
  New Crowdin translations by GitHub Action
  Quartz Feature Parity Table on Multiplatform
  enhanced recording button
  translations
  bug fix: - Allow reply to reply by using BaseVoiceEvent
  TODO: Reply to a reply fails to upload Verify deletion of temp voice files
  Code review: - Removed DisposableEffect cleanup - Added cancelUpload() to selectRecording()
  TOOD: Move re-record higher up tp avoid google assistant on longpress Reply to a reply fails to upload Verify deletion of temp voice files
  Register Route in AppNavigation Modify ReplyViaVoiceReaction to Navigate
  Add VoiceReply Route Create VoiceReplyViewModel and VoiceReplyScreen Add String Resource(s)
2025-12-28 12:23:14 -05:00
David KasparandGitHub 7351cf1dd3 Merge pull request #1626 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2025-12-28 16:24:52 +00:00
Crowdin Bot d2164118bc New Crowdin translations by GitHub Action 2025-12-28 15:59:11 +00:00
Vitor PamplonaandGitHub 425d07fa8f Merge pull request #1622 from davotoula/voice-notes-replies
Christmas delivery: Voice notes replies
2025-12-28 10:57:52 -05:00
Vitor PamplonaandGitHub fe046477f5 Merge pull request #1624 from DestBro/quartz_feature_parity_table_on_multiplatform
Quartz Feature Parity Table on Multiplatform
2025-12-28 10:40:40 -05:00
Róbert NagyandGitHub edd0ac9b77 Merge branch 'main' into feat/desktop-multiplatform 2025-12-28 15:43:59 +02:00
nrobi144 0dcd480d43 leftover changes 2025-12-28 07:35:48 +02:00
nrobi144 e407e03e90 update readme 2025-12-28 07:21:36 +02:00
nrobi144andClaude Opus 4.5 b23ff488ed refactor: Polish shared commons components and fix formatting
- Improve RelayConnectionManager with better connection handling
- Refine UI components (LoginCard, ProfileInfoCard, RelayStatusCard)
- Update KeyInputField with improved layout
- Fix formatting in FeedHeader and AppScreen
- Add proper number formatting utilities
- Remove .claude config from version control

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-28 07:03:57 +02:00
Vitor Pamplona 57a22384b2 Refactoring 2025-12-27 17:57:32 -05:00
Vitor Pamplona 390da16129 Creating a IndexingStrategy interface 2025-12-27 17:55:30 -05:00
DumitruandGitHub 71d9f25557 Quartz Feature Parity Table on Multiplatform 2025-12-27 20:41:17 +01:00
Vitor Pamplona 55774b0292 sorting tags before inserting speeds up imports by ~20% 2025-12-27 13:13:46 -05:00
Vitor Pamplona 068ba3b717 Vacuum and Analyze are blocking, so moving to a coroutine. 2025-12-27 12:40:23 -05:00
Vitor Pamplona d435231f55 Setting cache doesn't seem to do anything :( 2025-12-27 12:38:22 -05:00
Vitor Pamplona f07070d65e Fixes test name 2025-12-27 12:12:12 -05:00
Vitor Pamplona f0b6fa622c adds vacuum and analyse utility functions 2025-12-27 12:11:58 -05:00
Vitor Pamplona f9582bb007 Increases cache size to up to 40MB 2025-12-27 12:11:31 -05:00
nrobi144andClaude Opus 4.5 201acc8140 feat: Extract shared UI components to commons for Android/Desktop reuse
Extract reusable components from desktopApp to commons module:

Utilities (commonMain):
- NumberFormatters: countToHumanReadable, countToHumanReadableBytes
- PubKeyFormatter: toShortDisplay, toDisplayHexKey for key formatting

Utilities (jvmAndroid):
- TimeAgoFormatter: Human-readable time formatting for Nostr timestamps
- ZapFormatter: BigDecimal amount formatting with G/M/k suffixes

UI Components (commonMain):
- AppScreen enum for shared navigation
- LoadingState, EmptyState, ErrorState with refresh/retry support
- FeedHeader with relay status indicator
- NoteCard for displaying Nostr notes
- ActionButtons (AddButton, RemoveButton)
- RobohashImage for avatar display
- PlaceholderScreens (Search, Messages, Notifications)
- RelayStatusColors and shared color definitions

UI Components (jvmAndroid):
- LoginCard with key input field
- NewKeyWarningCard for new key backup warnings
- KeyInputField and SelectableKeyText
- ProfileInfoCard for account display
- RelayStatusCard for relay management

Core (jvmAndroid):
- AccountManager with login/logout/key generation
- RelayConnectionManager base class
- RelayStatus and DefaultRelays

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-27 15:12:04 +02:00
nrobi144andClaude Opus 4.5 e5a064d0c3 feat: Add expect/actual for blurhash and base64Image in commons
- Create PlatformImage expect/actual abstracting Bitmap/BufferedImage
- Move pure blurhash algorithms (Base83, SRGB, CosineCache, BlurHashEncoder) to commonMain
- Rewrite BlurHashDecoder to return PlatformImage instead of Bitmap
- Add PlatformImage.toBlurhash() extension in commonMain
- Add platform-specific Bitmap.toBlurhash() and BufferedImage.toBlurhash()
- Add base64Image toPlatformImage() for both Android and JVM

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-27 12:48:32 +02:00
nrobi144andClaude Opus 4.5 131252f19d feat: Convert commons module to Kotlin Multiplatform
- Rewrite build.gradle.kts for KMP with Android + JVM targets
- Restructure source sets: commonMain, jvmAndroid, androidMain, jvmMain
- Replace android.util.LruCache with androidx.collection.LruCache (KMP-ready)
- Replace android.util.Patterns with local regex constants
- Move shared code to commonMain (icons, hashtags, robohash, compose, etc.)
- Move JVM-shared code to jvmAndroid (richtext, base64Image detection)
- Keep Android-specific code in androidMain (blurhash, bitmap handling)
- Remove @Preview annotations from shared code (Android-only feature)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-27 07:52:45 +02:00
Vitor Pamplona 273d5feb94 - Adds support to delete GiftWraps by p-tag for Deletion and Vanish modules
- Adds Query plans to the test cases
- Reduces the amount of indexes that weren't used.
2025-12-26 15:43:51 -05:00
Vitor Pamplona 4455509e32 Converts pubkey to pubkey hash on vanish events. 2025-12-26 14:33:41 -05:00
Vitor Pamplona 5dcac034ac Uses event_header_row_id as primary key (with index) on event_vanish 2025-12-26 14:32:22 -05:00
Vitor Pamplona ef5ed755be Uses primary key to reuse rowId instead of building a separate index. 2025-12-26 14:31:18 -05:00
Vitor Pamplona 720e4d2c87 Forces the use of the index on Addressables and Replaceables 2025-12-26 14:29:10 -05:00
nrobi144andClaude Opus 4.5 59eeb1ff0c fix: Use configured relays to initiate subscriptions
The FeedScreen was waiting for connectedRelays to be non-empty before
subscribing, but relays are only added to the NostrClient pool when
subscriptions request them. This created a deadlock where no relays
would ever connect.

Now uses relayStatuses.keys (configured relays) to initiate the
subscription, which triggers the NostrClient to add those relays
to its pool and connect to them.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-26 14:10:02 +02:00
nrobi144andClaude Opus 4.5 8c8a4ab9e8 feat: Add relay connections, login, and feed view to desktop app
- Add DesktopHttpClient and DesktopRelayConnectionManager for relay WebSocket connections
- Implement AccountManager with key generation and nsec/npub login support
- Create LoginScreen with key import and new key generation UI
- Build FeedScreen with live global feed subscription and note cards
- Add ProfileScreen showing account info and logout
- Update RelaySettingsScreen with connection status and relay management
- Configure JDK 21 toolchain for desktop builds
- Add shared UI analysis documentation

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-26 14:02:44 +02:00
nrobi144andClaude Opus 4.5 e441a67e2b fix: Correct compose compiler plugin alias
Use jetbrainsComposeCompiler alias to match version catalog.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-26 08:24:38 +02:00
nrobi144andClaude Opus 4.5 82f8bc62a0 feat: Add Compose Multiplatform Desktop support foundation
- Add desktopApp module with JVM entry point and sidebar navigation
- Add Claude specs for AI-assisted development:
  - Agent definitions: nostr-protocol, kotlin-multiplatform, compose-ui, kotlin-coroutines
  - Skills: quartz-kmp conversion, compose-desktop patterns
  - Commands: desktop-run, nip, extract
- Update Gradle configuration with Compose Multiplatform 1.7.1 plugin
- Add coroutines and secp256k1 JVM dependencies to version catalog

Next steps:
- Convert Quartz library to full KMP (expect/actual for crypto)
- Implement relay connections in desktop app
- Share UI components between Android and Desktop

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-26 08:21:02 +02:00
Vitor Pamplona eeb0c11c65 No need for this create method of GiftWraps to be suspending 2025-12-24 17:10:34 -05:00
Vitor Pamplona 66ea8ceda0 Fixes replaceable test 2025-12-24 17:10:04 -05:00
Vitor Pamplona 77e56f86a9 Using in memory databases for the test cases 2025-12-24 17:09:33 -05:00
Vitor Pamplona 09eeebba94 Keeps the hasher cache on the seed module 2025-12-24 15:28:59 -05:00
Vitor Pamplona f1298c3133 Removes event id from the deletion module insert 2025-12-24 15:27:38 -05:00
Vitor Pamplona 826308cbad test addressable filters together with event-id based for deletions and updates 2025-12-24 15:26:43 -05:00
Vitor Pamplona ff48af5090 Adds a pTagHash utility to the hasher 2025-12-24 15:25:27 -05:00
davotoula 6f8072d1bb enhanced recording button 2025-12-24 18:37:29 +00:00
davotoula 0a0365c266 translations 2025-12-23 16:28:39 +00:00
davotoula 6785f124e9 bug fix:
- Allow reply to reply by using BaseVoiceEvent
2025-12-23 15:04:08 +00:00
davotoula 2422e77dd0 TODO:
Reply to a reply fails to upload
Verify deletion of temp voice files

Code review:
- move re-record button higher
2025-12-23 15:04:08 +00:00
davotoula 64fe4dfac6 Code review:
- Removed DisposableEffect cleanup
- Added cancelUpload() to selectRecording()
2025-12-23 15:04:08 +00:00
davotoula 9b2ef76250 TOOD:
Move re-record higher up tp avoid google assistant on longpress
Reply to a reply fails to upload
Verify deletion of temp voice files

Code review:
- temp file cleanup on disposal
- Made uploads cancellable
- recording must exist; metadata-only no longer enables Send)
- Removed duplication
2025-12-23 15:04:08 +00:00
davotoula 8eee85a05e Register Route in AppNavigation
Modify ReplyViaVoiceReaction to Navigate
2025-12-23 15:04:08 +00:00
davotoula 13e07eb2e8 Add VoiceReply Route
Create VoiceReplyViewModel and VoiceReplyScreen
Add String Resource(s)
2025-12-23 15:04:08 +00:00
Vitor Pamplona aa2f969be6 Faster event serializer -> sha256 -> id comparison without creating intermediary buffers. 2025-12-23 09:39:30 -05:00
Vitor Pamplona 01ea413afd Faster SQLLite DB with probabilistic hashvalues 2025-12-22 15:18:59 -05:00
Vitor Pamplona 79d6745ee6 Adds faster parsers for Messages and Commands
Unwraps inline runCatching because it seems faster for some reason.
2025-12-22 14:52:02 -05:00
David KasparandGitHub 2de4d49fd8 Merge pull request #1620 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2025-12-22 20:30:10 +01:00
Crowdin Bot 94f1e45b9a New Crowdin translations by GitHub Action 2025-12-22 19:20:35 +00:00
Vitor Pamplona 10bba83ee3 Faster Event parsers 2025-12-22 14:18:59 -05:00
Vitor Pamplona 59413f3051 Moves type to the when clause 2025-12-22 12:25:07 -05:00
Vitor Pamplona 23a7e3b535 Fixes kind being a number 2025-12-22 12:23:34 -05:00
Vitor Pamplona 4c1fedddef Makes runCatching inline 2025-12-22 12:17:55 -05:00
Vitor Pamplona 14a6ba586a Fixes command parser 2025-12-22 11:19:25 -05:00
Vitor Pamplona 349625f748 simple refactoring 2025-12-22 11:18:28 -05:00
Vitor Pamplona e278982e6d Filters correct events 2025-12-22 11:17:43 -05:00
Vitor Pamplona c7038d695f Adds new tests for rumor and template serializer 2025-12-22 11:07:01 -05:00
Vitor Pamplona eefca1e3f4 Removes unnecessary tests 2025-12-22 08:11:55 -05:00
Vitor Pamplona 37158a83dc Adds a nextLong random method
Adds unbounded methods because they are faster.
2025-12-20 12:26:16 -05:00
Vitor Pamplona e4f3a56851 Removes the Statement cache since statements are not thread safe 2025-12-20 12:25:16 -05:00
Vitor Pamplona 8cbc5f1e93 Faster Array Deserializer 2025-12-19 18:57:12 -05:00
Vitor Pamplona 8d5f866ed0 Merge 2025-12-19 17:47:26 -05:00
Vitor PamplonaandGitHub 62151727f2 Merge pull request #1619 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2025-12-19 17:45:47 -05:00
Vitor Pamplona 7e65f1afa7 Fixes the Imodule dependency 2025-12-19 17:45:31 -05:00
Crowdin Bot f6890e4e27 New Crowdin translations by GitHub Action 2025-12-19 22:34:14 +00:00
Vitor Pamplona 1dbb5f0c83 Merge branch 'main' of https://github.com/vitorpamplona/amethyst
* 'main' of https://github.com/vitorpamplona/amethyst:
  New Crowdin translations by GitHub Action
2025-12-19 17:31:37 -05:00
Vitor Pamplona 664f0d0d59 Adds a Benchmark to simulate a real use on storage operators 2025-12-19 17:29:23 -05:00
Vitor Pamplona 8d15f0478f Faster MurMur hash for 64bit results. 2025-12-18 17:30:53 -05:00
Vitor Pamplona fde5d57b16 Faster parseResult array builder 2025-12-18 12:08:05 -05:00
Vitor Pamplona 12beeea8ee Adds a module interface for the SQL Lite db with drop functions too 2025-12-18 12:01:17 -05:00
Vitor Pamplona 7840bc3318 Uses in memory DB because that's where the performance hits 2025-12-18 11:57:38 -05:00
Vitor PamplonaandGitHub d3abc47365 Merge pull request #1618 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2025-12-18 09:40:43 -05:00
Crowdin Bot 26124ba10b New Crowdin translations by GitHub Action 2025-12-18 14:32:20 +00:00
Vitor Pamplona 34355121b1 Merge 2025-12-18 09:28:49 -05:00
Vitor Pamplona 5df019e485 adds kotlin ktx to benchmark 2025-12-18 09:21:40 -05:00
Vitor Pamplona 625a12f4e2 Better way to clear the test db 2025-12-16 18:03:50 -05:00
Vitor Pamplona 9069b11036 small refactoring 2025-12-16 18:03:26 -05:00
Vitor Pamplona 0fb64c5ff7 Fixes new generics on the benchmark classes 2025-12-16 16:57:02 -05:00
Vitor Pamplona 2b865ab56d Fixes assertions after using generics on event stores query 2025-12-16 15:24:59 -05:00
Vitor PamplonaandGitHub 759971d139 Merge pull request #1614 from davotoula/cleaner-code-pt2
log boolean returned by .delete()
2025-12-16 11:15:52 -05:00
davotoula 3373d3a958 log boolean returned by .delete() 2025-12-16 17:08:44 +01:00
Vitor PamplonaandGitHub 7c0e0c49d3 Merge pull request #1613 from davotoula/cleaner-code
cleaner code
2025-12-16 11:05:26 -05:00
davotoula aa4270883d correct way to clear collection 2025-12-16 17:00:00 +01:00
David KasparandGitHub 8a5732cc47 Merge pull request #1612 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2025-12-16 15:24:36 +01:00
Crowdin Bot 128155714d New Crowdin translations by GitHub Action 2025-12-16 13:37:17 +00:00
Vitor PamplonaandGitHub 7b453076db Merge pull request #1609 from davotoula/voice-notes
Create KIND 1222 (voice notes) in amethyst
2025-12-16 08:35:43 -05:00
Vitor PamplonaandGitHub 769e92f49d Merge pull request #1610 from davotoula/get-identity-claim-fixes
minor bug fix: getIdentityClaimIcon
2025-12-16 08:33:58 -05:00
Vitor Pamplona eef3f42d3f Creating interfaces for multiple EventStores 2025-12-16 08:31:17 -05:00
davotoula 90c8a9df4c use 0 size reference for all identity icons
fix bug (R.string.github vs R.drawable.github)
2025-12-16 12:10:29 +01:00
davotoula 7e1998174c Delete any existing temp file before replacing 2025-12-16 09:36:24 +01:00
Vitor Pamplona aa9ac205c1 deletes Db after tests 2025-12-15 22:04:20 -05:00
Vitor Pamplona 7cffdd88bb Adds additional hashtag filter test for the db 2025-12-15 17:16:52 -05:00
David KasparandGitHub 44f395815d Merge pull request #1608 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2025-12-15 20:33:24 +01:00
davotoula de927b62bd translate hardcoded strings 2025-12-15 20:11:00 +01:00
davotoula 3dd4249220 code review: abort posting message if voice upload fails 2025-12-15 19:39:24 +01:00
davotoula a370112bc5 code review: extract startRecording code 2025-12-15 19:33:08 +01:00
davotoula 09f66359ad code review: delete temp voice files in removeVoiceMessage(), cancel() and uploadVoiceMessageSync() 2025-12-15 19:17:24 +01:00
davotoula 0f06838c25 code review: replace layout with box (Calling a androidx.compose.ui.UiComposable composable function where a UI Composable composable was expected ) 2025-12-15 19:09:09 +01:00
davotoula 2a0eeed55f code review: remove unsafe casting to avoid app crash if a non-voice reply context leaks through
lint fixes
2025-12-15 19:03:58 +01:00
davotoula 555fc983f6 Fix rate conditions between subsequent recordings
reduce duplicate code
fix a few coding issues (state management, callback usage, scope management)
2025-12-15 18:35:24 +01:00
davotoula f3fea8cfb4 Flow: Record, preview, then post or cancel
Added media server selection for upload
Added recording indicator
Added upload progress
Text input disabled for voice messages
2025-12-15 18:05:53 +01:00
davotoula c0d7afe86c fix potential race condition with previous recorder in method stop 2025-12-15 17:24:45 +01:00
davotoula 8ebd76b77b add a record voice button to new post screen 2025-12-15 17:10:30 +01:00
Vitor Pamplona 8f9340038b Removes advertisement by default. 2025-12-15 08:32:31 -05:00
Crowdin Bot 6e7c02c5b6 New Crowdin translations by GitHub Action 2025-12-15 11:15:10 +00:00
David KasparandGitHub 54e6369d41 Merge pull request #1605 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2025-12-15 12:13:50 +01:00
Crowdin Bot 8028c7b2d4 New Crowdin translations by GitHub Action 2025-12-14 17:15:06 +00:00
Vitor PamplonaandGitHub 17f886d661 Merge pull request #1604 from davotoula/optimise-imports-and-lint
Optimise imports and lint
2025-12-14 12:13:53 -05:00
davotoula 9ef9fb4b09 amethyst lint 2025-12-14 10:20:25 +01:00
davotoula c2636e6e2f amethyst 2025-12-14 10:17:09 +01:00
davotoula 49517e2264 quartz 2025-12-14 10:12:15 +01:00
David KasparandGitHub 86aa23ef3f Merge pull request #1603 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2025-12-14 09:45:45 +01:00
Crowdin Bot 49ec21c243 New Crowdin translations by GitHub Action 2025-12-14 01:45:25 +00:00
Vitor PamplonaandGitHub 3f26794038 Merge pull request #1602 from DestBro/patch-1
Add iosX64 target
2025-12-13 20:44:10 -05:00
DumitruandGitHub 3c06e0ffb1 Fix typo iosX64MainTest to iosX64Test 2025-12-13 23:40:55 +01:00
DumitruandGitHub 6d7205625a Add iosX64 target 2025-12-13 23:18:47 +01:00
Vitor Pamplona 83d14bd0e3 Minor adjustments and new benchmarks 2025-12-12 19:51:41 -05:00
Vitor Pamplona 1a63b77a2f Reduce some variables 2025-12-12 19:41:12 -05:00
Vitor Pamplona 286d9c8347 Improve formatting 2025-12-12 18:24:19 -05:00
Vitor Pamplona c89ae5782e small refactoring 2025-12-12 17:45:18 -05:00
Vitor Pamplona 84eb0a9abb Adds a large array test case 2025-12-12 17:40:38 -05:00
Vitor Pamplona e1b23e8ea5 Merge branch 'main' of https://github.com/vitorpamplona/amethyst
* 'main' of https://github.com/vitorpamplona/amethyst:
  New Crowdin translations by GitHub Action
2025-12-12 17:30:57 -05:00
Vitor Pamplona 7a61217eb6 Fixes rounding loop of MurMur128 2025-12-12 17:29:49 -05:00
David KasparandGitHub 50c7dea2bd Merge pull request #1601 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2025-12-12 23:29:33 +01:00
Crowdin Bot ed316fb06b New Crowdin translations by GitHub Action 2025-12-12 21:56:51 +00:00
Vitor Pamplona 95723479d6 Adds MurMur3128bits for x64 2025-12-12 16:54:34 -05:00
Vitor Pamplona 5c01aaf996 Upgrades AGP 2025-12-12 13:25:29 -05:00
Vitor Pamplona 25e9313d17 Reverts interning of all tag sizes due to significant gains in memory usage from large duplicated tags, like r tags with urls 2025-12-11 18:26:27 -05:00
Vitor Pamplona 9bd07468c2 deletes db before tests 2025-12-11 17:13:51 -05:00
Vitor Pamplona 39eed5d85a reformatting 2025-12-11 17:09:03 -05:00
Vitor Pamplona ee972cff35 reverts db deletions to avoid conflicts with the open file 2025-12-09 18:02:26 -05:00
Vitor Pamplona b06f80e862 Merge branch 'main' of https://github.com/vitorpamplona/amethyst
* 'main' of https://github.com/vitorpamplona/amethyst:
  Offers support for external transactions Offers support for indexing strategies for tags
2025-12-09 17:28:43 -05:00
Vitor Pamplona d0efe26ce7 Offers support for external transactions
Offers support for indexing strategies for tags
2025-12-09 17:28:33 -05:00
Vitor Pamplona 26a764a634 Offers support for external transactions
Offers support for indexing strategies for tags
2025-12-09 17:15:22 -05:00
Vitor Pamplona bcebac529b Merge branch 'main' of https://github.com/vitorpamplona/amethyst
* 'main' of https://github.com/vitorpamplona/amethyst:
  New Crowdin translations by GitHub Action
2025-12-09 11:02:50 -05:00
Vitor Pamplona fc03d8032e Activates recomposition logging in the application class 2025-12-09 11:02:36 -05:00
Vitor Pamplona 15ee49282d Adds the stability plugin to allow traces in debug 2025-12-09 11:01:03 -05:00
Vitor PamplonaandGitHub ac41580ef7 Merge pull request #1599 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2025-12-09 10:38:41 -05:00
Crowdin Bot 516c7c93ae New Crowdin translations by GitHub Action 2025-12-09 14:52:11 +00:00
Vitor Pamplona d268577443 Adds the missing when clauses due to the change in reports. 2025-12-09 09:49:18 -05:00
Vitor Pamplona d3fb507faa Reducing the need to intern large tags 2025-12-09 09:37:49 -05:00
Vitor Pamplona f4a0cd5af3 The number of params varies but a good limit seems to be 1000. This breaks the tag insert into many 1000 param inserts 2025-12-09 09:20:17 -05:00
Vitor Pamplona e44322adaa Speeds up db deletions 2025-12-09 09:05:37 -05:00
Vitor Pamplona 98e818cc60 Merge branch 'main' of https://github.com/vitorpamplona/amethyst
* 'main' of https://github.com/vitorpamplona/amethyst:
  New Crowdin translations by GitHub Action
2025-12-08 20:00:12 -05:00
Vitor Pamplona 03f13eeb4b Improved support for random report types coming from AI 2025-12-08 19:59:59 -05:00
David KasparandGitHub 23747a9334 Merge pull request #1597 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2025-12-04 22:47:58 +01:00
Crowdin Bot 5dea9e3cef New Crowdin translations by GitHub Action 2025-12-04 19:44:01 +00:00
Vitor Pamplona 0abc631773 - Removes compose bom from Quartz to avoid unnecessary dependencies.
- Removes old datetime dependencies from Quartz
- Moves compose.runtime dependencies to compose.runtime.annotation
- Adds dependency on coroutines directly (instead of through compose runtime)
- Removes old secp256 target dependencies
- Adds Default scope for NostrClient and Relay Authenticator
- Updates readme
2025-12-04 14:41:33 -05:00
Vitor Pamplona 03633c70ca Adds link to quartz versions in the readme 2025-12-03 15:24:32 -05:00
Vitor Pamplona 67eb47adb1 Updates Tor, Compose, Activity 2025-12-03 15:23:47 -05:00
Vitor Pamplona 0cea52ca80 Shows WoT Followers in each user profile 2025-12-02 19:23:13 -05:00
Vitor Pamplona e7c1925c76 Shows WoT Followers 2025-12-02 19:15:51 -05:00
Vitor Pamplona bb77163955 Adds WoT scores for public chat and live stream screens 2025-12-02 18:56:07 -05:00
Vitor Pamplona cc679e3d6b Using banner without the user header for Follow packs because the user is already listed in the bottom of the card. 2025-12-02 18:33:10 -05:00
Vitor Pamplona 1125c4e8f5 Creates a new size for the score tag due to the size of DM Group headers 2025-12-02 18:20:06 -05:00
Vitor Pamplona aa1aaeea38 Adds scores to the notification cards 2025-12-02 18:07:29 -05:00
Vitor Pamplona 742fa14286 Fixes size of the score in the dm screen 2025-12-02 17:35:41 -05:00
Vitor Pamplona 7f741260d1 Adds a cache system for WoT scores 2025-12-02 16:59:16 -05:00
Vitor Pamplona 9e1f863a67 Refactoring 2025-12-02 16:53:47 -05:00
Vitor Pamplona 72dc9368fb Fixes missing emit for null events 2025-12-02 16:05:23 -05:00
Vitor Pamplona 71dc25099e Merge branch 'main' of https://github.com/vitorpamplona/amethyst
* 'main' of https://github.com/vitorpamplona/amethyst:
  fix lint warnings
  add custom serializer that accepts both single integers and arrays add tests
  New Crowdin translations by GitHub Action
  updated cz, de, pt, sv
2025-12-02 15:16:39 -05:00
Vitor Pamplona ab44d5daf5 Removing the generalist approach of ptag-mentions 2025-12-02 15:16:14 -05:00
Vitor Pamplona e6861ea988 Removes expired addressable events. 2025-12-02 15:13:29 -05:00
Vitor Pamplona 62bf7db348 Adds the about user proerty of Contact Card 2025-12-02 15:07:07 -05:00
Vitor PamplonaandGitHub 0a76405d90 Merge pull request #1596 from davotoula/bugfix-Unexpected-JSON-token
Bugfix: unexpected json token
2025-12-02 08:55:00 -05:00
davotoula 70b36d64b5 fix lint warnings 2025-12-02 12:57:56 +01:00
davotoula 24bd1d0c97 add custom serializer that accepts both single integers and arrays
add tests
2025-12-02 12:53:51 +01:00
David KasparandGitHub e41c3284ff Merge pull request #1595 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2025-12-02 12:28:52 +01:00
Crowdin Bot 03b646cb33 New Crowdin translations by GitHub Action 2025-12-02 09:28:01 +00:00
David KasparandGitHub 80593e28db Merge pull request #1594 from davotoula/update-translations
updated cz, de, pt, sv
2025-12-02 10:26:32 +01:00
davotoula 9435ba9b14 updated cz, de, pt, sv 2025-12-02 10:15:06 +01:00
Vitor Pamplona 39223f0270 Merge branch 'main' of https://github.com/vitorpamplona/amethyst
* 'main' of https://github.com/vitorpamplona/amethyst:
  New Crowdin translations by GitHub Action
2025-12-01 18:19:18 -05:00
Vitor Pamplona 4332f311b3 - Removing user reports flow
- Integrates LocalCache.consume report into the consumeRegularEvent practice
2025-12-01 18:16:37 -05:00
Vitor PamplonaandGitHub 3d984d8917 Merge pull request #1593 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2025-12-01 17:52:48 -05:00
Crowdin Bot 30b6fff4d4 New Crowdin translations by GitHub Action 2025-12-01 22:51:23 +00:00
Vitor Pamplona 5062c10849 Merge branch 'main' of https://github.com/vitorpamplona/amethyst
* 'main' of https://github.com/vitorpamplona/amethyst:
  Remove delay, and implement custom moving function that updates the list in one event, rather than two with the previous approach.
  Increase delay time in moveBookmark. Need to find a better alternative to delay.
  String resources.
  Re-introduce display stats for bookmark list, focusing on total bookmarks size per category.
  Support moving articles/posts from private to public and vice versa.
2025-12-01 17:47:01 -05:00
Vitor Pamplona 8b5676bd39 Merge branch 'main' of https://github.com/vitorpamplona/amethyst
* 'main' of https://github.com/vitorpamplona/amethyst: (23 commits)
  Fix default permissions
  Add Bookmark group metadata edit to routes/nav. Make refactors to make use of it.
  Bookmark group metadata edit screen, with VM and string resources. Add methods to LabeledBookmarkListEvent and LabeledBookmarkListsState. Support image urls in LabeledBookmarkList.
  String resources. Remove unneeded Todo.
  Add bookmark list addition/removal to the note dropdown menu.
  Article Bookmark management screen. Add routes and nav entries for both screens.
  Post Bookmark management screen.
  Other Hashtag/Link related refactors.
  Remove HashtagListView and LinksListView due to spec changes, with their entries in BookmarkType.
  Use stringResources. Implement the necessary callbacks in BookmarkGroupScreen. Disable component stats display in BookmarkGroupItem until Link/Hashtag support is implemented.
  Bring ArticleListView up to speed. Fix callback naming mistake in BookmarkGroupItemOptions and refactor usages accordingly.
  First category-based screen: PostListView. Modify NoteCompose to use a different note options menu when in the PostListView.
  Fix some methods in LabeledBookmarkListsState. Add useful methods to LabeledBookmarkList and BookmarkGroupViewModel.
  Introduce BookmarkGroupScreen, with it's associated ViewModel, and the different category-based views.
  Move BookmarkType to an enum instead, for serialization purposes.
  Slight function name refactor in LabeledBookmarkListsState, and inclusion of a method to reference a particular bookmark group. Add route for viewing a bookmark group, and wire UI accordingly.
  Change the UI of BookmarkGroupItem to add buttons for viewing parts of the bookmark group.
  Add support for LabeledBookmarkListEvent in the LocalCache, and EventFactory(to prevent crashes when creating one).
  Add bookmark groups option to side menu. Make them work by hooking them up in Account.
  Build out UI for the list of labeled bookmarks, or bookmark groups, borrowing some components from elsewhere.
  ...
2025-12-01 17:46:14 -05:00
Vitor PamplonaandGitHub 4c43bc99da Merge pull request #1591 from KotlinGeekDev/bookmark-groups-improvements
Bookmark groups improvements.
2025-12-01 17:46:08 -05:00
Vitor Pamplona 13aa96faef Refactoring report caching. 2025-12-01 17:45:21 -05:00
KotlinGeekDev 80947207ab Remove delay, and implement custom moving function that updates the list in one event, rather than two with the previous approach. 2025-11-28 16:14:47 +01:00
KotlinGeekDev bf6109d18b Increase delay time in moveBookmark. Need to find a better alternative to delay. 2025-11-28 15:21:58 +01:00
KotlinGeekDevandGitHub 19a499f6cc Merge branch 'main' into bookmark-groups-improvements 2025-11-28 13:37:54 +00:00
Vitor PamplonaandGitHub 2db23b5f7f Merge pull request #1592 from greenart7c3/main
Fix duplicated default permissions
2025-11-28 07:16:22 -05:00
greenart7c3andGitHub 7c4ad3abb7 Fix default permissions 2025-11-28 05:43:52 -03:00
KotlinGeekDev b4431aab54 String resources. 2025-11-28 04:52:45 +01:00
KotlinGeekDev cd62b0a722 Re-introduce display stats for bookmark list, focusing on total bookmarks size per category. 2025-11-28 04:43:59 +01:00
KotlinGeekDev 194f57acc3 Support moving articles/posts from private to public and vice versa. 2025-11-28 04:31:08 +01:00
Vitor PamplonaandGitHub 74193a006d Merge pull request #1586 from KotlinGeekDev/labeled-bookmarks
Bookmark Lists.
2025-11-27 08:17:45 -05:00
KotlinGeekDev cd0a0405a9 Add Bookmark group metadata edit to routes/nav. Make refactors to make use of it. 2025-11-27 07:19:04 +01:00
KotlinGeekDev 0f21c72936 Bookmark group metadata edit screen, with VM and string resources. Add methods to LabeledBookmarkListEvent and LabeledBookmarkListsState. Support image urls in LabeledBookmarkList. 2025-11-27 07:08:08 +01:00
KotlinGeekDev 576d20723d Merge remote-tracking branch 'origin/labeled-bookmarks' into labeled-bookmarks 2025-11-26 22:45:50 +01:00
KotlinGeekDevandGitHub 2e5b55a213 Merge branch 'vitorpamplona:main' into labeled-bookmarks 2025-11-26 21:45:35 +00:00
KotlinGeekDev 07f82d3a22 String resources. Remove unneeded Todo. 2025-11-26 22:44:16 +01:00
KotlinGeekDev a0bff70dc2 Add bookmark list addition/removal to the note dropdown menu. 2025-11-26 22:25:59 +01:00
KotlinGeekDev 5742968f4c Article Bookmark management screen. Add routes and nav entries for both screens. 2025-11-26 22:22:50 +01:00
KotlinGeekDev e8f55c6456 Post Bookmark management screen. 2025-11-26 22:21:10 +01:00
KotlinGeekDev b00eb413fa Other Hashtag/Link related refactors. 2025-11-26 19:49:16 +01:00
KotlinGeekDev 6fc5084931 Remove HashtagListView and LinksListView due to spec changes, with their entries in BookmarkType. 2025-11-26 19:47:42 +01:00
Vitor Pamplona 1155e7d5b1 Using real keys to preview 2025-11-26 11:00:04 -05:00
Vitor Pamplona b776957d0e Reverts to 500 posts to load in the Profile screen (too many replies in some users) 2025-11-26 10:59:47 -05:00
Vitor Pamplona f29a6337a0 Removing unused methods in User 2025-11-25 16:56:04 -05:00
Vitor Pamplona 0ad1d75fe7 Improving font size of the WoT Score 2025-11-25 16:47:53 -05:00
Vitor Pamplona f76638f077 Adds a way to view WoT scores 2025-11-25 15:47:08 -05:00
Vitor Pamplona fd628fffd2 Removes Contact cards from filter since it wasn't returning anything (user is in the d tag, not p or author) 2025-11-25 15:46:51 -05:00
Vitor Pamplona ff8b441bf6 Increases max pool of SHA 256 processors 2025-11-25 15:02:50 -05:00
Vitor Pamplona 7981ecc82c Adds pre-caching for trust providers 2025-11-25 11:03:00 -05:00
KotlinGeekDevandGitHub 925506269d Merge branch 'vitorpamplona:main' into labeled-bookmarks 2025-11-25 11:35:01 +00:00
Vitor Pamplona 0c864b7c76 fixes wong kind 2025-11-24 11:18:27 -05:00
Vitor Pamplona 1bfe1e4844 Adds TrustProvider loader to the account filter 2025-11-24 11:03:32 -05:00
Vitor Pamplona 1e00c08f7d Fixes ConcurrentExceptions on Event outbox tentative management 2025-11-24 10:50:58 -05:00
Vitor Pamplona 4f019c6e49 Removes ConcurrentModificationException on outbox events 2025-11-24 10:01:05 -05:00
Vitor PamplonaandGitHub 5e7f938442 Merge pull request #1589 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2025-11-22 12:18:52 -05:00
Crowdin Bot 5fb309d00c New Crowdin translations by GitHub Action 2025-11-22 17:16:30 +00:00
Vitor PamplonaandGitHub 83639a5991 Merge pull request #1587 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2025-11-22 12:15:14 -05:00
KotlinGeekDevandGitHub d1fe0cb941 Merge branch 'vitorpamplona:main' into labeled-bookmarks 2025-11-22 02:45:27 +00:00
Crowdin Bot f3938fb336 New Crowdin translations by GitHub Action 2025-11-21 22:18:06 +00:00
Vitor Pamplona d12a8ba1c1 Revert runTest because the wait to get results back fails in the function 2025-11-21 17:16:08 -05:00
Vitor Pamplona 382de9ba03 Fixing new remember rules 2025-11-21 16:51:21 -05:00
Vitor Pamplona 25094be07c Puts flow creation inside a remember 2025-11-21 16:43:32 -05:00
Vitor Pamplona ad879fcf63 Fix test 2025-11-21 16:42:07 -05:00
Vitor Pamplona 50f91e8b91 Updates dependencies 2025-11-21 16:37:56 -05:00
Vitor Pamplona 1f3bd7614e Moves account loading to the IO thread 2025-11-21 16:37:48 -05:00
Vitor Pamplona c5fae65f2d Moves temporaryAccount handler to lazy loading 2025-11-21 15:31:02 -05:00
Vitor Pamplona 2bff998340 Moves from List to Set of Trust Provider 2025-11-21 15:18:31 -05:00
KotlinGeekDev f103fe9c79 Merge conflict-resolved changes from upstream 2025-11-21 19:09:50 +01:00
KotlinGeekDev dae8544938 Fix some conflicts 2025-11-21 18:44:24 +01:00
KotlinGeekDev b4aca59372 Use stringResources. Implement the necessary callbacks in BookmarkGroupScreen. Disable component stats display in BookmarkGroupItem until Link/Hashtag support is implemented. 2025-11-21 18:19:41 +01:00
Vitor Pamplona ffe88a5364 Merge branch 'main' of https://github.com/vitorpamplona/amethyst
* 'main' of https://github.com/vitorpamplona/amethyst:
  add a convertExeptions function
  added response helper
  check for error result
  create a RemoteSignerManager
  add a fromBunkerUri helper
  add a signer result interface for remote signer
  implement the functions of nip46
  return BunkerResponse from connect
  - Add connect, getPublivKey and ping - send NostrConnectEvent to relays
  - Add connect, getPublivKey and ping - send NostrConnectEvent to relays
  Create NostrSignerRemote
2025-11-21 11:30:51 -05:00
Vitor Pamplona 0d7f2ce838 Adds support for Trust Provider lists 2025-11-21 11:29:46 -05:00
KotlinGeekDev 26c66a97b5 Bring ArticleListView up to speed. Fix callback naming mistake in BookmarkGroupItemOptions and refactor usages accordingly. 2025-11-21 14:51:53 +01:00
KotlinGeekDev 808030d176 First category-based screen: PostListView. Modify NoteCompose to use a different note options menu when in the PostListView. 2025-11-21 14:20:57 +01:00
KotlinGeekDev 267d680ffc Fix some methods in LabeledBookmarkListsState. Add useful methods to LabeledBookmarkList and BookmarkGroupViewModel. 2025-11-21 14:11:55 +01:00
Vitor PamplonaandGitHub 058e8ab833 Merge pull request #1555 from greenart7c3/nip46
Support for nip 46
2025-11-20 14:53:17 -05:00
Vitor Pamplona 23d1e94ddf Moves the prepare video call to IO 2025-11-18 19:12:21 -05:00
Vitor Pamplona 89d7e52dc4 Avoids blinking image space in the screen 2025-11-18 18:07:08 -05:00
Vitor Pamplona 2f93aa8777 Moves the QR Code screen from Dialog to full Route 2025-11-18 17:56:04 -05:00
Vitor PamplonaandGitHub 6f0c65d84c Merge pull request #1583 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2025-11-18 17:26:42 -05:00
Crowdin Bot 07ce3d5e5f New Crowdin translations by GitHub Action 2025-11-18 22:20:37 +00:00
Vitor PamplonaandGitHub ba541129ba Merge pull request #1582 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2025-11-18 17:19:45 -05:00
Vitor PamplonaandGitHub b9d4805d56 Merge pull request #1581 from StellarStoic/main
ui: center QR dialog content and reduce excessive top spacing
2025-11-18 17:19:36 -05:00
Crowdin Bot 302aee325f New Crowdin translations by GitHub Action 2025-11-18 22:19:14 +00:00
Vitor Pamplona 3b648a1d9f Merge branch 'main' of https://github.com/vitorpamplona/amethyst
* 'main' of https://github.com/vitorpamplona/amethyst:
  New Crowdin translations by GitHub Action
  Open njump.to, iris.to, zap.stream and shosho.live in Amethyst
2025-11-18 17:17:09 -05:00
Vitor Pamplona a36d35912a Manages the pool state without having to loop through relays, saving some milliseconds of processing. 2025-11-18 17:12:39 -05:00
Vitor Pamplona 326f059d1e Removes logging for connected and available relay dump 2025-11-18 16:00:28 -05:00
StellarStoic 3762fb7b2a ui: center QR dialog content and reduce excessive top spacing 2025-11-18 13:23:36 +01:00
Vitor PamplonaandGitHub 653e70070c Merge pull request #1580 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2025-11-17 16:55:12 -05:00
Crowdin Bot 941f890e46 New Crowdin translations by GitHub Action 2025-11-17 21:49:41 +00:00
Vitor PamplonaandGitHub a0bd0bb619 Merge pull request #1579 from kirpy0x/UpdateNostrLinks
Open njump.to, iris.to, zap.stream and shosho.live in Amethyst
2025-11-17 16:48:31 -05:00
kirpy be2d976124 Open njump.to, iris.to, zap.stream and shosho.live in Amethyst 2025-11-17 12:55:10 -07:00
KotlinGeekDev c48436b0a6 Introduce BookmarkGroupScreen, with it's associated ViewModel, and the different category-based views. 2025-11-17 18:51:39 +01:00
KotlinGeekDev c50c3f26c8 Move BookmarkType to an enum instead, for serialization purposes. 2025-11-17 18:17:16 +01:00
Vitor Pamplona ae22f4685d Merge branch 'main' of https://github.com/vitorpamplona/amethyst
* 'main' of https://github.com/vitorpamplona/amethyst:
  New Crowdin translations by GitHub Action
  New Crowdin translations by GitHub Action
  New Crowdin translations by GitHub Action
  New Crowdin translations by GitHub Action
  user correct overlad that accepts argument
  New Crowdin translations by GitHub Action
2025-11-17 08:15:49 -05:00
David KasparandGitHub 84694d8a36 Merge pull request #1578 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2025-11-17 13:48:05 +01:00
Crowdin Bot f73046e652 New Crowdin translations by GitHub Action 2025-11-17 12:46:11 +00:00
David KasparandGitHub 3727bc408b Merge pull request #1577 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2025-11-17 13:44:52 +01:00
Crowdin Bot 69ea1c8e51 New Crowdin translations by GitHub Action 2025-11-17 11:10:43 +00:00
David KasparandGitHub 43fba40076 Merge pull request #1576 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2025-11-17 12:09:20 +01:00
greenart7c3 a6f3e09a6c add a convertExeptions function 2025-11-17 07:07:49 -03:00
greenart7c3 7dce71d8fa added response helper 2025-11-17 07:07:49 -03:00
greenart7c3 c2f2dc46d9 check for error result 2025-11-17 07:07:49 -03:00
greenart7c3 e02dfce152 create a RemoteSignerManager 2025-11-17 07:07:48 -03:00
greenart7c3 55b4ddc453 add a fromBunkerUri helper 2025-11-17 07:07:48 -03:00
greenart7c3 8d529e1ea9 add a signer result interface for remote signer 2025-11-17 07:07:48 -03:00
greenart7c3 b2f2a409c3 implement the functions of nip46 2025-11-17 07:07:48 -03:00
greenart7c3 aee6ffdbf5 return BunkerResponse from connect 2025-11-17 07:07:48 -03:00
greenart7c3 da85e59424 - Add connect, getPublivKey and ping
- send NostrConnectEvent to relays
2025-11-17 07:07:47 -03:00
greenart7c3 e6f109de74 - Add connect, getPublivKey and ping
- send NostrConnectEvent to relays
2025-11-17 07:07:47 -03:00
greenart7c3 df1bf82e76 Create NostrSignerRemote 2025-11-17 07:07:46 -03:00
Crowdin Bot f5d8b9df8b New Crowdin translations by GitHub Action 2025-11-17 07:53:24 +00:00
David KasparandGitHub 2c3da65063 Merge pull request #1574 from davotoula/bugfix-placeholder-not-replaced-with-value
Bugfix: use correct overload that accepts argument
2025-11-17 08:52:03 +01:00
David KasparandGitHub 4e6b3a142b Merge pull request #1575 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2025-11-16 19:26:09 +01:00
Crowdin Bot 976ff9970a New Crowdin translations by GitHub Action 2025-11-16 16:50:25 +00:00
David KasparandGitHub c5073303ba Merge pull request #1573 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2025-11-16 17:49:05 +01:00
davotoula 3b81490ac1 user correct overlad that accepts argument 2025-11-16 17:43:59 +01:00
KotlinGeekDev eb8b19ceb9 Slight function name refactor in LabeledBookmarkListsState, and inclusion of a method to reference a particular bookmark group. Add route for viewing a bookmark group, and wire UI accordingly. 2025-11-16 15:40:46 +01:00
Vitor Pamplona 4f5acc5c29 Updates to the latest zapstore config 2025-11-15 17:57:03 -05:00
Crowdin Bot 5f6e65f768 New Crowdin translations by GitHub Action 2025-11-15 22:35:09 +00:00
Vitor Pamplona ae6f37a52f v1.04.2 to appease the PlayStore 2025-11-15 17:28:55 -05:00
Vitor PamplonaandGitHub f6e0124734 Merge pull request #1571 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2025-11-15 16:55:10 -05:00
Crowdin Bot b6a7d9146f New Crowdin translations by GitHub Action 2025-11-15 21:52:58 +00:00
Vitor PamplonaandGitHub 8492a597c5 Merge pull request #1570 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2025-11-15 16:52:28 -05:00
Vitor Pamplona 5264bd2cbc v1.04.1 2025-11-15 16:51:45 -05:00
Crowdin Bot f630c34045 New Crowdin translations by GitHub Action 2025-11-15 21:51:29 +00:00
Vitor Pamplona 042ff4c5ed Merge branch 'main' of https://github.com/vitorpamplona/amethyst
* 'main' of https://github.com/vitorpamplona/amethyst:
  New Crowdin translations by GitHub Action
2025-11-15 16:49:33 -05:00
Vitor Pamplona f7e72c57d3 Turn the Pay zaps via intent dialog into a full screen with route 2025-11-15 16:41:47 -05:00
Vitor Pamplona fef78cd557 Fixes test case for payload type 2025-11-15 16:41:02 -05:00
Vitor Pamplona 673d8d2c30 Makes sure the Zap the Devs dialog doesn't appear behind the status bar 2025-11-15 16:11:58 -05:00
Vitor PamplonaandGitHub 414aef1818 Merge pull request #1569 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2025-11-15 15:41:18 -05:00
Crowdin Bot bbc4f4281a New Crowdin translations by GitHub Action 2025-11-15 20:30:16 +00:00
Vitor Pamplona 14e3098924 Fixes deserialization issues with old jsons that didn't have these properties 2025-11-15 15:27:57 -05:00
Vitor Pamplona 7742e27780 Moves all hashtags to lowercase and unfollows with ignore case just in case some other client added in uppercase 2025-11-15 15:20:56 -05:00
Vitor Pamplona a1879a93df sending a new version for quartz 2025-11-15 12:26:38 -05:00
KotlinGeekDev 37b6f84d9f Merge remote-tracking branch 'origin/labeled-bookmarks' into labeled-bookmarks 2025-11-15 03:36:28 +01:00
KotlinGeekDev f1c3a881f1 Change the UI of BookmarkGroupItem to add buttons for viewing parts of the bookmark group. 2025-11-14 20:12:02 +01:00
KotlinGeekDev 822d464a6e Add support for LabeledBookmarkListEvent in the LocalCache, and EventFactory(to prevent crashes when creating one). 2025-11-14 19:45:32 +01:00
KotlinGeekDev 34e5e7de5b Add bookmark groups option to side menu. Make them work by hooking them up in Account. 2025-11-14 19:38:48 +01:00
KotlinGeekDevandGitHub 5068247807 Merge branch 'vitorpamplona:main' into labeled-bookmarks 2025-11-13 09:15:43 +00:00
KotlinGeekDev 0c06bd4271 Build out UI for the list of labeled bookmarks, or bookmark groups, borrowing some components from elsewhere. 2025-11-10 19:34:55 +01:00
KotlinGeekDev d029c9eca0 Merge remote-tracking branch 'origin/labeled-bookmarks' into labeled-bookmarks 2025-11-09 22:13:47 +01:00
KotlinGeekDevandGitHub 1d49ee3d57 Merge branch 'vitorpamplona:main' into labeled-bookmarks 2025-11-09 21:13:32 +00:00
KotlinGeekDev 751cbdcaa9 Introduce LabeledBookmarkList and a full implementation for LabeledBookmarkListsState. 2025-11-09 22:12:54 +01:00
KotlinGeekDev d5a2910029 Add support for descriptions in LabeledBookmarkListEvent. Make function names clearer, and add modification functions. 2025-11-09 22:11:12 +01:00
KotlinGeekDevandGitHub 3613bed5fa Merge branch 'vitorpamplona:main' into labeled-bookmarks 2025-11-06 16:22:38 +00:00
KotlinGeekDevandGitHub 19fa7e25d5 Merge branch 'vitorpamplona:main' into labeled-bookmarks 2025-11-06 01:35:11 +00:00
KotlinGeekDevandGitHub 7accbc198b Merge branch 'vitorpamplona:main' into labeled-bookmarks 2025-11-05 20:25:46 +00:00
KotlinGeekDev e2df96ea4b Introduce LabeledBookmarkListEvent. 2025-11-05 17:51:12 +01:00
580 changed files with 47304 additions and 7865 deletions
+258
View File
@@ -0,0 +1,258 @@
# Amethyst Desktop Fork
## Project Overview
Fork of [Amethyst](https://github.com/vitorpamplona/amethyst) adding Compose Multiplatform Desktop support. Quartz library converted to full KMP for code sharing between Android and Desktop JVM.
## Architecture
```
amethyst/
├── quartz/ # Nostr KMP library (protocol only, no UI)
│ └── src/
│ ├── commonMain/ # Shared Nostr protocol, data models
│ ├── androidMain/ # Android-specific (crypto, storage)
│ └── jvmMain/ # Desktop JVM-specific
├── commons/ # Shared UI components (convert to KMP)
│ └── src/
│ ├── commonMain/ # Shared composables, icons, state
│ ├── androidMain/ # Android-specific UI utilities
│ └── jvmMain/ # Desktop-specific UI utilities
├── desktopApp/ # Desktop JVM application (layouts, navigation)
├── amethyst/ # Android app (layouts, navigation)
└── ammolite/ # Support module
```
**Sharing Philosophy:**
- `quartz/` = Business logic, protocol, data (no UI)
- `commons/` = Shared UI components, icons, composables, **ViewModels**
- `amethyst/` & `desktopApp/` = Platform-native layouts and navigation
## Tech Stack
| Layer | Technology |
|-------|------------|
| **Core** | Quartz (Nostr KMP) |
| **UI** | Compose Multiplatform 1.7.x |
| **Async** | kotlinx.coroutines + Flow |
| **Network** | OkHttp (JVM) |
| **Serialization** | Jackson |
| **DI** | Manual / Koin |
| **Build** | Gradle 8.x, Kotlin 2.1.0 |
## Skills
Specialized skills provide domain expertise with bundled resources and patterns:
| Skill | Expertise | When to Use |
|-------|-----------|-------------|
| `nostr-expert` | Nostr protocol (Quartz library) | Event types, NIPs, tags, signing, Bech32 |
| `kotlin-expert` | Advanced Kotlin patterns | StateFlow, sealed classes, @Immutable, DSLs |
| `kotlin-coroutines` | Advanced async patterns | supervisorScope, callbackFlow, relay pools, testing |
| `kotlin-multiplatform` | Platform abstraction | expect/actual, source sets, sharing decisions |
| `compose-expert` | Shared UI components | Material3, state hoisting, recomposition |
| `android-expert` | Android platform | Navigation, permissions, lifecycle, Material3 |
| `desktop-expert` | Desktop platform | Window, MenuBar, Tray, keyboard shortcuts |
| `gradle-expert` | Build system | Dependencies, versioning, packaging, optimization |
## Workflow
**When you ask for a feature:**
1. **Quick skill assessment** - I identify which skills are relevant
2. **Propose which skills** - I present which skills I'll use for the task
3. **Get approval** - You review and approve (or adjust) the skill selection
4. **Review plan using approved skills** - I invoke the approved skills to create detailed implementation plan
5. **Execute with skills** - Skills collaborate to implement the feature
**Example:**
```
You: "Add video support to notes"
Me: "I'll use:
- /nostr-expert (NIP-71 video events)
- /compose-expert (video player UI)
- /android-expert (platform video APIs)
Proceed?"
You: "yes"
Me: [invokes skills to create plan]
"Plan from skills:
1. nostr-expert: Use NIP-71 kind 34235 for video events...
2. compose-expert: Create VideoPlayer composable in commons...
3. android-expert: Use ExoPlayer for Android...
Proceed with implementation?"
You: "yes"
Me: [implements using skill guidance]
```
## Commands
- `/desktop-run` - Build and run desktop app
- `/nip <number>` - Get NIP implementation guidance
## Feature Workflow
**CRITICAL: Always check existing implementations first before creating new code!**
When picking up a new task or feature, follow this process:
### Step 0: Survey Existing Implementation (MANDATORY)
**Before writing ANY code, thoroughly audit ALL modules:**
1. **Search for existing implementations across all modules:**
```bash
# Search in quartz for protocol/business logic
grep -r "class.*Manager\|object.*Cache\|class.*Filter" quartz/src/commonMain/
# Search in commons for UI components
grep -r "@Composable.*Card\|@Composable.*View\|@Composable.*Dialog" commons/src/
# Search in amethyst for Android patterns
grep -r "class.*ViewModel\|class.*Account\|class.*State" amethyst/src/main/java/
# Search for specific functionality
grep -r "fun isFollowing\|fun subscribe\|fun getMetadata" {quartz,commons,amethyst}/src/
```
2. **Understand existing architecture patterns:**
- Event stores and caching systems
- State management patterns (StateFlow, mutable states)
- ViewModel patterns and lifecycle handling
- Filter builders and relay subscription patterns
- UI component hierarchies
3. **Key principle:** Most logic already exists! Your job is to:
- **Reuse** existing protocol/business logic from quartz
- **Extract** shareable UI components AND ViewModels from amethyst to commons
- Create **platform-specific** layouts/navigation for Desktop
- **NOT** duplicate existing managers, caches, or state systems
4. **Document findings in implementation plan as a matrix:**
| File/Component | Status | Location | Action |
|----------------|--------|----------|--------|
| FilterBuilders | ✅ Exists | quartz/relay/filters/ | Reuse as-is |
| NoteCard | 📦 Extract | amethyst/ui/note/ → commons/ | Extract to commons |
| HomeFeedViewModel | 📦 Extract | amethyst/ → commons/commonMain/viewmodels/ | Extract to commons |
| ProfileCache | ⚠️ Avoid | N/A | Already in User/Account pattern |
**Legend:**
- ✅ **Reuse** - Exists and can be used directly
- 📦 **Extract** - Exists in Android, needs extraction to commons
- 🆕 **New** - Doesn't exist, needs creation (platform-specific only)
- ⚠️ **Avoid** - Duplicate functionality, use existing pattern instead
### Step 1: Analyze Android Implementation
After surveying (Step 0), deeply examine the Android implementation:
1. Find the relevant feature/component in `amethyst/` module
2. Understand the current implementation patterns
3. Identify dependencies and integrations
4. Map out what code can be shared vs platform-specific
### Step 2: Create Implementation Plan
Before coding, create a plan that categorizes work into three buckets:
| Category | Description | Location |
|----------|-------------|----------|
| **Android-Specific** | Platform-native layouts, navigation patterns | `amethyst/`, `androidMain/` |
| **Reusable (Shared)** | Business logic, UI components, **ViewModels**, state management | `quartz/commonMain/`, `commons/commonMain/` |
| **Desktop-Specific** | Desktop-native layouts, navigation patterns, platform APIs | `desktopApp/`, `jvmMain/` |
### Step 3: Code Sharing Strategy
**Share:**
- Business logic and data models → `quartz/commonMain/`
- Major UI components (cards, lists, dialogs) → `commons/commonMain/`
- **ViewModels** (state, business logic) → `commons/commonMain/viewmodels/`
- Icons and visual assets → `commons/commonMain/`
**Keep Platform-Native:**
- **Screen composables** (layout, scaffolding) - Desktop uses `Window`, Android uses `Activity`
- Navigation patterns (sidebar vs bottom nav)
- Platform-specific interactions (gestures, keyboard shortcuts)
- System integrations (notifications, file pickers)
**Rationale:** ViewModels contain platform-agnostic state management (StateFlow/SharedFlow) and business logic. Screens consume ViewModels but render differently (Desktop sidebar + content area vs Android bottom nav).
### Step 4: Extract Shared Components
When extracting UI components:
1. Identify reusable composables in Android code
2. Move to `commons/commonMain/` (consult `/compose-expert` for patterns)
3. Create expect/actual declarations for platform-specific behavior (consult `/kotlin-multiplatform`)
4. Update both Android and Desktop to use shared component
**Note:** `quartz/` is protocol-only (no composables). Shared UI goes in `commons/` after converting it to KMP.
## Build Commands
```bash
# Run desktop app
./gradlew :desktopApp:run
# Run Android app
./gradlew :amethyst:installDebug
# Build Quartz for all targets
./gradlew :quartz:build
# Run tests
./gradlew test
# Format code
./gradlew spotlessApply
```
## Quartz KMP Structure
The Quartz library uses expect/actual for platform-specific implementations:
```kotlin
// commonMain - shared protocol logic
expect class CryptoProvider {
fun sign(message: ByteArray, privateKey: ByteArray): ByteArray
fun verify(message: ByteArray, signature: ByteArray, publicKey: ByteArray): Boolean
}
// androidMain - uses secp256k1-kmp-jni-android
actual class CryptoProvider { /* Android implementation */ }
// jvmMain - uses secp256k1-kmp-jni-jvm
actual class CryptoProvider { /* JVM implementation */ }
```
## Key Patterns
### Platform Abstraction
```kotlin
// commonMain
expect fun openExternalUrl(url: String)
// androidMain
actual fun openExternalUrl(url: String) {
context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(url)))
}
// jvmMain (Desktop)
actual fun openExternalUrl(url: String) {
Desktop.getDesktop().browse(URI(url))
}
```
### Navigation Shell
- **Desktop**: Sidebar + main content area
- **Android**: Bottom navigation
## Git Workflow
- Branch: `feat/desktop-<feature>` or `fix/desktop-<issue>`
- Commits: Conventional commits (`feat:`, `fix:`, etc.)
- Never use `--no-verify`
## Resources
- [Nostr NIPs](https://github.com/nostr-protocol/nips)
- [Compose Multiplatform](https://www.jetbrains.com/compose-multiplatform/)
- [KMP Documentation](https://kotlinlang.org/docs/multiplatform.html)
+45
View File
@@ -0,0 +1,45 @@
---
description: Build and run the desktop app
---
Build and run the Amethyst Desktop application:
```bash
./gradlew :desktopApp:run
```
## Troubleshooting
If the build fails, check:
1. **JDK Version**: Requires JDK 17+
```bash
java -version
```
2. **Compose Multiplatform Plugin**: Verify version in `gradle/libs.versions.toml`
3. **Quartz Build**: Ensure Quartz compiles first
```bash
./gradlew :quartz:build
```
4. **Desktop Dependencies**: Check `desktopApp/build.gradle.kts` has:
```kotlin
implementation(compose.desktop.currentOs)
```
## Creating Distributable
```bash
# macOS
./gradlew :desktopApp:packageDmg
# Windows
./gradlew :desktopApp:packageMsi
# Linux
./gradlew :desktopApp:packageDeb
```
Outputs will be in `desktopApp/build/compose/binaries/`
+50
View File
@@ -0,0 +1,50 @@
---
description: Extract a composable from amethyst to shared code
---
Extract the component `$ARGUMENTS` from the Android app to shared KMP code:
## Process
1. **Locate the component** in the amethyst module:
```bash
find amethyst/src -name "*$ARGUMENTS*" -o -name "*$ARGUMENTS*"
grep -r "fun $ARGUMENTS\|class $ARGUMENTS" amethyst/src/
```
2. **Analyze dependencies**:
- Android-specific imports (Context, Intent, etc.)
- Platform APIs (Camera, MediaStore, etc.)
- Android Compose specifics vs standard Compose
3. **Identify what can be shared**:
- Pure Composable functions → `shared-ui/commonMain/`
- Business logic → `quartz/commonMain/`
- Platform-specific → create expect/actual
4. **Create shared version**:
- Move to appropriate shared module
- Replace Android imports with multiplatform alternatives
- Add expect declarations for platform-specific parts
5. **Update references**:
- Change imports in amethyst module
- Add implementations in desktopApp if needed
## Common Replacements
| Android | Multiplatform |
|---------|---------------|
| `LocalContext.current` | expect/actual or parameter |
| `stringResource()` | `Res.string.*` |
| `painterResource()` | `painterResource(Res.drawable.*)` |
| `Toast.makeText()` | Custom snackbar/notification |
| `Intent` | expect/actual for navigation |
## Example
```
/extract NoteCard
```
This will find NoteCard, analyze its dependencies, and guide you through extracting it to shared code.
+32
View File
@@ -0,0 +1,32 @@
---
description: Get NIP specification and implementation guidance
---
Fetch and explain NIP-$ARGUMENTS from the Nostr protocol:
1. **Get the specification** from https://github.com/nostr-protocol/nips/blob/master/$ARGUMENTS.md
2. **Show key details**:
- Event kind(s) used
- Required and optional fields
- Tag structure
- Message flow between client and relay
3. **Check implementation status** in Quartz:
```bash
grep -r "NIP-$ARGUMENTS\|nip$ARGUMENTS\|kind.*=" quartz/src/
```
4. **Provide implementation guidance**:
- Which Quartz classes to use or create
- Event construction example
- Relay subscription filters
- Verification/validation logic
## Example Usage
```
/nip 01 # Basic protocol
/nip 44 # Versioned encryption
/nip 57 # Zaps
```
+322
View File
@@ -0,0 +1,322 @@
# AmethystMultiplatform Skills Creation Plan
## Overview
Create 8 hybrid domain skills combining general expertise with AmethystMultiplatform-specific patterns.
**Approach:** Each skill provides domain knowledge + project-specific implementation patterns from codebase.
## Skills to Implement
### 1. kotlin-multiplatform ✅ COMPLETED
**Focus:** KMP architecture, jvmAndroid source set pattern, expect/actual
**SKILL.md sections:**
- Mental model: KMP hierarchy as dependency graph
- Source set architecture: commonMain → jvmAndroid → {androidMain, jvmMain}
- The jvmAndroid pattern (unique to this project, verified in quartz/build.gradle.kts:132-149)
- expect/actual mechanics with 24+ examples from codebase
- iOS framework setup for Quartz distribution
**Bundled resources:**
- `references/source-set-hierarchy.md` - Visual diagram + examples
- `references/expect-actual-catalog.md` - All 24 expect/actual pairs with patterns
- `scripts/validate-kmp-structure.sh` - Verify source set dependencies
- `assets/kmp-hierarchy-diagram.png` - Visual graph
**Differentiation:** Existing kotlin-multiplatform agent = general KMP. This skill = Amethyst's unique jvmAndroid pattern, concrete examples.
**Status:** ✅ Skill created and packaged at `.claude/skills/kotlin-multiplatform/`
---
### 2. gradle-expert ✅ COMPLETED
**Focus:** Build optimization, dependency resolution, multi-module KMP troubleshooting
**SKILL.md sections:**
- Build architecture: 4 modules, dependency flow
- Version catalog mastery (libs.versions.toml)
- Module dependency patterns (api vs implementation)
- Android-specific: compileSdk, proguard
- Desktop packaging: TargetFormat, distributions
- Build performance: daemon, parallel, caching
- Common errors: compose version conflicts, secp256k1 JNI variants
**Bundled resources:**
- `references/build-commands.md` - Common gradle tasks
- `references/dependency-graph.md` - Module visualization
- `references/version-catalog-guide.md` - Version catalog patterns
- `references/common-errors.md` - Troubleshooting guide
- `scripts/analyze-build-time.sh` - Performance report
- `scripts/fix-dependency-conflicts.sh` - Conflict patterns
**Differentiation:** Focus on 4-module structure, KMP + Android + Desktop combo, specific issues (compose conflicts).
**Status:** ✅ SKILL.md (549 lines) + 4 references + 2 scripts created at `.claude/skills/gradle-expert/`
---
### 3. kotlin-expert ✅ DRAFT COMPLETE
**Focus:** Flow state management, sealed hierarchies, immutability, DSL builders, inline/reified
**SKILL.md sections:**
- Flow state management: StateFlow/SharedFlow patterns (AccountManager, RelayConnectionManager)
- Sealed hierarchies: sealed class vs sealed interface decision trees (AccountState, SignerResult)
- Immutability: @Immutable for Compose performance (173+ event classes)
- DSL builders: Type-safe fluent APIs (TagArrayBuilder, TlvBuilder)
- Inline functions: reified generics, performance optimization (OptimizedJsonMapper)
- Value classes: Zero-cost wrappers (optimization opportunity)
**Bundled resources:**
- `references/flow-patterns.md` - StateFlow/SharedFlow with AccountManager, RelayManager patterns
- `references/sealed-class-catalog.md` - All 8 sealed types in quartz with usage patterns
- `references/dsl-builder-examples.md` - TagArrayBuilder, PrivateTagArrayBuilder, TlvBuilder, custom DSL patterns
- `references/immutability-patterns.md` - @Immutable annotation, data classes, ImmutableList/Map/Set
**Differentiation:** Complements kotlin-coroutines agent (deep async). This skill = Amethyst Kotlin idioms (StateFlow state management, sealed for type safety, @Immutable for Compose, DSL builders).
**Status:** ✅ SKILL.md (455 lines) + 4 references created at `.claude/skills/kotlin-expert/`
**10-Step Progress:**
1. ✅ UNDERSTAND - Defined scope (Flow/sealed/DSL/immutability/inline)
2. ✅ EXPLORE - Found 173 @Immutable events, StateFlow in AccountManager/RelayManager, SignerResult generics, TagArrayBuilder
3. ✅ RESEARCH - StateFlow vs SharedFlow, sealed class vs interface best practices 2025
4. ✅ SYNTHESIZE - Extracted Amethyst patterns (hot flows for state, sealed for results, @Immutable for perf)
5. ✅ DRAFT - Created SKILL.md + 4 reference files (flow, sealed, dsl, immutability)
6. ✅ SELF-CRITIQUE - Reviewed against 4 Core Truths (all PASS)
7. ✅ ITERATE - Draft complete (skipping deep iteration for now)
8. ⏸️ TEST - Deferred to later (requires real usage scenarios)
9. ⏸️ FINALIZE - Deferred to later
10. ✅ DOCUMENT - Updated plan
---
### 4. compose-expert ✅ COMPLETED
**Focus:** Shared composables, state management, animations, Material3
**SKILL.md sections:**
- Shared composables philosophy (100+ already shared in commons/commonMain)
- State management: remember, derivedStateOf, produceState (visual patterns)
- Recomposition optimization: @Stable/@Immutable (visual usage)
- Material3 conventions: theming
- Custom icons: ImageVector builders (robohash pattern)
- Platform differences: Desktop vs Android UI
- Performance: lazy lists, image loading
- Decision framework: share by default in commonMain
**Bundled resources:**
- `references/shared-composables-catalog.md` - Complete catalog with patterns
- `references/state-patterns.md` - State hoisting, derivedStateOf examples
- `references/icon-assets.md` - ImageVector patterns, roboBuilder DSL
- `scripts/find-composables.sh` - Grep @Composable utility
**Differentiation:** Multiplatform Compose patterns, shared vs platform UI philosophy, Amethyst conventions (robohash, custom icons). Delegates navigation to platform experts, defers Kotlin language details to kotlin-expert.
**Status:** ✅ SKILL.md (578 lines) + 3 references + 1 script created at `.claude/skills/compose-expert/`
---
### 5. ios-expert
**Focus:** iosMain patterns, Swift/KMP interop, XCFramework generation
**SKILL.md sections:**
- iOS source sets: iosMain, iosX64Main, iosArm64Main
- Swift interop: type mapping, nullability
- expect/actual iOS: 10+ examples from quartz/iosMain
- XCFramework setup: baseName = "quartz-kmpKit"
- Platform APIs: platform.posix, CFNetwork, Security
- CocoaPods integration
- XCode project setup
**Bundled resources:**
- `references/ios-actual-implementations.md` - 10 iosMain actuals
- `references/swift-interop-guide.md` - Type mapping
- `references/xcode-integration.md` - XCode setup
- `scripts/generate-xcframework.sh` - Build all iOS targets
**Differentiation:** iOS platform specialization with Amethyst iosMain patterns, Quartz framework setup.
---
### 6. desktop-expert ✅ DRAFT COMPLETE
**Focus:** Desktop UX, window management, Compose Desktop APIs, OS-specific conventions
**SKILL.md sections:**
- Desktop entry point: application {} DSL
- Window management: WindowState, positioning, multi-window
- Menu system: MenuBar, keyboard shortcuts (OS-aware)
- System tray: minimize to tray
- Desktop navigation: NavigationRail pattern (vs Android bottom nav)
- File system: Desktop.getDesktop(), file pickers, drag-drop
- Desktop UX principles: keyboard-first, native feel, tooltips
- OS-specific behavior: macOS vs Windows vs Linux
- Platform detection: PlatformDetector utility
- Packaging: DMG, MSI, DEB distribution
**Bundled resources:**
- `references/desktop-compose-apis.md` - Complete Desktop API catalog (Window, Tray, MenuBar, Dialog, etc.)
- `references/desktop-navigation.md` - NavigationRail vs BottomNav patterns
- `references/keyboard-shortcuts.md` - Standard shortcuts by OS with DesktopShortcuts helper
- `references/os-detection.md` - Platform detection, file paths, system integration
**Differentiation:** Desktop-only APIs, OS conventions (Cmd vs Ctrl), NavigationRail, delegates build to gradle-expert and shared code to kotlin-multiplatform/compose-expert.
**Status:** ✅ SKILL.md + 4 references created at `.claude/skills/desktop-expert/`
**10-Step Progress:**
1. ✅ UNDERSTAND - Defined desktop usage scenarios
2. ✅ EXPLORE - Analyzed desktopApp/ module patterns (Main.kt, FeedScreen.kt, LoginScreen.kt)
3. ✅ RESEARCH - Compose Desktop APIs, OS-specific UX conventions (JetBrains docs, HIG)
4. ✅ SYNTHESIZE - Extracted desktop principles from codebase
5. ✅ DRAFT - Created SKILL.md + 4 reference files
6. ✅ SELF-CRITIQUE - Reviewed against 4 Core Truths (all PASS)
7. ✅ ITERATE - Draft complete (skipping deep iteration for now)
8. ⏸️ TEST - Deferred to later (requires real desktop scenarios)
9. ⏸️ FINALIZE - Deferred to later
10. ✅ DOCUMENT - Updated plan
---
### 7. android-expert ✅ DRAFT COMPLETE
**Focus:** Android platform APIs, navigation, permissions, Material Design
**SKILL.md sections:**
- Android module structure: amethyst/ layout
- Navigation: Navigation Compose, bottom nav
- Permissions: runtime (camera, biometric)
- Platform APIs: Intent, Context, ContentResolver
- Lifecycle: Lifecycle-aware, ViewModel
- Material Design: Android Material 3
- Build config: Proguard, R8
- Android UX: mobile-first patterns
**Bundled resources:**
- `references/android-navigation.md` - Navigation Compose
- `references/android-permissions.md` - Permission handling
- `references/proguard-rules.md` - Proguard explanation
- `scripts/analyze-apk-size.sh` - APK optimization
**Differentiation:** amethyst module structure, Android vs desktop patterns, Amethyst conventions.
**Status:** ✅ SKILL.md + 3 references + 1 script created at `.claude/skills/android-expert/`
**10-Step Progress:**
1. ✅ UNDERSTAND - Defined Android usage scenarios
2. ✅ EXPLORE - Analyzed amethyst/ module patterns
3. ✅ RESEARCH - Android best practices + KMP Android patterns
4. ✅ SYNTHESIZE - Extracted Android principles from codebase
5. ✅ DRAFT - Initialized skill, created resources
6. ✅ SELF-CRITIQUE - Reviewed against 4 Core Truths (all PASS)
7. ✅ ITERATE - Draft complete (skipping deep iteration for now)
8. ⏸️ TEST - Deferred to later
9. ⏸️ FINALIZE - Deferred to later
10. ✅ DOCUMENT - Updated plan
---
### 8. nostr-expert ✅ COMPLETED
**Focus:** Nostr protocol, NIPs, Quartz architecture, event patterns
**SKILL.md sections:**
- Quartz architecture: package structure by NIP (57 NIPs implemented)
- Event anatomy: IEvent, Event, kinds, tags
- EventTemplate & TagArrayBuilder DSL patterns
- Common event types: TextNoteEvent, MetadataEvent, ReactionEvent, Addressable events
- Tag patterns: e-tag, p-tag, a-tag, d-tag with builders
- Threading (NIP-10): reply/root markers
- Cryptography: secp256k1 signing, NIP-44 encryption
- Bech32 encoding: npub, nsec, note, nevent
- Event validation & verification
- Common workflows: publishing, querying, zaps, gift-wrapped DMs
**Bundled resources:**
- `references/nip-catalog.md` - All 57 NIPs with package locations (179 lines)
- `references/event-hierarchy.md` - Event class hierarchy, kind classifications (293 lines)
- `references/tag-patterns.md` - Tag structure, TagArrayBuilder DSL, parsing (251 lines)
- `scripts/nip-lookup.sh` - Find NIP implementations by number or search term
**Differentiation:** nostr-protocol agent = NIP specs. This skill = Quartz implementation patterns (57 NIPs), concrete code examples from codebase.
**Status:** ✅ SKILL.md (552 lines) + 3 references + 1 script created at `.claude/skills/nostr-expert/`
---
## Implementation Workflow
Using skill-creator 10-step methodology per skill:
**Overall Plan:**
1. **UNDERSTAND** ✅ - 8 skills defined, user clarifications obtained
2. **EXPLORE** ✅ - Codebase analyzed via Explore agent
3. **RESEARCH** ✅ - Domain patterns identified via Plan agent
4. **SYNTHESIZE** ✅ - Skills designed above
**Per-Skill Implementation:**
- kotlin-multiplatform: ✅ COMPLETED
- gradle-expert: ✅ COMPLETED
- kotlin-expert: ✅ COMPLETED
- compose-expert: ✅ COMPLETED
- desktop-expert: ✅ COMPLETED
- android-expert: ✅ COMPLETED
- nostr-expert: ✅ COMPLETED
- ios-expert: ⏸️ DEFERRED (iOS not yet implemented in AmethystMultiplatform)
## Critical Files Referenced
**Build patterns:**
- `/quartz/build.gradle.kts:132-149` - jvmAndroid source set
- `/commons/build.gradle.kts` - Shared UI setup
**Code patterns:**
- `/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip10Notes/TextNoteEvent.kt` - Event structure
- `/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/account/AccountManager.kt` - StateFlow pattern
- `/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/Platform.kt` - expect/actual
**Documentation:**
- `/docs/shared-ui-analysis.md` - UI migration strategy
## Output Location
`.claude/skills/<skill-name>/` for each skill
## Next Steps
1. ✅ Save this plan as `.claude/core-skills-plan.md` for reference
2. ✅ Completed kotlin-multiplatform skill
3. ✅ Completed gradle-expert skill
4. ✅ Completed kotlin-expert skill
5. ✅ Completed compose-expert skill
6. ✅ Completed desktop-expert skill
7. ✅ Completed android-expert skill
8. ✅ Completed nostr-expert skill
9. ⏸️ Deferred ios-expert (iOS not yet implemented in codebase)
## Current Status: 7/8 Skills Completed
**Completed Skills (Auto-loaded from `.claude/skills/`):**
1. ✅ kotlin-multiplatform (KMP architecture, jvmAndroid pattern, expect/actual)
2. ✅ gradle-expert (Build system, dependencies, version catalog, troubleshooting)
3. ✅ kotlin-expert (Flow state, sealed classes, @Immutable, DSL builders)
4. ✅ compose-expert (Shared composables, state management, Material3, ImageVector)
5. ✅ desktop-expert (Desktop UX, window management, Compose Desktop APIs)
6. ✅ android-expert (Android platform APIs, navigation, permissions)
7. ✅ nostr-expert (Nostr protocol, Quartz implementation, NIPs, events, tags)
**Deferred:**
- ⏸️ ios-expert (iOS not implemented yet in AmethystMultiplatform)
## Skill Loading
**All completed skills are automatically loaded** when this project opens. Skills are auto-discovered from `.claude/skills/` directory.
To manually verify skills are loaded:
```bash
ls -1 .claude/skills/
```
Should show:
- android-expert/
- compose-expert/
- desktop-expert/
- gradle-expert/
- kotlin-expert/
- kotlin-multiplatform/
- nostr-expert/
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,615 @@
# Android Navigation Patterns
Complete navigation implementation patterns for Amethyst Android app using Navigation Compose with type safety.
## Type-Safe Routes (Navigation 2.8.0+)
### Route Definitions
```kotlin
// Routes.kt - All 40+ routes in Amethyst
@Serializable
sealed class Route {
// Bottom nav routes
@Serializable object Home : Route()
@Serializable object Messages : Route()
@Serializable object Video : Route()
@Serializable object Discover : Route()
@Serializable object Notification : Route()
// Content routes with parameters
@Serializable data class Profile(val pubkey: String) : Route()
@Serializable data class Note(val id: String) : Route()
@Serializable data class Channel(val id: String) : Route()
@Serializable data class Thread(
val id: String,
val replyTo: String? = null
) : Route()
// New content routes
@Serializable data class NewPost(
val message: String? = null,
val attachment: String? = null,
val replyTo: String? = null
) : Route()
// Settings
@Serializable object Settings : Route()
@Serializable object Security : Route()
@Serializable object Relays : Route()
// Search
@Serializable data class Search(val query: String = "") : Route()
// Media
@Serializable data class Image(val url: String) : Route()
@Serializable data class Video(val url: String) : Route()
}
```
## NavHost Configuration
### Basic Setup
```kotlin
@Composable
fun AppNavigation(
navController: NavHostController,
accountViewModel: AccountViewModel,
drawerState: DrawerState
) {
val scope = rememberCoroutineScope()
val nav = remember {
Nav(navController, drawerState, scope)
}
NavHost(
navController = navController,
startDestination = Route.Home,
enterTransition = { fadeIn(animationSpec = tween(200)) },
exitTransition = { fadeOut(animationSpec = tween(200)) },
popEnterTransition = { fadeIn(animationSpec = tween(200)) },
popExitTransition = { fadeOut(animationSpec = tween(200)) }
) {
// Define routes
composable<Route.Home> {
HomeScreen(accountViewModel, nav)
}
composable<Route.Profile> { backStackEntry ->
val profile = backStackEntry.toRoute<Route.Profile>()
ProfileScreen(
pubkey = profile.pubkey,
accountViewModel = accountViewModel,
nav = nav
)
}
composable<Route.Note> { backStackEntry ->
val note = backStackEntry.toRoute<Route.Note>()
NoteScreen(
noteId = note.id,
accountViewModel = accountViewModel,
nav = nav
)
}
composable<Route.NewPost> { backStackEntry ->
val newPost = backStackEntry.toRoute<Route.NewPost>()
NewPostScreen(
initialMessage = newPost.message,
initialAttachment = newPost.attachment,
replyTo = newPost.replyTo,
accountViewModel = accountViewModel,
onPost = { nav.popBack() }
)
}
}
}
```
### Custom Transitions
```kotlin
composable<Route.Profile>(
enterTransition = {
slideIntoContainer(
AnimatedContentTransitionScope.SlideDirection.Start,
animationSpec = tween(300)
)
},
exitTransition = {
slideOutOfContainer(
AnimatedContentTransitionScope.SlideDirection.Start,
animationSpec = tween(300)
)
},
popEnterTransition = {
slideIntoContainer(
AnimatedContentTransitionScope.SlideDirection.End,
animationSpec = tween(300)
)
},
popExitTransition = {
slideOutOfContainer(
AnimatedContentTransitionScope.SlideDirection.End,
animationSpec = tween(300)
)
}
) { backStackEntry ->
val profile = backStackEntry.toRoute<Route.Profile>()
ProfileScreen(profile.pubkey, accountViewModel, nav)
}
```
## Navigation Manager
### Nav Wrapper Class
```kotlin
class Nav(
val controller: NavHostController,
val drawerState: DrawerState,
val scope: CoroutineScope
) {
/**
* Navigate to a route, closing drawer if open
*/
fun nav(route: Route) {
scope.launch {
if (!controller.popBackStack(route, inclusive = false)) {
controller.navigate(route) {
launchSingleTop = true
}
}
drawerState.close()
}
}
/**
* Navigate with new stack (clear back stack to Home)
*/
fun newStack(route: Route) {
scope.launch {
controller.navigate(route) {
popUpTo(Route.Home) {
inclusive = false
}
launchSingleTop = true
}
drawerState.close()
}
}
/**
* Pop back stack
*/
fun popBack() {
controller.popBackStack()
}
/**
* Pop up to specific route
*/
inline fun <reified T : Route> popUpTo(inclusive: Boolean = false) {
controller.popBackStack<T>(inclusive = inclusive)
}
/**
* Get current route
*/
fun currentRoute(): Route? {
return controller.currentBackStackEntry?.toRoute<Route>()
}
}
```
## Bottom Navigation
### Material3 NavigationBar
```kotlin
@Composable
fun AppBottomBar(
currentRoute: Route?,
nav: Nav
) {
NavigationBar(
containerColor = MaterialTheme.colorScheme.surface,
contentColor = MaterialTheme.colorScheme.onSurface
) {
BottomBarRoute.entries.forEach { item ->
NavigationBarItem(
selected = currentRoute?.let { it::class == item.route::class } ?: false,
onClick = { nav.nav(item.route) },
icon = {
Icon(
imageVector = if (currentRoute?.let { it::class == item.route::class } == true) {
item.selectedIcon
} else {
item.unselectedIcon
},
contentDescription = item.label
)
},
label = { Text(item.label) },
alwaysShowLabel = false
)
}
}
}
enum class BottomBarRoute(
val route: Route,
val selectedIcon: ImageVector,
val unselectedIcon: ImageVector,
val label: String
) {
HOME(
route = Route.Home,
selectedIcon = Icons.Filled.Home,
unselectedIcon = Icons.Outlined.Home,
label = "Home"
),
MESSAGES(
route = Route.Messages,
selectedIcon = Icons.Filled.Message,
unselectedIcon = Icons.Outlined.Message,
label = "Messages"
),
VIDEOS(
route = Route.Video,
selectedIcon = Icons.Filled.VideoLibrary,
unselectedIcon = Icons.Outlined.VideoLibrary,
label = "Videos"
),
DISCOVER(
route = Route.Discover,
selectedIcon = Icons.Filled.Explore,
unselectedIcon = Icons.Outlined.Explore,
label = "Discover"
),
NOTIFICATIONS(
route = Route.Notification,
selectedIcon = Icons.Filled.Notifications,
unselectedIcon = Icons.Outlined.Notifications,
label = "Notifications"
)
}
```
### Observing Current Route
```kotlin
@Composable
fun MainScreen() {
val navController = rememberNavController()
val currentBackStackEntry by navController.currentBackStackEntryAsState()
val currentRoute = currentBackStackEntry?.toRoute<Route>()
Scaffold(
topBar = {
if (shouldShowTopBar(currentRoute)) {
AppTopBar(currentRoute)
}
},
bottomBar = {
if (shouldShowBottomBar(currentRoute)) {
AppBottomBar(currentRoute, nav)
}
}
) { paddingValues ->
AppNavigation(
navController = navController,
modifier = Modifier.padding(paddingValues)
)
}
}
fun shouldShowBottomBar(route: Route?): Boolean {
return when (route) {
is Route.Home,
is Route.Messages,
is Route.Video,
is Route.Discover,
is Route.Notification -> true
else -> false
}
}
```
## Navigation Drawer
### Material3 ModalDrawerSheet
```kotlin
@Composable
fun AppDrawer(
drawerState: DrawerState,
nav: Nav,
accountViewModel: AccountViewModel
) {
val scope = rememberCoroutineScope()
ModalDrawerSheet {
// User profile header
DrawerHeader(accountViewModel.account)
HorizontalDivider()
// Menu items
NavigationDrawerItem(
label = { Text("Home") },
selected = false,
onClick = { nav.nav(Route.Home) },
icon = { Icon(Icons.Default.Home, "Home") }
)
NavigationDrawerItem(
label = { Text("Profile") },
selected = false,
onClick = { nav.nav(Route.Profile(accountViewModel.account.pubkey)) },
icon = { Icon(Icons.Default.Person, "Profile") }
)
NavigationDrawerItem(
label = { Text("Settings") },
selected = false,
onClick = { nav.nav(Route.Settings) },
icon = { Icon(Icons.Default.Settings, "Settings") }
)
HorizontalDivider()
NavigationDrawerItem(
label = { Text("Logout") },
selected = false,
onClick = {
scope.launch {
accountViewModel.logout()
drawerState.close()
}
},
icon = { Icon(Icons.Default.Logout, "Logout") }
)
}
}
```
### Main Scaffold with Drawer
```kotlin
@Composable
fun MainScreen() {
val navController = rememberNavController()
val drawerState = rememberDrawerState(DrawerValue.Closed)
val scope = rememberCoroutineScope()
val nav = remember { Nav(navController, drawerState, scope) }
ModalNavigationDrawer(
drawerState = drawerState,
drawerContent = {
AppDrawer(drawerState, nav, accountViewModel)
}
) {
Scaffold(
topBar = {
TopAppBar(
title = { Text("Amethyst") },
navigationIcon = {
IconButton(
onClick = { scope.launch { drawerState.open() } }
) {
Icon(Icons.Default.Menu, "Menu")
}
}
)
},
bottomBar = { AppBottomBar(currentRoute, nav) }
) { paddingValues ->
AppNavigation(
navController = navController,
modifier = Modifier.padding(paddingValues)
)
}
}
}
```
## Deep Link Handling
### Intent Processing
```kotlin
@Composable
fun AppNavigation(
navController: NavHostController,
accountViewModel: AccountViewModel
) {
val activity = LocalContext.current as? Activity
// Handle incoming intents
LaunchedEffect(activity?.intent) {
activity?.intent?.let { intent ->
handleIntent(intent, navController)
}
}
NavHost(navController = navController) {
// Routes...
}
}
fun handleIntent(intent: Intent, navController: NavHostController) {
when (intent.action) {
Intent.ACTION_SEND -> {
// Share text/image
val sharedText = intent.getStringExtra(Intent.EXTRA_TEXT)
val sharedUri = intent.getParcelableExtra<Uri>(Intent.EXTRA_STREAM)
navController.navigate(
Route.NewPost(
message = sharedText,
attachment = sharedUri?.toString()
)
)
}
Intent.ACTION_VIEW -> {
// Deep link
intent.data?.let { uri ->
when (uri.scheme) {
"nostr" -> handleNostrUri(uri, navController)
"https", "http" -> handleWebUri(uri, navController)
}
}
}
}
}
fun handleNostrUri(uri: Uri, navController: NavHostController) {
val path = uri.pathSegments.firstOrNull() ?: return
when {
path.startsWith("npub") -> {
navController.navigate(Route.Profile(path))
}
path.startsWith("note") -> {
navController.navigate(Route.Note(path))
}
path.startsWith("nevent") -> {
// Decode and navigate to event
val eventId = decodeNevent(path)
navController.navigate(Route.Note(eventId))
}
}
}
fun handleWebUri(uri: Uri, navController: NavHostController) {
// Handle web-based deep links
// https://njump.me/npub1...
// https://primal.net/profile/npub1...
when (uri.host) {
"njump.me" -> {
val id = uri.pathSegments.lastOrNull()
if (id?.startsWith("npub") == true) {
navController.navigate(Route.Profile(id))
}
}
"primal.net" -> {
// Parse primal.net URLs
}
}
}
```
### AndroidManifest Intent Filters
```xml
<!-- MainActivity -->
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="nostr" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="https" android:host="njump.me" />
<data android:scheme="https" android:host="primal.net" />
<data android:scheme="https" android:host="iris.to" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.SEND" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="text/plain" />
<data android:mimeType="image/*" />
</intent-filter>
```
## Nested Navigation
### Tab Navigation Inside Screen
```kotlin
@Composable
fun ProfileScreen(
pubkey: String,
nav: Nav
) {
val nestedNavController = rememberNavController()
Column {
ProfileHeader(pubkey)
// Tab row
TabRow(selectedTabIndex = currentTab) {
Tab(selected = currentTab == 0, onClick = { /* Notes */ })
Tab(selected = currentTab == 1, onClick = { /* Replies */ })
Tab(selected = currentTab == 2, onClick = { /* Likes */ })
}
// Nested NavHost for tabs
NavHost(
navController = nestedNavController,
startDestination = ProfileTab.Notes
) {
composable<ProfileTab.Notes> {
NotesTabContent(pubkey)
}
composable<ProfileTab.Replies> {
RepliesTabContent(pubkey)
}
composable<ProfileTab.Likes> {
LikesTabContent(pubkey)
}
}
}
}
@Serializable
sealed class ProfileTab {
@Serializable object Notes : ProfileTab()
@Serializable object Replies : ProfileTab()
@Serializable object Likes : ProfileTab()
}
```
## Testing Navigation
### Navigation Test Example
```kotlin
@Test
fun testNavigationToProfile() {
val navController = TestNavHostController(
ApplicationProvider.getApplicationContext()
)
composeTestRule.setContent {
navController.navigatorProvider.addNavigator(
ComposeNavigator()
)
AppNavigation(navController, accountViewModel)
}
// Navigate to profile
composeTestRule.onNodeWithText("Profile").performClick()
// Verify navigation
val currentRoute = navController.currentBackStackEntry?.toRoute<Route>()
assertTrue(currentRoute is Route.Profile)
}
```
## File Locations
- `amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt`
- `amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt`
- `amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/Nav.kt`
- `amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/AppBottomBar.kt`
- `amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/drawer/DrawerContent.kt`
@@ -0,0 +1,659 @@
# Android Runtime Permissions
Complete permission handling patterns for Amethyst using Accompanist Permissions library and Android best practices.
## Permission Categories in Amethyst
### Network Permissions (Normal - Auto-granted)
```xml
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.CHANGE_NETWORK_STATE" />
```
### Media Permissions (Dangerous - Runtime request)
```xml
<!-- Camera -->
<uses-permission android:name="android.permission.CAMERA" />
<!-- Audio -->
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<!-- Storage (version-specific) -->
<uses-permission
android:name="android.permission.READ_EXTERNAL_STORAGE"
android:maxSdkVersion="32" />
<uses-permission
android:name="android.permission.WRITE_EXTERNAL_STORAGE"
android:maxSdkVersion="28" />
```
### Notification Permissions (Android 13+)
```xml
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
```
### Location Permissions
```xml
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
```
### NFC Permissions
```xml
<uses-permission android:name="android.permission.NFC" />
```
### Foreground Service Permissions
```xml
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
```
## Accompanist Permissions Library
### Setup
```gradle
dependencies {
implementation("com.google.accompanist:accompanist-permissions:0.36.0")
}
```
### Single Permission Pattern
```kotlin
import com.google.accompanist.permissions.ExperimentalPermissionsApi
import com.google.accompanist.permissions.rememberPermissionState
import com.google.accompanist.permissions.isGranted
import com.google.accompanist.permissions.shouldShowRationale
@OptIn(ExperimentalPermissionsApi::class)
@Composable
fun CameraFeature() {
val cameraPermissionState = rememberPermissionState(
Manifest.permission.CAMERA
)
when {
// Permission granted - show feature
cameraPermissionState.status.isGranted -> {
CameraPreview()
}
// Should show rationale - explain why permission is needed
cameraPermissionState.status.shouldShowRationale -> {
Column(
modifier = Modifier
.fillMaxSize()
.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Text(
text = "Camera permission is needed to scan QR codes for login",
style = MaterialTheme.typography.bodyLarge,
textAlign = TextAlign.Center
)
Spacer(modifier = Modifier.height(16.dp))
Button(
onClick = { cameraPermissionState.launchPermissionRequest() }
) {
Text("Grant Permission")
}
}
}
// First time - request permission
else -> {
Column(
modifier = Modifier
.fillMaxSize()
.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Button(
onClick = { cameraPermissionState.launchPermissionRequest() }
) {
Icon(Icons.Default.CameraAlt, contentDescription = null)
Spacer(modifier = Modifier.width(8.dp))
Text("Enable Camera")
}
}
}
}
}
```
### Multiple Permissions Pattern
```kotlin
@OptIn(ExperimentalPermissionsApi::class)
@Composable
fun MediaUploadFeature() {
val permissionsState = rememberMultiplePermissionsState(
permissions = buildList {
add(Manifest.permission.CAMERA)
if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.S_V2) {
add(Manifest.permission.READ_EXTERNAL_STORAGE)
}
}
)
when {
// All permissions granted
permissionsState.allPermissionsGranted -> {
MediaUploadUI()
}
// Some permissions need rationale
permissionsState.shouldShowRationale -> {
RationaleDialog(
title = "Permissions Required",
message = "Camera and storage access are needed to upload photos",
onConfirm = {
permissionsState.launchMultiplePermissionRequest()
},
onDismiss = { /* Handle dismissal */ }
)
}
// Request all permissions
else -> {
PermissionRequestScreen(
permissions = permissionsState.permissions,
onRequestPermissions = {
permissionsState.launchMultiplePermissionRequest()
}
)
}
}
}
@Composable
fun RationaleDialog(
title: String,
message: String,
onConfirm: () -> Unit,
onDismiss: () -> Unit
) {
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(title) },
text = { Text(message) },
confirmButton = {
TextButton(onClick = onConfirm) {
Text("Continue")
}
},
dismissButton = {
TextButton(onClick = onDismiss) {
Text("Cancel")
}
}
)
}
```
## Lifecycle-Aware Permission Requests
### Amethyst Pattern: POST_NOTIFICATIONS
**File:** `amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/LoggedInPage.kt`
```kotlin
@OptIn(ExperimentalPermissionsApi::class)
@Composable
fun NotificationRegistration(accountViewModel: AccountViewModel) {
val context = LocalContext.current
// Only request on Android 13+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
val notificationPermissionState = rememberPermissionState(
Manifest.permission.POST_NOTIFICATIONS
)
// Register for push notifications when permission is granted
if (notificationPermissionState.status.isGranted) {
LifecycleResumeEffect(
key1 = accountViewModel,
key2 = notificationPermissionState.status.isGranted
) {
val scope = rememberCoroutineScope()
scope.launch(Dispatchers.IO) {
PushNotificationUtils.checkAndInit(
context = context,
accountViewModel = accountViewModel
)
}
onPauseOrDispose {
// Cleanup when composable pauses or disposes
}
}
} else {
// Show prompt to enable notifications
NotificationPermissionPrompt(
onEnableClick = {
notificationPermissionState.launchPermissionRequest()
}
)
}
}
}
@Composable
fun NotificationPermissionPrompt(onEnableClick: () -> Unit) {
Card(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp)
) {
Column(
modifier = Modifier.padding(16.dp)
) {
Icon(
imageVector = Icons.Default.Notifications,
contentDescription = null,
modifier = Modifier.size(48.dp)
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "Enable Notifications",
style = MaterialTheme.typography.titleMedium
)
Text(
text = "Get notified when someone mentions you or replies to your posts",
style = MaterialTheme.typography.bodyMedium
)
Spacer(modifier = Modifier.height(16.dp))
Button(
onClick = onEnableClick,
modifier = Modifier.fillMaxWidth()
) {
Text("Enable Notifications")
}
}
}
}
```
## Permission Best Practices
### 1. Request Contextually
**Bad:**
```kotlin
// Requesting permission on app launch
@Composable
fun AppContent() {
val permissionState = rememberPermissionState(Manifest.permission.CAMERA)
LaunchedEffect(Unit) {
// DON'T DO THIS - user doesn't know why
permissionState.launchPermissionRequest()
}
}
```
**Good:**
```kotlin
// Request when user explicitly wants to use camera
@Composable
fun QRScannerButton() {
val permissionState = rememberPermissionState(Manifest.permission.CAMERA)
Button(
onClick = {
if (permissionState.status.isGranted) {
// Open scanner
} else {
// Request permission
permissionState.launchPermissionRequest()
}
}
) {
Text("Scan QR Code")
}
}
```
### 2. Show Rationale
```kotlin
@OptIn(ExperimentalPermissionsApi::class)
@Composable
fun LocationFeature() {
val locationPermissionState = rememberPermissionState(
Manifest.permission.ACCESS_COARSE_LOCATION
)
// Always show rationale first for sensitive permissions
if (!locationPermissionState.status.isGranted) {
LocationRationaleCard(
onEnableClick = {
locationPermissionState.launchPermissionRequest()
}
)
} else {
LocationMap()
}
}
@Composable
fun LocationRationaleCard(onEnableClick: () -> Unit) {
Card {
Column(modifier = Modifier.padding(16.dp)) {
Text(
text = "Why location access?",
style = MaterialTheme.typography.titleMedium
)
Text(
text = "Location is used for geohashing your posts. " +
"This helps other users discover local content. " +
"Your exact location is never shared.",
style = MaterialTheme.typography.bodyMedium
)
Spacer(modifier = Modifier.height(16.dp))
Row {
OutlinedButton(onClick = { /* Skip */ }) {
Text("Skip")
}
Spacer(modifier = Modifier.width(8.dp))
Button(onClick = onEnableClick) {
Text("Enable")
}
}
}
}
}
```
### 3. Handle Permanent Denial
```kotlin
@OptIn(ExperimentalPermissionsApi::class)
@Composable
fun CameraFeatureWithSettings() {
val context = LocalContext.current
val cameraPermissionState = rememberPermissionState(
Manifest.permission.CAMERA
)
when {
cameraPermissionState.status.isGranted -> {
CameraPreview()
}
cameraPermissionState.status.shouldShowRationale -> {
// User denied once, show rationale
RationaleDialog(
onConfirm = { cameraPermissionState.launchPermissionRequest() }
)
}
else -> {
// Might be permanently denied - offer settings
PermanentlyDeniedDialog(
onOpenSettings = {
val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply {
data = Uri.fromParts("package", context.packageName, null)
}
context.startActivity(intent)
}
)
}
}
}
@Composable
fun PermanentlyDeniedDialog(onOpenSettings: () -> Unit) {
AlertDialog(
onDismissRequest = { },
title = { Text("Permission Denied") },
text = {
Text(
"Camera permission is required for QR scanning. " +
"Please enable it in Settings."
)
},
confirmButton = {
TextButton(onClick = onOpenSettings) {
Text("Open Settings")
}
},
dismissButton = {
TextButton(onClick = { /* Cancel */ }) {
Text("Cancel")
}
}
)
}
```
### 4. Version-Specific Permissions
```kotlin
@Composable
fun StoragePermissionRequest() {
val permissions = remember {
buildList {
when {
Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU -> {
add(Manifest.permission.READ_MEDIA_IMAGES)
add(Manifest.permission.READ_MEDIA_VIDEO)
}
Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q -> {
// Android 10-12: No permission needed for scoped storage
}
else -> {
// Android 9 and below
add(Manifest.permission.READ_EXTERNAL_STORAGE)
add(Manifest.permission.WRITE_EXTERNAL_STORAGE)
}
}
}
}
if (permissions.isNotEmpty()) {
val permissionsState = rememberMultiplePermissionsState(permissions)
if (!permissionsState.allPermissionsGranted) {
StoragePermissionUI(
onRequest = { permissionsState.launchMultiplePermissionRequest() }
)
} else {
MediaPickerUI()
}
} else {
// No permission needed
MediaPickerUI()
}
}
```
## Permission Groups
### Camera + Storage (Media Upload)
```kotlin
@OptIn(ExperimentalPermissionsApi::class)
@Composable
fun MediaCaptureFeature() {
val mediaPermissions = buildList {
add(Manifest.permission.CAMERA)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
add(Manifest.permission.READ_MEDIA_IMAGES)
} else if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.S_V2) {
add(Manifest.permission.READ_EXTERNAL_STORAGE)
}
}
val permissionsState = rememberMultiplePermissionsState(mediaPermissions)
when {
permissionsState.allPermissionsGranted -> {
MediaCaptureUI()
}
else -> {
MediaPermissionScreen(
permissions = permissionsState.permissions,
onRequest = { permissionsState.launchMultiplePermissionRequest() }
)
}
}
}
```
### Audio + Storage (Voice Recording)
```kotlin
@OptIn(ExperimentalPermissionsApi::class)
@Composable
fun VoiceRecordingFeature() {
val audioPermissions = buildList {
add(Manifest.permission.RECORD_AUDIO)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
add(Manifest.permission.READ_MEDIA_AUDIO)
} else if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.S_V2) {
add(Manifest.permission.READ_EXTERNAL_STORAGE)
}
}
val permissionsState = rememberMultiplePermissionsState(audioPermissions)
if (permissionsState.allPermissionsGranted) {
AudioRecorderUI()
} else {
AudioPermissionScreen(
onRequest = { permissionsState.launchMultiplePermissionRequest() }
)
}
}
```
## Testing Permissions
### Grant Permission in Tests
```kotlin
@get:Rule
val permissionRule = GrantPermissionRule.grant(
Manifest.permission.CAMERA,
Manifest.permission.READ_EXTERNAL_STORAGE
)
@Test
fun testCameraFeatureWithPermission() {
composeTestRule.setContent {
CameraFeature()
}
// Permission is already granted by rule
composeTestRule.onNodeWithText("Take Photo").assertExists()
}
```
### Test Permission Request Flow
```kotlin
@Test
fun testPermissionRequestFlow() {
composeTestRule.setContent {
CameraFeature()
}
// Initially shows permission request button
composeTestRule.onNodeWithText("Enable Camera").assertExists()
// Click to request
composeTestRule.onNodeWithText("Enable Camera").performClick()
// System permission dialog appears (can't test dialog itself)
// Would need UiAutomator to interact with system dialog
}
```
## Permission State Checking
### Check Permission Before Action
```kotlin
fun checkAndRequestCameraPermission(
context: Context,
permissionState: PermissionState,
onGranted: () -> Unit
) {
when {
permissionState.status.isGranted -> {
onGranted()
}
permissionState.status.shouldShowRationale -> {
// Show rationale dialog
}
else -> {
permissionState.launchPermissionRequest()
}
}
}
```
### Manual Permission Check (Non-Compose)
```kotlin
fun hasCameraPermission(context: Context): Boolean {
return ContextCompat.checkSelfPermission(
context,
Manifest.permission.CAMERA
) == PackageManager.PERMISSION_GRANTED
}
fun requestCameraPermission(activity: ComponentActivity) {
ActivityCompat.requestPermissions(
activity,
arrayOf(Manifest.permission.CAMERA),
REQUEST_CAMERA_PERMISSION
)
}
// In Activity
override fun onRequestPermissionsResult(
requestCode: Int,
permissions: Array<out String>,
grantResults: IntArray
) {
super.onRequestPermissionsResult(requestCode, permissions, grantResults)
when (requestCode) {
REQUEST_CAMERA_PERMISSION -> {
if (grantResults.isNotEmpty() && grantResults[0] == PackageManager.PERMISSION_GRANTED) {
// Permission granted
} else {
// Permission denied
}
}
}
}
companion object {
private const val REQUEST_CAMERA_PERMISSION = 100
}
```
## File Locations
- `amethyst/src/main/AndroidManifest.xml` - Permission declarations
- `amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/LoggedInPage.kt` - Notification permission pattern
- `amethyst/build.gradle` - Accompanist dependency
## Resources
- [Accompanist Permissions Documentation](https://google.github.io/accompanist/permissions/)
- [Android Permissions Guide](https://developer.android.com/guide/topics/permissions/overview)
- [Request Runtime Permissions](https://developer.android.com/training/permissions/requesting)
@@ -0,0 +1,468 @@
# Proguard Rules for Amethyst
Proguard configuration for optimizing and obfuscating Android APK while preserving necessary code.
## What is Proguard/R8?
**R8** is Android's default code shrinker and obfuscator (replaced Proguard in AGP 3.4.0+). It:
- **Shrinks** code by removing unused classes/methods
- **Obfuscates** code by renaming classes/methods to short names
- **Optimizes** code by inlining methods and removing dead code
## Amethyst Proguard Configuration
**File:** `amethyst/proguard-rules.pro`
### Keep Kotlin Metadata
```proguard
# Kotlin metadata is required for reflection
-keep class kotlin.Metadata { *; }
-keep class kotlin.** { *; }
-dontwarn kotlin.**
# Kotlin serialization
-keepattributes *Annotation*, InnerClasses
-dontnote kotlinx.serialization.AnnotationsKt
-dontnote kotlinx.serialization.SerializationKt
-keep,includedescriptorclasses class com.vitorpamplona.**$$serializer { *; }
-keepclassmembers class com.vitorpamplona.** {
*** Companion;
}
-keepclasseswithmembers class com.vitorpamplona.** {
kotlinx.serialization.KSerializer serializer(...);
}
```
### Keep Nostr Event Classes
```proguard
# Nostr events are serialized/deserialized
-keep class com.vitorpamplona.quartz.events.** { *; }
-keep class com.vitorpamplona.quartz.encoders.** { *; }
# Keep event builders
-keep class com.vitorpamplona.quartz.builders.** { *; }
# Keep tag classes
-keep class com.vitorpamplona.quartz.nip01Core.tags.** { *; }
```
### Keep Data Classes
```proguard
# Data classes used in ViewModels and serialization
-keep @kotlinx.serialization.Serializable class * { *; }
# Keep all data classes
-keep class com.vitorpamplona.amethyst.model.** { *; }
-keep class com.vitorpamplona.amethyst.service.model.** { *; }
```
### Keep Compose Classes
```proguard
# Jetpack Compose
-keep class androidx.compose.** { *; }
-dontwarn androidx.compose.**
# Compose runtime
-keep class androidx.compose.runtime.** { *; }
# Compose UI
-keep class androidx.compose.ui.** { *; }
# Material3
-keep class androidx.compose.material3.** { *; }
# Navigation Compose - Keep serializable routes
-keep class * implements java.io.Serializable { *; }
-keepclassmembers class * implements java.io.Serializable {
static final long serialVersionUID;
private static final java.io.ObjectStreamField[] serialPersistentFields;
!static !transient <fields>;
private void writeObject(java.io.ObjectOutputStream);
private void readObject(java.io.ObjectInputStream);
java.lang.Object writeReplace();
java.lang.Object readResolve();
}
```
### Keep OkHttp/Retrofit
```proguard
# OkHttp
-dontwarn okhttp3.**
-dontwarn okio.**
-keep class okhttp3.** { *; }
-keep class okio.** { *; }
# OkHttp WebSockets (for Nostr relays)
-keep class okhttp3.internal.ws.** { *; }
# Retrofit (if used)
-keepattributes Signature
-keepattributes Exceptions
-keep class retrofit2.** { *; }
```
### Keep Jackson (JSON)
```proguard
# Jackson JSON library
-keep class com.fasterxml.jackson.** { *; }
-keep class org.codehaus.** { *; }
-keepclassmembers class * {
@com.fasterxml.jackson.annotation.* <methods>;
}
# Jackson polymorphic types
-keepattributes RuntimeVisibleAnnotations
-keep @com.fasterxml.jackson.annotation.JsonTypeInfo class *
```
### Keep Secp256k1 (Crypto)
```proguard
# Secp256k1 native library
-keep class fr.acinq.secp256k1.** { *; }
# Keep native methods
-keepclasseswithmembernames class * {
native <methods>;
}
```
### Keep Tor
```proguard
# Tor library
-keep class com.msopentech.thali.toronionproxy.** { *; }
-dontwarn com.msopentech.thali.toronionproxy.**
```
### Keep ExoPlayer (Media)
```proguard
# ExoPlayer (Media3)
-keep class androidx.media3.** { *; }
-dontwarn androidx.media3.**
-keep class com.google.android.exoplayer2.** { *; }
-dontwarn com.google.android.exoplayer2.**
```
### Keep Coil (Image Loading)
```proguard
# Coil image loading
-keep class coil.** { *; }
-keep class coil3.** { *; }
-dontwarn coil.**
-dontwarn coil3.**
```
### Keep ViewModels
```proguard
# ViewModel classes
-keep class * extends androidx.lifecycle.ViewModel {
<init>();
}
# ViewModel factories
-keep class * extends androidx.lifecycle.ViewModelProvider$Factory {
<init>(...);
}
# Keep ViewModel constructors for reflection
-keepclassmembers class * extends androidx.lifecycle.ViewModel {
<init>(...);
}
```
### Keep Parcelable
```proguard
# Parcelable
-keep class * implements android.os.Parcelable {
public static final android.os.Parcelable$Creator *;
}
-keepclassmembers class * implements android.os.Parcelable {
public <fields>;
private <fields>;
}
```
### Keep Enums
```proguard
# Enums
-keepclassmembers enum * {
public static **[] values();
public static ** valueOf(java.lang.String);
}
```
### Remove Logging (Production)
```proguard
# Remove debug logging in release builds
-assumenosideeffects class android.util.Log {
public static *** d(...);
public static *** v(...);
public static *** i(...);
}
# Keep error/warning logs
-assumenosideeffects class android.util.Log {
public static *** e(...) return false;
public static *** w(...) return false;
}
```
### Keep Crashlytics/Firebase
```proguard
# Firebase Crashlytics
-keepattributes SourceFile,LineNumberTable
-keep public class * extends java.lang.Exception
# Firebase
-keep class com.google.firebase.** { *; }
-dontwarn com.google.firebase.**
```
## Build Configuration
### Enable R8 in build.gradle
```gradle
android {
buildTypes {
release {
minifyEnabled = true
shrinkResources = true
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
}
debug {
minifyEnabled = false
}
}
}
```
### Multiple Proguard Files
```gradle
android {
buildTypes {
release {
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro",
"proguard-quartz.pro", // Library-specific rules
"proguard-compose.pro" // Compose-specific rules
)
}
}
}
```
## Debugging Proguard Issues
### Generate Mapping File
R8 generates `mapping.txt` in `app/build/outputs/mapping/release/`:
```
# Original class name -> Obfuscated name
com.vitorpamplona.amethyst.ui.MainActivity -> a.b.c:
void onCreate(Bundle) -> a
```
### Deobfuscate Stack Traces
```bash
# Using retrace (part of Android SDK)
retrace.sh mapping.txt stacktrace.txt
```
### Enable Proguard Output
```gradle
android {
buildTypes {
release {
proguardFiles(...)
// Generate reports
postprocessing {
proguardFiles = [...]
obfuscate = true
optimizeCode = true
removeUnusedCode = true
}
}
}
}
```
**Output files:**
- `build/outputs/mapping/release/configuration.txt` - All Proguard rules applied
- `build/outputs/mapping/release/mapping.txt` - Obfuscation mappings
- `build/outputs/mapping/release/seeds.txt` - Classes kept by `-keep` rules
- `build/outputs/mapping/release/usage.txt` - Code removed by R8
### Test Release Build
```bash
./gradlew assembleRelease
# Install and test
adb install app/build/outputs/apk/release/app-release.apk
```
## Common Issues
### Issue: NoSuchMethodException at Runtime
**Cause:** Proguard removed or renamed a method used via reflection.
**Solution:**
```proguard
-keep class com.example.YourClass {
public <methods>;
}
```
### Issue: Serialization Fails
**Cause:** Data class fields were renamed.
**Solution:**
```proguard
-keep @kotlinx.serialization.Serializable class * { *; }
-keepclassmembers class * {
@kotlinx.serialization.SerialName <fields>;
}
```
### Issue: Compose Navigation Crashes
**Cause:** @Serializable route classes were obfuscated.
**Solution:**
```proguard
# Keep all route classes
-keep @kotlinx.serialization.Serializable class com.vitorpamplona.amethyst.ui.navigation.routes.** { *; }
```
### Issue: Native Library Crashes
**Cause:** Native method signatures were changed.
**Solution:**
```proguard
-keepclasseswithmembernames class * {
native <methods>;
}
```
## Optimization Tips
### 1. Keep Only What's Necessary
Don't use broad wildcards:
```proguard
# Bad - keeps everything
-keep class com.vitorpamplona.** { *; }
# Good - keeps only specific packages
-keep class com.vitorpamplona.quartz.events.** { *; }
```
### 2. Test Thoroughly
- Test all app features after enabling Proguard
- Test deep links and navigation
- Test serialization/deserialization
- Test external library integrations
### 3. Use AGP's Proguard Analysis
```gradle
android {
buildTypes {
release {
// Generate R8 configuration
android.debug.obsoleteApi = true
}
}
}
```
### 4. Analyze APK Size
```bash
# Build release APK
./gradlew assembleRelease
# Analyze APK with Android Studio
# Build > Analyze APK > Select app-release.apk
```
See `scripts/analyze-apk-size.sh` for automated analysis.
## Product Flavor Specific Rules
### Play Flavor (Firebase)
```proguard
# proguard-play.pro
-keep class com.google.firebase.** { *; }
-keep class com.google.android.gms.** { *; }
```
### F-Droid Flavor (No Google Services)
```proguard
# proguard-fdroid.pro
# UnifiedPush
-keep class org.unifiedpush.** { *; }
```
**Configure in build.gradle:**
```gradle
android {
flavorDimensions = ["channel"]
productFlavors {
create("play") {
dimension = "channel"
proguardFiles("proguard-play.pro")
}
create("fdroid") {
dimension = "channel"
proguardFiles("proguard-fdroid.pro")
}
}
}
```
## File Locations
- `amethyst/proguard-rules.pro` - Main Proguard rules
- `amethyst/build/outputs/mapping/release/` - Proguard output files
- `amethyst/build.gradle` - Proguard configuration
## Resources
- [Android R8 Documentation](https://developer.android.com/build/shrink-code)
- [Proguard Manual](https://www.guardsquare.com/manual/configuration)
- [Kotlinx Serialization Proguard](https://github.com/Kotlin/kotlinx.serialization#android)
+230
View File
@@ -0,0 +1,230 @@
#!/bin/bash
#
# APK Size Analysis Script for Amethyst
#
# Usage:
# ./analyze-apk-size.sh [apk-path]
#
# If no APK path provided, uses latest release build
set -e
# Colors
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m' # No Color
# Default APK path
DEFAULT_APK="amethyst/build/outputs/apk/release/amethyst-release.apk"
APK_PATH="${1:-$DEFAULT_APK}"
# Check if APK exists
if [ ! -f "$APK_PATH" ]; then
echo -e "${RED}Error: APK not found at $APK_PATH${NC}"
echo "Build the APK first: ./gradlew :amethyst:assembleRelease"
exit 1
fi
echo -e "${BLUE}═══════════════════════════════════════════════════════${NC}"
echo -e "${BLUE} Amethyst APK Size Analysis${NC}"
echo -e "${BLUE}═══════════════════════════════════════════════════════${NC}"
echo
# APK basic info
echo -e "${GREEN}APK Path:${NC} $APK_PATH"
APK_SIZE=$(du -h "$APK_PATH" | cut -f1)
APK_SIZE_BYTES=$(stat -f%z "$APK_PATH" 2>/dev/null || stat -c%s "$APK_PATH" 2>/dev/null)
echo -e "${GREEN}APK Size:${NC} $APK_SIZE ($APK_SIZE_BYTES bytes)"
echo
# Extract APK to temp directory
TEMP_DIR=$(mktemp -d)
echo -e "${YELLOW}Extracting APK to $TEMP_DIR...${NC}"
unzip -q "$APK_PATH" -d "$TEMP_DIR"
# Analyze APK contents
echo
echo -e "${BLUE}═══════════════════════════════════════════════════════${NC}"
echo -e "${BLUE} Top-Level Contents${NC}"
echo -e "${BLUE}═══════════════════════════════════════════════════════${NC}"
du -sh "$TEMP_DIR"/* | sort -hr
# Analyze DEX files
echo
echo -e "${BLUE}═══════════════════════════════════════════════════════${NC}"
echo -e "${BLUE} DEX Files (Code)${NC}"
echo -e "${BLUE}═══════════════════════════════════════════════════════${NC}"
DEX_TOTAL=0
for dex in "$TEMP_DIR"/*.dex; do
if [ -f "$dex" ]; then
DEX_NAME=$(basename "$dex")
DEX_SIZE=$(stat -f%z "$dex" 2>/dev/null || stat -c%s "$dex" 2>/dev/null)
DEX_SIZE_MB=$(echo "scale=2; $DEX_SIZE / 1024 / 1024" | bc)
echo -e " $DEX_NAME: ${GREEN}${DEX_SIZE_MB} MB${NC}"
DEX_TOTAL=$((DEX_TOTAL + DEX_SIZE))
fi
done
DEX_TOTAL_MB=$(echo "scale=2; $DEX_TOTAL / 1024 / 1024" | bc)
echo -e "${YELLOW}Total DEX: $DEX_TOTAL_MB MB${NC}"
# Analyze resources
echo
echo -e "${BLUE}═══════════════════════════════════════════════════════${NC}"
echo -e "${BLUE} Resources${NC}"
echo -e "${BLUE}═══════════════════════════════════════════════════════${NC}"
if [ -d "$TEMP_DIR/res" ]; then
RES_SIZE=$(du -sh "$TEMP_DIR/res" | cut -f1)
echo -e " res/: ${GREEN}$RES_SIZE${NC}"
# Top resource folders
echo " Top resource folders:"
du -sh "$TEMP_DIR/res"/* | sort -hr | head -10 | sed 's/^/ /'
fi
# Analyze assets
echo
if [ -d "$TEMP_DIR/assets" ]; then
ASSETS_SIZE=$(du -sh "$TEMP_DIR/assets" | cut -f1)
echo -e " assets/: ${GREEN}$ASSETS_SIZE${NC}"
# Top asset files
echo " Top asset files:"
find "$TEMP_DIR/assets" -type f -exec du -h {} \; | sort -hr | head -10 | sed 's/^/ /'
fi
# Analyze native libraries
echo
echo -e "${BLUE}═══════════════════════════════════════════════════════${NC}"
echo -e "${BLUE} Native Libraries${NC}"
echo -e "${BLUE}═══════════════════════════════════════════════════════${NC}"
if [ -d "$TEMP_DIR/lib" ]; then
LIB_SIZE=$(du -sh "$TEMP_DIR/lib" | cut -f1)
echo -e " lib/: ${GREEN}$LIB_SIZE${NC}"
# By architecture
for arch in "$TEMP_DIR/lib"/*; do
if [ -d "$arch" ]; then
ARCH_NAME=$(basename "$arch")
ARCH_SIZE=$(du -sh "$arch" | cut -f1)
echo -e " $ARCH_NAME: ${GREEN}$ARCH_SIZE${NC}"
# Top libraries in architecture
find "$arch" -type f -name "*.so" -exec du -h {} \; | sort -hr | head -5 | sed 's/^/ /'
fi
done
else
echo " No native libraries"
fi
# Analyze Kotlin metadata
echo
echo -e "${BLUE}═══════════════════════════════════════════════════════${NC}"
echo -e "${BLUE} Kotlin Metadata${NC}"
echo -e "${BLUE}═══════════════════════════════════════════════════════${NC}"
if [ -d "$TEMP_DIR/kotlin" ]; then
KOTLIN_SIZE=$(du -sh "$TEMP_DIR/kotlin" | cut -f1)
echo -e " kotlin/: ${GREEN}$KOTLIN_SIZE${NC}"
fi
# Method count (if dexdump available)
echo
echo -e "${BLUE}═══════════════════════════════════════════════════════${NC}"
echo -e "${BLUE} Method Count${NC}"
echo -e "${BLUE}═══════════════════════════════════════════════════════${NC}"
# Try to find dexdump
DEXDUMP=$(which dexdump 2>/dev/null || find "$ANDROID_HOME/build-tools" -name dexdump 2>/dev/null | head -1 || echo "")
if [ -n "$DEXDUMP" ] && [ -x "$DEXDUMP" ]; then
TOTAL_METHODS=0
for dex in "$TEMP_DIR"/*.dex; do
if [ -f "$dex" ]; then
DEX_NAME=$(basename "$dex")
METHOD_COUNT=$("$DEXDUMP" -l xml "$dex" | grep -c "<method " || echo "0")
echo -e " $DEX_NAME: ${GREEN}$METHOD_COUNT methods${NC}"
TOTAL_METHODS=$((TOTAL_METHODS + METHOD_COUNT))
fi
done
echo -e "${YELLOW}Total methods: $TOTAL_METHODS${NC}"
# Check multidex threshold
if [ $TOTAL_METHODS -gt 65536 ]; then
echo -e "${RED} ⚠ Exceeded 64K method limit (multidex required)${NC}"
else
REMAINING=$((65536 - TOTAL_METHODS))
echo -e "${GREEN} ✓ Below 64K limit ($REMAINING methods remaining)${NC}"
fi
else
echo -e "${YELLOW} dexdump not found - cannot analyze method count${NC}"
echo " Set ANDROID_HOME or install Android SDK build-tools"
fi
# Size breakdown percentages
echo
echo -e "${BLUE}═══════════════════════════════════════════════════════${NC}"
echo -e "${BLUE} Size Breakdown${NC}"
echo -e "${BLUE}═══════════════════════════════════════════════════════${NC}"
# Calculate percentages
CODE_SIZE=$(du -s "$TEMP_DIR"/*.dex 2>/dev/null | awk '{sum+=$1} END {print sum}' || echo "0")
RES_SIZE_BYTES=$(du -s "$TEMP_DIR/res" 2>/dev/null | awk '{print $1}' || echo "0")
ASSETS_SIZE_BYTES=$(du -s "$TEMP_DIR/assets" 2>/dev/null | awk '{print $1}' || echo "0")
LIB_SIZE_BYTES=$(du -s "$TEMP_DIR/lib" 2>/dev/null | awk '{print $1}' || echo "0")
# Convert to KB for bc
APK_SIZE_KB=$((APK_SIZE_BYTES / 1024))
CODE_PCT=$(echo "scale=1; $CODE_SIZE * 100 / $APK_SIZE_KB" | bc 2>/dev/null || echo "0")
RES_PCT=$(echo "scale=1; $RES_SIZE_BYTES * 100 / $APK_SIZE_KB" | bc 2>/dev/null || echo "0")
ASSETS_PCT=$(echo "scale=1; $ASSETS_SIZE_BYTES * 100 / $APK_SIZE_KB" | bc 2>/dev/null || echo "0")
LIB_PCT=$(echo "scale=1; $LIB_SIZE_BYTES * 100 / $APK_SIZE_KB" | bc 2>/dev/null || echo "0")
echo -e " Code (DEX): ${CODE_PCT}%"
echo -e " Resources: ${RES_PCT}%"
echo -e " Assets: ${ASSETS_PCT}%"
echo -e " Native libs: ${LIB_PCT}%"
# Recommendations
echo
echo -e "${BLUE}═══════════════════════════════════════════════════════${NC}"
echo -e "${BLUE} Optimization Recommendations${NC}"
echo -e "${BLUE}═══════════════════════════════════════════════════════${NC}"
# Check if resources are large
if [ $(echo "$RES_PCT > 30" | bc 2>/dev/null || echo "0") -eq 1 ]; then
echo -e "${YELLOW} • Resources are ${RES_PCT}% of APK - consider:${NC}"
echo " - Enable resource shrinking (shrinkResources = true)"
echo " - Use WebP for images instead of PNG/JPG"
echo " - Remove unused resources"
fi
# Check if native libs are large
if [ $(echo "$LIB_PCT > 40" | bc 2>/dev/null || echo "0") -eq 1 ]; then
echo -e "${YELLOW} • Native libraries are ${LIB_PCT}% of APK - consider:${NC}"
echo " - Use App Bundle to serve ABI-specific APKs"
echo " - Remove unused ABIs"
fi
# Check if code is large
if [ $(echo "$CODE_PCT > 40" | bc 2>/dev/null || echo "0") -eq 1 ]; then
echo -e "${YELLOW} • Code is ${CODE_PCT}% of APK - consider:${NC}"
echo " - Enable Proguard/R8 (minifyEnabled = true)"
echo " - Review dependencies for bloat"
echo " - Enable code shrinking"
fi
# General recommendations
echo -e "${GREEN} General optimizations:${NC}"
echo " - Use Android App Bundle (.aab) instead of APK"
echo " - Enable R8 optimization (minifyEnabled = true)"
echo " - Enable resource shrinking (shrinkResources = true)"
echo " - Analyze with APK Analyzer in Android Studio"
# Cleanup
echo
echo -e "${YELLOW}Cleaning up temporary files...${NC}"
rm -rf "$TEMP_DIR"
echo
echo -e "${GREEN}✓ Analysis complete${NC}"
+339
View File
@@ -0,0 +1,339 @@
# Compose Desktop Skill
## Desktop Application Entry Point
```kotlin
// desktopApp/src/jvmMain/kotlin/Main.kt
package com.vitorpamplona.amethyst.desktop
import androidx.compose.ui.Alignment
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.*
fun main() = application {
val windowState = rememberWindowState(
width = 1200.dp,
height = 800.dp,
position = WindowPosition.Aligned(Alignment.Center)
)
// System tray
Tray(
icon = painterResource("icon.png"),
tooltip = "Amethyst",
menu = {
Item("Show", onClick = { windowState.isMinimized = false })
Separator()
Item("Exit", onClick = ::exitApplication)
}
)
Window(
onCloseRequest = ::exitApplication,
state = windowState,
title = "Amethyst",
icon = painterResource("icon.png")
) {
MenuBar {
Menu("File") {
Item("New Note", shortcut = KeyShortcut(Key.N, ctrl = true)) { }
Separator()
Item("Settings", shortcut = KeyShortcut(Key.Comma, ctrl = true)) { }
Separator()
Item("Quit", shortcut = KeyShortcut(Key.Q, ctrl = true), onClick = ::exitApplication)
}
Menu("Edit") {
Item("Copy", shortcut = KeyShortcut(Key.C, ctrl = true)) { }
Item("Paste", shortcut = KeyShortcut(Key.V, ctrl = true)) { }
}
Menu("View") {
Item("Feed") { }
Item("Messages") { }
Item("Notifications") { }
}
Menu("Help") {
Item("About Amethyst") { }
}
}
App()
}
}
```
## Desktop-Specific Components
### File Dialog
```kotlin
@Composable
fun rememberFileDialog(): FileDialogState {
return remember { FileDialogState() }
}
class FileDialogState {
var isOpen by mutableStateOf(false)
var result by mutableStateOf<File?>(null)
fun open() { isOpen = true }
@Composable
fun Dialog(
title: String = "Select File",
allowedExtensions: List<String> = emptyList()
) {
if (isOpen) {
DisposableEffect(Unit) {
val dialog = java.awt.FileDialog(null as java.awt.Frame?, title)
if (allowedExtensions.isNotEmpty()) {
dialog.setFilenameFilter { _, name ->
allowedExtensions.any { name.endsWith(it) }
}
}
dialog.isVisible = true
result = dialog.file?.let { File(dialog.directory, it) }
isOpen = false
onDispose { }
}
}
}
}
```
### Scroll Behavior
```kotlin
@Composable
fun DesktopScrollableColumn(
modifier: Modifier = Modifier,
content: @Composable ColumnScope.() -> Unit
) {
val scrollState = rememberScrollState()
Box(modifier) {
Column(
modifier = Modifier
.verticalScroll(scrollState)
.fillMaxSize()
) {
content()
}
VerticalScrollbar(
modifier = Modifier.align(Alignment.CenterEnd),
adapter = rememberScrollbarAdapter(scrollState)
)
}
}
```
### Keyboard Navigation
```kotlin
@Composable
fun KeyboardNavigableList(
items: List<Note>,
selectedIndex: Int,
onSelect: (Int) -> Unit,
onActivate: (Note) -> Unit
) {
val focusRequester = remember { FocusRequester() }
LaunchedEffect(Unit) {
focusRequester.requestFocus()
}
LazyColumn(
modifier = Modifier
.focusRequester(focusRequester)
.focusable()
.onKeyEvent { event ->
when {
event.key == Key.DirectionDown && event.type == KeyEventType.KeyDown -> {
onSelect((selectedIndex + 1).coerceAtMost(items.lastIndex))
true
}
event.key == Key.DirectionUp && event.type == KeyEventType.KeyDown -> {
onSelect((selectedIndex - 1).coerceAtLeast(0))
true
}
event.key == Key.Enter && event.type == KeyEventType.KeyDown -> {
items.getOrNull(selectedIndex)?.let { onActivate(it) }
true
}
else -> false
}
}
) {
itemsIndexed(items) { index, note ->
NoteCard(
note = note,
isSelected = index == selectedIndex,
modifier = Modifier.clickable { onSelect(index) }
)
}
}
}
```
### Multi-Window Support
```kotlin
@Composable
fun ApplicationScope.NoteDetailWindow(
note: Note,
onClose: () -> Unit
) {
Window(
onCloseRequest = onClose,
title = "Note by ${note.author.name}",
state = rememberWindowState(width = 600.dp, height = 400.dp)
) {
NoteDetailScreen(note)
}
}
// Usage in main application
var openNotes by remember { mutableStateOf<List<Note>>(emptyList()) }
openNotes.forEach { note ->
key(note.id) {
NoteDetailWindow(
note = note,
onClose = { openNotes = openNotes - note }
)
}
}
```
### Tooltips
```kotlin
@Composable
fun TooltipButton(
tooltip: String,
onClick: () -> Unit,
content: @Composable () -> Unit
) {
TooltipArea(
tooltip = {
Surface(
shape = RoundedCornerShape(4.dp),
color = MaterialTheme.colorScheme.inverseSurface
) {
Text(
text = tooltip,
modifier = Modifier.padding(8.dp),
color = MaterialTheme.colorScheme.inverseOnSurface
)
}
}
) {
IconButton(onClick = onClick) {
content()
}
}
}
```
## Desktop Layout Pattern
```kotlin
@Composable
fun DesktopAppLayout(
currentScreen: Screen,
onNavigate: (Screen) -> Unit,
content: @Composable () -> Unit
) {
Row(Modifier.fillMaxSize()) {
// Sidebar navigation
NavigationRail(
modifier = Modifier.width(72.dp),
containerColor = MaterialTheme.colorScheme.surfaceVariant
) {
Spacer(Modifier.height(16.dp))
NavigationRailItem(
icon = { Icon(Icons.Default.Home, "Feed") },
label = { Text("Feed") },
selected = currentScreen == Screen.Feed,
onClick = { onNavigate(Screen.Feed) }
)
NavigationRailItem(
icon = { Icon(Icons.Default.Email, "Messages") },
label = { Text("DMs") },
selected = currentScreen == Screen.Messages,
onClick = { onNavigate(Screen.Messages) }
)
NavigationRailItem(
icon = { Icon(Icons.Default.Notifications, "Notifications") },
label = { Text("Alerts") },
selected = currentScreen == Screen.Notifications,
onClick = { onNavigate(Screen.Notifications) }
)
Spacer(Modifier.weight(1f))
NavigationRailItem(
icon = { Icon(Icons.Default.Settings, "Settings") },
label = { Text("Settings") },
selected = currentScreen == Screen.Settings,
onClick = { onNavigate(Screen.Settings) }
)
}
// Divider
VerticalDivider()
// Main content
Box(Modifier.weight(1f).fillMaxHeight()) {
content()
}
}
}
```
## Build Configuration
```kotlin
// desktopApp/build.gradle.kts
plugins {
kotlin("jvm")
id("org.jetbrains.compose")
}
dependencies {
implementation(compose.desktop.currentOs)
implementation(compose.material3)
implementation(project(":quartz"))
}
compose.desktop {
application {
mainClass = "com.vitorpamplona.amethyst.desktop.MainKt"
nativeDistributions {
targetFormats(
org.jetbrains.compose.desktop.application.dsl.TargetFormat.Dmg,
org.jetbrains.compose.desktop.application.dsl.TargetFormat.Msi,
org.jetbrains.compose.desktop.application.dsl.TargetFormat.Deb
)
packageName = "Amethyst"
packageVersion = "1.0.0"
macOS {
bundleID = "com.vitorpamplona.amethyst.desktop"
iconFile.set(project.file("icons/icon.icns"))
}
windows {
iconFile.set(project.file("icons/icon.ico"))
menuGroup = "Amethyst"
}
linux {
iconFile.set(project.file("icons/icon.png"))
}
}
}
}
```
+577
View File
@@ -0,0 +1,577 @@
---
name: compose-expert
description: Advanced Compose Multiplatform UI patterns for shared composables. Use when working with visual UI components, state management patterns (remember, derivedStateOf, produceState), recomposition optimization (@Stable/@Immutable visual usage), Material3 theming, custom ImageVector icons, or determining whether to share UI in commonMain vs keep platform-specific. Delegates navigation to android-expert/desktop-expert. Complements kotlin-expert (handles Kotlin language aspects of state/annotations).
---
# Compose Multiplatform Expert
Visual UI patterns for sharing composables across Android and Desktop.
## When to Use This Skill
- Creating or refactoring shared UI components
- Deciding whether to share UI in `commonMain` or keep platform-specific
- Building custom ImageVector icons (robohash pattern)
- State management: remember, derivedStateOf, produceState
- Recomposition optimization: visual usage of @Stable/@Immutable
- Material3 theming and styling
- Performance: lazy lists, image loading
**Delegate to other skills:**
- Navigation structure → `android-expert`, `desktop-expert`
- Kotlin state patterns (StateFlow, sealed classes) → `kotlin-expert`
- Build configuration → `gradle-expert`
## Philosophy: Share by Default
**Default to `commons/commonMain`** unless platform experts indicate otherwise.
### Always Share
- **UI components**: Buttons, cards, lists, dialogs, inputs
- **State visualization**: Loading, empty, error states
- **Custom icons**: ImageVector assets (robohash, custom paths)
- **Theme utilities**: Color calculations, style helpers
- **Material3 components**: Any UI using Material primitives
### Keep Platform-Specific
- **Navigation structure**: Bottom nav (Android) vs Sidebar (Desktop)
- **Screen layouts**: Platform-specific scaffolding
- **System integrations**: File pickers, notifications, share sheets
- **Platform UX**: Gestures, keyboard shortcuts, window management
### Decision Framework
1. **Uses only Material3 primitives?** → Share in `commonMain`
2. **Requires platform system APIs?** → Platform-specific
3. **Pure visual component without navigation?** → Share in `commonMain`
4. **Needs platform UX patterns?** → Ask `android-expert` or `desktop-expert`
If uncertain, **default to sharing** - easier to split later than merge.
## Shared Composable Anatomy
### Structure
```kotlin
@Composable
fun SharedComponent(
// State parameters (read-only)
data: DataClass,
isLoading: Boolean,
// Event parameters (write-only)
onAction: () -> Unit,
// Visual parameters
modifier: Modifier = Modifier,
// Optional customization
colors: ComponentColors = ComponentDefaults.colors()
) {
// Implementation
}
```
**Pattern**: State down, events up
- Parameters above modifier = required state/events
- `modifier` parameter = layout control
- Parameters below modifier = optional customization
### Example: AddButton
```kotlin
@Composable
fun AddButton(
onClick: () -> Unit,
modifier: Modifier = Modifier,
text: String = "Add",
enabled: Boolean = true
) {
OutlinedButton(
modifier = modifier,
enabled = enabled,
onClick = onClick,
shape = ActionButtonShape,
contentPadding = ActionButtonPadding
) {
Text(text = text, textAlign = TextAlign.Center)
}
}
// Shared constants for consistency
val ActionButtonShape = RoundedCornerShape(20.dp)
val ActionButtonPadding = PaddingValues(vertical = 0.dp, horizontal = 16.dp)
```
**Why this works on all platforms:**
- Material3 primitives (OutlinedButton, Text)
- No platform APIs
- Configurable through parameters
- Consistent styling via shared constants
## State Management Patterns
### remember - Cache Across Recompositions
```kotlin
@Composable
fun ExpandableCard() {
var isExpanded by remember { mutableStateOf(false) }
Column {
IconButton(onClick = { isExpanded = !isExpanded }) {
Icon(
if (isExpanded) Icons.Default.ExpandLess else Icons.Default.ExpandMore,
contentDescription = if (isExpanded) "Collapse" else "Expand"
)
}
if (isExpanded) {
Text("Expanded content...")
}
}
}
```
**Visual pattern**: Toggle button → state changes → UI expands/collapses
**Use for**: Simple UI state (toggles, counters, text input)
### derivedStateOf - Optimize Frequent Changes
```kotlin
@Composable
fun ScrollToTopButton(listState: LazyListState) {
// Only recomposes when showButton changes, not every scroll pixel
val showButton by remember {
derivedStateOf {
listState.firstVisibleItemIndex > 0
}
}
if (showButton) {
FloatingActionButton(onClick = { /* scroll to top */ }) {
Icon(Icons.Default.ArrowUpward, null)
}
}
}
```
**Visual pattern**: Scroll position (0, 1, 2...) → boolean (show/hide) → Button visibility
**Use for**: Input changes frequently, derived result changes rarely
**Performance**: Prevents recomposition on every scroll event
### produceState - Async to Compose State
```kotlin
@Composable
fun LoadUserProfile(userId: String): State<User?> {
return produceState<User?>(initialValue = null, userId) {
value = repository.fetchUser(userId)
}
}
@Composable
fun ProfileScreen(userId: String) {
val user by LoadUserProfile(userId)
when (user) {
null -> LoadingState("Loading profile...")
else -> ProfileCard(user!!)
}
}
```
**Visual pattern**: Async operation → state updates → UI reflects changes
**Use for**: Convert Flow, LiveData, callbacks into Compose state
**Lifecycle**: Coroutine cancelled when composable leaves composition
For Kotlin-specific state patterns (StateFlow, sealed classes), see `kotlin-expert`.
## State Hoisting
Move state up to make composables reusable:
```kotlin
// ❌ Stateful - hard to test, can't control externally
@Composable
fun BadSearchBar() {
var query by remember { mutableStateOf("") }
TextField(value = query, onValueChange = { query = it })
}
// ✅ Stateless - reusable, testable
@Composable
fun GoodSearchBar(
query: String,
onQueryChange: (String) -> Unit,
modifier: Modifier = Modifier
) {
TextField(
value = query,
onValueChange = onQueryChange,
modifier = modifier
)
}
@Composable
fun SearchScreen() {
var query by remember { mutableStateOf("") }
Column {
GoodSearchBar(query = query, onQueryChange = { query = it })
SearchResults(query = query)
}
}
```
**Principle**: State up, events down
- State: `query: String` (read-only parameter)
- Events: `onQueryChange: (String) -> Unit` (callback parameter)
## Recomposition Optimization
### Visual Usage of @Immutable
Use @Immutable on data classes passed to composables:
```kotlin
@Immutable
data class UserProfile(val name: String, val avatar: String)
@Composable
fun ProfileCard(profile: UserProfile) {
// Only recomposes when profile instance changes
Row {
RobohashImage(robot = profile.avatar)
Text(profile.name, style = MaterialTheme.typography.titleMedium)
}
}
```
**Visual effect**: Prevents recomposition when parent recomposes with same data
**Pattern**: Mark parameter data classes as @Immutable
**Note**: For Kotlin language details on @Immutable, see `kotlin-expert`
### Stable Parameters
```kotlin
// ✅ Stable - won't trigger recomposition unless colors instance changes
@Composable
fun ThemedCard(
content: String,
colors: CardColors = CardDefaults.colors(),
modifier: Modifier = Modifier
) {
Card(colors = colors, modifier = modifier) {
Text(content)
}
}
```
For @Stable annotation details, see `kotlin-expert`.
## Material3 Theming
All shared composables use Material3 for consistency:
```kotlin
@Composable
fun ThemedComponent() {
val bg = MaterialTheme.colorScheme.background
val fg = MaterialTheme.colorScheme.onBackground
val primary = MaterialTheme.colorScheme.primary
Column(
modifier = Modifier.background(bg)
) {
Text(
"Title",
style = MaterialTheme.typography.headlineMedium,
color = fg
)
Button(
onClick = { /* ... */ },
colors = ButtonDefaults.buttonColors(containerColor = primary)
) {
Text("Action")
}
}
}
```
**Principles:**
- Colors: `MaterialTheme.colorScheme.*`
- Typography: `MaterialTheme.typography.*`
- Shapes: `MaterialTheme.shapes.*`
### Theme Detection
```kotlin
@Composable
private fun isLightTheme(): Boolean {
val background = MaterialTheme.colorScheme.background
return (background.red + background.green + background.blue) / 3 > 0.5f
}
@Composable
fun ThemedIcon() {
val isDark = !isLightTheme()
val tint = if (isDark) Color.White else Color.Black
Icon(Icons.Default.Face, null, tint = tint)
}
```
## Custom Icons: ImageVector Pattern
Amethyst uses ImageVector for multiplatform icons.
### roboBuilder DSL
```kotlin
fun roboBuilder(block: Builder.() -> Unit): ImageVector {
return ImageVector.Builder(
name = "Robohash",
defaultWidth = 300.dp,
defaultHeight = 300.dp,
viewportWidth = 300f,
viewportHeight = 300f
).apply(block).build()
}
```
### Building Icons
```kotlin
fun customIcon(fgColor: SolidColor, builder: Builder) {
builder.addPath(pathData1, fill = fgColor, stroke = Black, strokeLineWidth = 1.5f)
builder.addPath(pathData2, fill = Black, fillAlpha = 0.4f)
builder.addPath(pathData3, fill = Black, fillAlpha = 0.2f)
}
private val pathData1 = PathData {
moveTo(144.5f, 87.5f)
reflectiveCurveToRelative(-51.0f, 3.0f, -53.0f, 55.0f)
lineToRelative(16.0f, 16.0f)
close()
}
@Composable
fun CustomIcon() {
Image(
painter = rememberVectorPainter(
roboBuilder {
customIcon(SolidColor(Color.Blue), this)
}
),
contentDescription = "Custom icon"
)
}
```
**Why ImageVector?**
- Pure Kotlin, no XML
- Works on Android, Desktop, iOS
- GPU-accelerated
- Type-safe
### Caching Pattern
```kotlin
object CustomIcons {
private val cache = mutableMapOf<String, ImageVector>()
fun get(key: String): ImageVector {
return cache.getOrPut(key) {
buildIcon(key)
}
}
}
@Composable
fun CachedIcon(key: String) {
Image(imageVector = CustomIcons.get(key), contentDescription = null)
}
```
For detailed icon patterns, see `references/icon-assets.md`.
## Common Visual Patterns
### State Visualization
```kotlin
@Composable
fun DataScreen(uiState: UiState) {
when (uiState) {
is UiState.Loading -> LoadingState("Loading...")
is UiState.Empty -> EmptyState(
title = "No data",
onRefresh = { /* refresh */ }
)
is UiState.Error -> ErrorState(
message = uiState.message,
onRetry = { /* retry */ }
)
is UiState.Success -> ContentList(uiState.items)
}
}
```
**Components** (all in `commons/commonMain`):
- `LoadingState` - Progress indicator + message
- `EmptyState` - Empty message + optional refresh button
- `ErrorState` - Error message + optional retry button
### Relay Status (Amethyst Pattern)
```kotlin
@Composable
fun RelayStatusIndicator(connectedCount: Int) {
val statusColor = when {
connectedCount == 0 -> RelayStatusColors.Disconnected
connectedCount < 3 -> RelayStatusColors.Connecting
else -> RelayStatusColors.Connected
}
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Icon(
imageVector = if (connectedCount > 0) Icons.Default.Check else Icons.Default.Close,
tint = statusColor,
modifier = Modifier.size(16.dp)
)
Text(
"$connectedCount relay${if (connectedCount != 1) "s" else ""}",
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
```
**Visual mapping**:
- 0 relays → Red + X icon
- 1-2 relays → Yellow + Check icon
- 3+ relays → Green + Check icon
### Placeholder Pattern
```kotlin
@Composable
fun PlaceholderScreen(
title: String,
description: String,
modifier: Modifier = Modifier
) {
Column(modifier = modifier) {
Text(title, style = MaterialTheme.typography.headlineMedium)
Spacer(Modifier.height(16.dp))
Text(description, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
}
// Specific implementations
@Composable
fun SearchPlaceholder() = PlaceholderScreen(
title = "Search",
description = "Search for users, notes, and hashtags."
)
```
**Pattern**: Generic composable + specific wrappers with preset text
## Performance
### Avoid Unnecessary Recomposition
```kotlin
// ❌ Bad - recomposes on every scroll
@Composable
fun BadButton(scrollState: ScrollState) {
if (scrollState.value > 100) {
Button(onClick = {}) { Text("Top") }
}
}
// ✅ Good - only recomposes when visibility changes
@Composable
fun GoodButton(scrollState: ScrollState) {
val show by remember { derivedStateOf { scrollState.value > 100 } }
if (show) {
Button(onClick = {}) { Text("Top") }
}
}
```
### Lazy Lists
```kotlin
@Composable
fun FeedList(items: List<Item>) {
LazyColumn {
items(items, key = { it.id }) { item ->
FeedItem(item)
}
}
}
```
**Key principle**: Use `key` parameter for stable item identity
## Bundled Resources
- **references/shared-composables-catalog.md** - Complete catalog of shared UI components
- **references/state-patterns.md** - State management patterns with visual examples
- **references/icon-assets.md** - Custom ImageVector icon patterns
- **scripts/find-composables.sh** - Find all @Composable functions in codebase
## Quick Reference
| Task | Pattern | Location |
|------|---------|----------|
| Reusable UI | State hoisting | commons/commonMain |
| Simple state | remember { mutableStateOf() } | Composable scope |
| Derived state | derivedStateOf { } | remember block |
| Async → state | produceState { } | Composable function |
| Custom icons | roboBuilder + PathData | commons/icons |
| Loading/Error | LoadingState, ErrorState | commons/ui/components |
| Theme colors | MaterialTheme.colorScheme | Any @Composable |
| Navigation | Delegate to platform expert | amethyst/, desktopApp/ |
## Common Workflows
### Creating a Shared Component
1. Start in `commons/src/commonMain/kotlin/.../ui/components/`
2. Use Material3 primitives only
3. Hoist state (parameters for data, callbacks for events)
4. Add modifier parameter
5. Use MaterialTheme for colors/typography
6. Test on both Android and Desktop
### Converting Existing Component
1. Read current implementation in `amethyst/` or `desktopApp/`
2. Identify pure visual logic (no platform APIs)
3. Create in `commons/commonMain` with hoisted state
4. Replace platform implementations with shared component
5. Keep platform-specific wrappers if needed
### Custom Icon
1. Export SVG from design tool
2. Convert to PathData using Android Studio
3. Create icon function with roboBuilder
4. Add caching if generated dynamically
5. Wrap in @Composable for easy use
### Navigation (Delegate)
For navigation patterns:
- Android bottom nav → `android-expert`
- Desktop sidebar → `desktop-expert`
- Multi-window → `desktop-expert`
## Related Skills
- **kotlin-expert** - Kotlin language aspects (@Immutable details, StateFlow, sealed classes)
- **android-expert** - Android navigation, platform APIs
- **desktop-expert** - Desktop navigation, window management, OS specifics
- **kotlin-coroutines** - Async patterns, Flow integration
@@ -0,0 +1,365 @@
# Custom Icon Assets and ImageVector Patterns
Guide to creating and using custom ImageVector icons in Compose Multiplatform.
## Why ImageVector?
ImageVector is the native Compose format for vector graphics:
- **Pure Kotlin**: No XML, no asset files
- **Multiplatform**: Works on Android, Desktop, iOS without conversion
- **Performant**: Lightweight, composable, GPU-accelerated
- **Type-safe**: Compile-time checking, no resource IDs
## Amethyst Pattern: Robohash
Amethyst generates deterministic avatars using ImageVector builders.
### Architecture
```
commons/robohash/
├── RobohashAssembler.kt # Main assembly logic
├── CachedRobohash.kt # Caching layer
└── parts/
├── Face0C3po.kt # Face variants (0-9)
├── Eyes2Single.kt # Eye variants (0-9)
├── Mouth3Grid.kt # Mouth variants (0-9)
├── Body2Thinnest.kt # Body variants (0-9)
└── Accessory7Antenna.kt # Accessory variants (0-9)
```
**Pattern**: 10 variants per feature × 5 features = 100,000+ unique combinations
### roboBuilder DSL
Custom ImageVector builder with sensible defaults:
```kotlin
fun roboBuilder(block: Builder.() -> Unit): ImageVector {
return ImageVector.Builder(
name = "Robohash",
defaultWidth = 300.dp,
defaultHeight = 300.dp,
viewportWidth = 300f,
viewportHeight = 300f
).apply(block).build()
}
```
**Usage**:
```kotlin
@Composable
fun CustomIcon() {
Image(
painter = rememberVectorPainter(
roboBuilder {
// Add paths here
}
),
contentDescription = "Custom icon"
)
}
```
### Path Building Pattern
```kotlin
fun face0C3po(fgColor: SolidColor, builder: Builder) {
builder.addPath(pathData1, fill = fgColor, stroke = Black, strokeLineWidth = 1.5f)
builder.addPath(pathData2, fill = Black, fillAlpha = 0.4f)
builder.addPath(pathData5, fill = Black, fillAlpha = 0.2f)
builder.addPath(pathData6, stroke = Black, strokeLineWidth = 1.0f)
builder.addPath(pathData7, fill = Black, stroke = Black, fillAlpha = 0.2f, strokeLineWidth = 0.75f)
}
private val pathData1 = PathData {
moveTo(144.5f, 87.5f)
reflectiveCurveToRelative(-51.0f, 3.0f, -53.0f, 55.0f)
curveToRelative(0.0f, 0.0f, 0.0f, 27.0f, 5.0f, 42.0f)
reflectiveCurveToRelative(10.0f, 38.0f, 10.0f, 38.0f)
lineToRelative(16.0f, 16.0f)
// ...
close()
}
```
**Key elements**:
- `pathData` variables for path commands
- `addPath()` for each layer
- Parameterized colors (`fgColor`)
- Constant colors (`Black`)
- Alpha for shadows/highlights
### PathData DSL
Compose's PathData builder provides SVG-like commands:
| Command | Description | Example |
|---------|-------------|---------|
| `moveTo(x, y)` | Move pen without drawing | `moveTo(100f, 100f)` |
| `lineTo(x, y)` | Draw line to point | `lineTo(200f, 150f)` |
| `curveToRelative(...)` | Relative cubic Bézier | `curveToRelative(10f, 20f, 30f, 40f, 50f, 60f)` |
| `reflectiveCurveToRelative(...)` | Smooth curve | `reflectiveCurveToRelative(-51f, 3f, -53f, 55f)` |
| `horizontalLineTo(x)` | Horizontal line | `horizontalLineTo(250f)` |
| `verticalLineTo(y)` | Vertical line | `verticalLineTo(300f)` |
| `close()` | Close path | `close()` |
**Relative vs Absolute**:
- `moveTo` / `lineTo` - Absolute coordinates
- `moveToRelative` / `lineToRelative` - Relative to current position
## Creating Custom Icons
### Method 1: From SVG (Recommended)
1. **Export SVG** from design tool (Figma, Illustrator)
2. **Convert to ImageVector** using Android Studio's Vector Asset tool
3. **Extract path data** and adapt to roboBuilder pattern
```kotlin
// SVG path: M 10 10 L 20 20 ...
// Becomes:
private val myIconPath = PathData {
moveTo(10f, 10f)
lineTo(20f, 20f)
// ...
}
```
### Method 2: Programmatic
Build paths programmatically for simple shapes:
```kotlin
fun simpleIcon(): ImageVector = roboBuilder {
addPath(
pathData = PathData {
moveTo(50f, 50f)
lineTo(150f, 50f)
lineTo(150f, 150f)
lineTo(50f, 150f)
close()
},
fill = SolidColor(Color.Blue),
stroke = SolidColor(Color.Black),
strokeLineWidth = 2f
)
}
```
### Method 3: Material Icons Extensions
Extend Material Icons when you need platform-consistent icons:
```kotlin
// For standard icons, use Material Icons
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
Icon(Icons.Default.Refresh, contentDescription = "Refresh")
Icon(Icons.Default.Check, contentDescription = "Success")
Icon(Icons.Default.Close, contentDescription = "Error")
```
## CachedRobohash Pattern
Performance optimization for generated icons:
```kotlin
object CachedRobohash {
private val cache = mutableMapOf<Pair<String, Boolean>, ImageVector>()
fun get(seed: String, isLight: Boolean): ImageVector {
return cache.getOrPut(seed to isLight) {
RobohashAssembler.assemble(seed, isLight)
}
}
}
```
**Pattern**:
- Key: `(seed, theme)` pair
- Value: Assembled ImageVector
- Lifecycle: Application lifetime (never cleared)
**Usage**:
```kotlin
@Composable
fun RobohashImage(robot: String) {
Image(
imageVector = CachedRobohash.get(robot, isLightTheme()),
contentDescription = "Avatar for $robot"
)
}
```
## Color Management
### Dynamic Colors
Pass colors as parameters for theme adaptation:
```kotlin
fun themedIcon(fgColor: SolidColor, bgColor: SolidColor, builder: Builder) {
builder.addPath(pathData1, fill = bgColor)
builder.addPath(pathData2, fill = fgColor)
}
@Composable
fun ThemedIcon() {
val fg = MaterialTheme.colorScheme.primary
val bg = MaterialTheme.colorScheme.surface
Image(
painter = rememberVectorPainter(
roboBuilder {
themedIcon(SolidColor(fg), SolidColor(bg), this)
}
),
contentDescription = null
)
}
```
### Static Colors
Define constants for colors that don't change:
```kotlin
val Black = SolidColor(Color.Black)
val White = SolidColor(Color.White)
val Transparent = SolidColor(Color.Transparent)
```
## Advanced Techniques
### Layering
Build complex icons with multiple layers:
```kotlin
fun complexIcon(builder: Builder) {
// Layer 1: Background
builder.addPath(bgPath, fill = SolidColor(Color.White))
// Layer 2: Shadow
builder.addPath(shadowPath, fill = SolidColor(Color.Black), fillAlpha = 0.2f)
// Layer 3: Main shape
builder.addPath(mainPath, fill = SolidColor(Color.Blue))
// Layer 4: Highlight
builder.addPath(highlightPath, fill = SolidColor(Color.White), fillAlpha = 0.3f)
// Layer 5: Stroke
builder.addPath(outlinePath, stroke = SolidColor(Color.Black), strokeLineWidth = 1f)
}
```
**Render order**: Bottom to top (first addPath = bottom layer)
### Alpha for Visual Effects
```kotlin
// Shadow
builder.addPath(shadowPath, fill = Black, fillAlpha = 0.4f)
// Highlight
builder.addPath(highlightPath, fill = White, fillAlpha = 0.2f)
// Glass effect
builder.addPath(glassPath, fill = White, fillAlpha = 0.1f)
```
### Stroke Styles
```kotlin
// Outline only
builder.addPath(path, stroke = Black, strokeLineWidth = 1.5f)
// Fill + outline
builder.addPath(path, fill = fgColor, stroke = Black, strokeLineWidth = 1f)
// Dashed (not supported directly, use multiple segments)
```
## Composable Icon Pattern
Wrap ImageVector in a Composable for reusability:
```kotlin
@Composable
fun MyCustomIcon(
modifier: Modifier = Modifier,
tint: Color = Color.Unspecified
) {
Image(
painter = rememberVectorPainter(myIconVector()),
contentDescription = "My custom icon",
modifier = modifier,
colorFilter = if (tint != Color.Unspecified) {
ColorFilter.tint(tint)
} else null
)
}
```
**Usage**:
```kotlin
MyCustomIcon(
modifier = Modifier.size(24.dp),
tint = MaterialTheme.colorScheme.primary
)
```
## Best Practices
### DO
✅ Cache generated ImageVectors for performance
✅ Use PathData DSL for readability
✅ Parameterize colors for theme support
✅ Use Material Icons for standard icons
✅ Keep viewport size consistent (e.g., 300×300)
✅ Layer paths from back to front
✅ Use alpha for shadows and highlights
### DON'T
❌ Generate ImageVectors in @Composable without caching
❌ Hardcode theme-specific colors
❌ Create custom icons for standard Material icons
❌ Use extreme viewport sizes (stay 24-1000dp)
❌ Mix absolute and relative coordinates unnecessarily
❌ Forget to close() paths
## Icon Organization
### Structure
```
commons/icons/
├── CustomIcons.kt # Icon collection object
├── icons/
│ ├── Zap.kt # Lightning bolt
│ ├── Relay.kt # Relay indicator
│ └── Bitcoin.kt # Bitcoin symbol
└── builders/
└── IconBuilder.kt # Shared builder utilities
```
### Collection Object
```kotlin
object CustomIcons {
val Zap: ImageVector by lazy { ZapIcon.create() }
val Relay: ImageVector by lazy { RelayIcon.create() }
val Bitcoin: ImageVector by lazy { BitcoinIcon.create() }
}
// Usage
Icon(CustomIcons.Zap, contentDescription = "Zap")
```
## Resources
- [Compose ImageVector API](https://developer.android.com/reference/kotlin/androidx/compose/ui/graphics/vector/ImageVector)
- [SVG Path Commands](https://developer.mozilla.org/en-US/docs/Web/SVG/Tutorial/Paths)
- [Material Icons](https://fonts.google.com/icons)
- Robohash implementation: `commons/robohash/` in AmethystMultiplatform
@@ -0,0 +1,281 @@
# Shared Composables Catalog
This catalog documents shared UI components in `commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/`.
## Directory Structure
```
commons/src/commonMain/kotlin/.../commons/ui/
├── components/ # Reusable UI components
├── screens/ # Screen-level composables
├── theme/ # Theming and styling
└── feed/ # Feed-specific components
```
## Components (`ui/components/`)
### State Visualization
**LoadingState** - Centered loading indicator with message
```kotlin
@Composable
fun LoadingState(message: String, modifier: Modifier = Modifier)
```
- Use for: Async operations, data fetching
- Pattern: fillMaxSize, centered Column, CircularProgressIndicator
- Works on: Android, Desktop
**EmptyState** - Centered empty state with optional refresh
```kotlin
@Composable
fun EmptyState(
title: String,
modifier: Modifier = Modifier,
description: String? = null,
onRefresh: (() -> Unit)? = null,
refreshLabel: String = "Refresh"
)
```
- Use for: Empty lists, no data scenarios
- Pattern: Centered Column, optional OutlinedButton
- Works on: Android, Desktop
**ErrorState** - Centered error message with retry
```kotlin
@Composable
fun ErrorState(
message: String,
modifier: Modifier = Modifier,
onRetry: (() -> Unit)? = null,
retryLabel: String = "Try Again"
)
```
- Use for: Error handling, failed operations
- Pattern: error color, optional Button
- Works on: Android, Desktop
### Feed-Specific States
**FeedEmptyState** - Pre-configured empty state for feeds
```kotlin
@Composable
fun FeedEmptyState(
modifier: Modifier = Modifier,
title: String = "Feed is empty",
onRefresh: (() -> Unit)? = null
)
```
**FeedErrorState** - Pre-configured error state for feeds
```kotlin
@Composable
fun FeedErrorState(
errorMessage: String,
modifier: Modifier = Modifier,
onRetry: (() -> Unit)? = null
)
```
### Action Buttons
**Shared Constants**:
```kotlin
val ActionButtonShape = RoundedCornerShape(20.dp)
val ActionButtonPadding = PaddingValues(vertical = 0.dp, horizontal = 16.dp)
```
**AddButton** - Consistent "Add" action button
```kotlin
@Composable
fun AddButton(
onClick: () -> Unit,
modifier: Modifier = Modifier,
text: String = "Add",
enabled: Boolean = true
)
```
- Pattern: OutlinedButton with consistent shape/padding
- Works on: Android, Desktop
**RemoveButton** - Consistent "Remove" action button
```kotlin
@Composable
fun RemoveButton(
onClick: () -> Unit,
modifier: Modifier = Modifier,
text: String = "Remove",
enabled: Boolean = true
)
```
### Custom Images
**RobohashImage** - Deterministic avatar generation
```kotlin
@Composable
fun RobohashImage(
robot: String, // Seed (e.g., pubkey)
modifier: Modifier = Modifier,
contentDescription: String? = null,
loadRobohash: Boolean = true
)
// Overload with more options
@Composable
fun RobohashImage(
robot: String,
modifier: Modifier = Modifier,
contentDescription: String? = null,
alignment: Alignment = Alignment.Center,
contentScale: ContentScale = ContentScale.Fit,
colorFilter: ColorFilter? = null,
loadRobohash: Boolean = true
)
```
- Use for: User avatars, deterministic graphics
- Pattern: Uses CachedRobohash.get(), isLightTheme() detection
- Fallback: Icons.Default.Face
- Works on: Android, Desktop (pure ImageVector)
**Theme Detection Helper**:
```kotlin
@Composable
private fun isLightTheme(): Boolean {
val background = MaterialTheme.colorScheme.background
return (background.red + background.green + background.blue) / 3 > 0.5f
}
```
## Feed Components (`ui/feed/`)
### FeedHeader
**FeedHeader** - Screen header with title and relay status
```kotlin
@Composable
fun FeedHeader(
title: String,
connectedRelayCount: Int,
onRefresh: () -> Unit,
modifier: Modifier = Modifier
)
```
- Pattern: Row with SpaceBetween, title + RelayStatusIndicator
- Works on: Android, Desktop
**RelayStatusIndicator** - Compact relay connection indicator
```kotlin
@Composable
fun RelayStatusIndicator(
connectedCount: Int,
onRefresh: () -> Unit,
modifier: Modifier = Modifier
)
```
- Pattern: Status icon + count text + refresh button
- Colors: RelayStatusColors.{Disconnected, Connecting, Connected}
- Visual cues: Check icon (connected), Close icon (disconnected)
## Screens (`ui/screens/`)
### Placeholder Pattern
**PlaceholderScreen** - Generic placeholder
```kotlin
@Composable
fun PlaceholderScreen(
title: String,
description: String,
modifier: Modifier = Modifier
)
```
- Pattern: Column with title (headlineMedium) + description
- Use for: Unimplemented screens, coming soon features
**Specific Placeholders**:
- `SearchPlaceholder()` - Search screen
- `MessagesPlaceholder()` - DMs screen
- `NotificationsPlaceholder()` - Notifications screen
Pattern: Specific implementations wrap PlaceholderScreen with preset text.
## Custom Icons (`robohash/parts/`)
### ImageVector Builder Pattern
Amethyst uses a custom DSL for building ImageVector assets:
```kotlin
@Composable
fun Face0C3po() {
Image(
painter = rememberVectorPainter(
roboBuilder {
face0C3po(SolidColor(Color.Blue), this)
}
),
contentDescription = ""
)
}
fun face0C3po(fgColor: SolidColor, builder: Builder) {
builder.addPath(pathData1, fill = fgColor, stroke = Black, strokeLineWidth = 1.5f)
builder.addPath(pathData2, fill = Black, fillAlpha = 0.4f)
// ...
}
private val pathData1 = PathData {
moveTo(144.5f, 87.5f)
reflectiveCurveToRelative(-51.0f, 3.0f, -53.0f, 55.0f)
// ... path commands
}
```
**roboBuilder** - Custom ImageVector.Builder DSL
- Located in: `commons/robohash/`
- Pattern: Builder-based, composable paths
- Parts: Face, Eyes, Mouth, Body, Accessory (0-9 variants each)
- Colors: Dynamic (fgColor parameter) + Black constants
### CachedRobohash
```kotlin
CachedRobohash.get(seed: String, isLight: Boolean): ImageVector
```
- Deterministic: Same seed → same avatar
- Theme-aware: Different colors for light/dark
- Cached: Performance optimization
- Pure ImageVector: Works on all platforms
## Sharing Guidelines
### Always Share
- State visualization (Loading, Empty, Error)
- Action buttons with consistent styling
- Generic placeholders
- Custom ImageVector icons
- Material3 themed components
- Theme utilities (isLightTheme)
### Platform-Specific (Delegate to Experts)
- Navigation structure (android-expert, desktop-expert)
- Screen layouts and scaffolds
- Platform system integrations
- Gesture handling specifics
### Decision Framework
1. **Can it use Material3 primitives?** → Share
2. **Does it need platform system APIs?** → Platform-specific
3. **Is it a visual component without navigation?** → Share
4. **Does it require platform UX patterns?** → Ask platform expert
## Material3 Usage
All shared composables use Material3:
- `MaterialTheme.colorScheme.*` for colors
- `MaterialTheme.typography.*` for text styles
- `OutlinedButton`, `Button`, `IconButton` for actions
- `CircularProgressIndicator` for loading
- `Icon`, `Image` for visuals
This ensures consistent theming across Android and Desktop.
@@ -0,0 +1,334 @@
# Compose State Management Patterns
Visual guide to state management in Compose Multiplatform. For Kotlin-specific patterns (StateFlow, sealed classes), see `kotlin-expert` skill.
## Core State Functions
### remember
Cache values across recompositions:
```kotlin
@Composable
fun Counter() {
var count by remember { mutableStateOf(0) }
Button(onClick = { count++ }) {
Text("Clicked $count times")
}
}
```
**When to use**: Simple UI state (toggles, counters, text input)
**Visual pattern**: Button press → state changes → UI updates
### derivedStateOf
Compute state from other state, recompose only when result changes:
```kotlin
@Composable
fun ScrollToTopButton(listState: LazyListState) {
// Only recomposes when showButton value changes (not every scroll pixel)
val showButton by remember {
derivedStateOf {
listState.firstVisibleItemIndex > 0
}
}
if (showButton) {
FloatingActionButton(onClick = { /* scroll to top */ }) {
Icon(Icons.Default.ArrowUpward, null)
}
}
}
```
**When to use**: Input state changes frequently, but derived result changes rarely
**Visual pattern**: Scroll position (0, 1, 2...) → boolean (show/hide) → FAB visibility
**Performance**: Prevents recomposition on every scroll event
### produceState
Convert non-Compose state into Compose state:
```kotlin
@Composable
fun LoadUserProfile(userId: String): State<User?> {
return produceState<User?>(initialValue = null, userId) {
value = repository.fetchUser(userId)
}
}
@Composable
fun ProfileScreen(userId: String) {
val user by LoadUserProfile(userId)
when (user) {
null -> LoadingState("Loading profile...")
else -> ProfileCard(user!!)
}
}
```
**When to use**: Convert Flow, LiveData, callbacks into Compose state
**Visual pattern**: Async operation → state updates → UI reflects changes
**Lifecycle**: Coroutine cancelled when composable leaves composition
## State Hoisting Pattern
Move state up to make composables reusable and testable:
### Before (Stateful)
```kotlin
@Composable
fun SearchBar() {
var query by remember { mutableStateOf("") }
TextField(
value = query,
onValueChange = { query = it },
placeholder = { Text("Search...") }
)
}
```
❌ Hard to test, can't control state externally
### After (Stateless)
```kotlin
@Composable
fun SearchBar(
query: String,
onQueryChange: (String) -> Unit,
modifier: Modifier = Modifier
) {
TextField(
value = query,
onValueChange = onQueryChange,
placeholder = { Text("Search...") },
modifier = modifier
)
}
@Composable
fun SearchScreen() {
var query by remember { mutableStateOf("") }
Column {
SearchBar(query = query, onQueryChange = { query = it })
SearchResults(query = query)
}
}
```
✅ Reusable, testable, state controlled by parent
**Hoisting principle**: State goes up, events go down
- State: `query: String` (read-only)
- Events: `onQueryChange: (String) -> Unit` (write-only)
## Amethyst State Patterns
### Theme-Aware State
```kotlin
@Composable
private fun isLightTheme(): Boolean {
val background = MaterialTheme.colorScheme.background
return (background.red + background.green + background.blue) / 3 > 0.5f
}
@Composable
fun ThemedContent() {
val isDark = !isLightTheme()
// Adjust visuals based on theme
val iconTint = if (isDark) Color.White else Color.Black
}
```
**Pattern**: Derive state from MaterialTheme
**Visual**: Component adapts to light/dark theme automatically
### Relay Status State
```kotlin
@Composable
fun RelayStatusIndicator(
connectedCount: Int,
onRefresh: () -> Unit,
modifier: Modifier = Modifier
) {
val statusColor = when {
connectedCount == 0 -> RelayStatusColors.Disconnected
connectedCount < 3 -> RelayStatusColors.Connecting
else -> RelayStatusColors.Connected
}
Icon(
imageVector = if (connectedCount > 0) Icons.Default.Check else Icons.Default.Close,
tint = statusColor
)
}
```
**Pattern**: Visual state derived from domain state
**Visual mapping**:
- 0 relays → Red + X icon
- 1-2 relays → Yellow + Check icon
- 3+ relays → Green + Check icon
### Loading/Empty/Error States
```kotlin
@Composable
fun FeedScreen(viewModel: FeedViewModel) {
val uiState by viewModel.uiState.collectAsState()
when (uiState) {
is UiState.Loading -> LoadingState("Loading feed...")
is UiState.Empty -> FeedEmptyState(onRefresh = { viewModel.refresh() })
is UiState.Error -> FeedErrorState(
errorMessage = uiState.message,
onRetry = { viewModel.retry() }
)
is UiState.Success -> LazyColumn {
items(uiState.items) { FeedItem(it) }
}
}
}
```
**Pattern**: Sealed class → visual state component
**Components**:
- `LoadingState` - Progress indicator
- `EmptyState` - Empty message + refresh
- `ErrorState` - Error message + retry
- Success - Actual content
## Common Patterns
### Toggle State
```kotlin
var isExpanded by remember { mutableStateOf(false) }
IconButton(onClick = { isExpanded = !isExpanded }) {
Icon(
if (isExpanded) Icons.Default.ExpandLess else Icons.Default.ExpandMore,
contentDescription = if (isExpanded) "Collapse" else "Expand"
)
}
if (isExpanded) {
Text("Expanded content...")
}
```
### List State with Actions
```kotlin
var items by remember { mutableStateOf(listOf("Item 1", "Item 2")) }
Column {
AddButton(onClick = {
items = items + "Item ${items.size + 1}"
})
items.forEachIndexed { index, item ->
Row {
Text(item)
RemoveButton(onClick = {
items = items.filterIndexed { i, _ -> i != index }
})
}
}
}
```
### TextField State
```kotlin
var text by remember { mutableStateOf("") }
TextField(
value = text,
onValueChange = { text = it },
label = { Text("Enter text") }
)
```
## Performance Patterns
### Avoid Unnecessary Recomposition
```kotlin
// ❌ Bad: Recomposes on every scroll position change
@Composable
fun BadScrollButton(scrollState: ScrollState) {
if (scrollState.value > 100) { // scrollState.value changes constantly
Button(onClick = { /* ... */ }) { Text("Scroll to Top") }
}
}
// ✅ Good: Only recomposes when visibility changes
@Composable
fun GoodScrollButton(scrollState: ScrollState) {
val showButton by remember {
derivedStateOf { scrollState.value > 100 }
}
if (showButton) {
Button(onClick = { /* ... */ }) { Text("Scroll to Top") }
}
}
```
### Stable Parameters
Use `@Immutable` data classes (see `kotlin-expert`) to prevent recomposition:
```kotlin
@Immutable
data class UserProfile(val name: String, val avatar: String)
@Composable
fun ProfileCard(profile: UserProfile) {
// Only recomposes when profile instance changes
Row {
RobohashImage(robot = profile.avatar)
Text(profile.name)
}
}
```
## Integration with Kotlin State
For ViewModel state, Flow, StateFlow → See `kotlin-expert` skill
Common integration pattern:
```kotlin
// ViewModel (Kotlin state)
class FeedViewModel {
private val _uiState = MutableStateFlow<UiState>(UiState.Loading)
val uiState: StateFlow<UiState> = _uiState.asStateFlow()
}
// Composable (Compose state)
@Composable
fun FeedScreen(viewModel: FeedViewModel) {
val uiState by viewModel.uiState.collectAsState()
// Use uiState to render UI
}
```
## Quick Reference
| Function | Use Case | Recomposes When |
|----------|----------|----------------|
| `remember { mutableStateOf() }` | Local UI state | State value changes |
| `derivedStateOf { }` | Computed state | Derived result changes |
| `produceState { }` | Async/Flow → State | Async operation updates value |
| `collectAsState()` | Flow → State | Flow emits new value |
| State hoisting | Reusable components | Parent passes new state |
## Sources
State management patterns based on:
- [State and Jetpack Compose - Android Developers](https://developer.android.com/develop/ui/compose/state)
- [When should I use derivedStateOf?](https://medium.com/androiddevelopers/jetpack-compose-when-should-i-use-derivedstateof-63ce7954c11b)
- [Advanced State and Side Effects](https://developer.android.com/codelabs/jetpack-compose-advanced-state-side-effects)
- AmethystMultiplatform codebase patterns (2025)
+34
View File
@@ -0,0 +1,34 @@
#!/bin/bash
# Find all @Composable functions in the codebase
set -e
# Default to current directory if no path provided
SEARCH_PATH="${1:-.}"
echo "Searching for @Composable functions in: $SEARCH_PATH"
echo "================================================"
echo ""
# Find all @Composable functions with file paths and line numbers
grep -r -n "@Composable" "$SEARCH_PATH" \
--include="*.kt" \
--exclude-dir=build \
--exclude-dir=.gradle \
| while IFS=: read -r file line content; do
# Extract function name if possible
if [[ $content =~ fun[[:space:]]+([a-zA-Z0-9_]+) ]]; then
func_name="${BASH_REMATCH[1]}"
echo "$file:$line - $func_name"
else
echo "$file:$line"
fi
done
echo ""
echo "Total @Composable functions found:"
grep -r "@Composable" "$SEARCH_PATH" \
--include="*.kt" \
--exclude-dir=build \
--exclude-dir=.gradle \
| wc -l
+748
View File
@@ -0,0 +1,748 @@
# Desktop Expert
Expert in Compose Multiplatform Desktop development for AmethystMultiplatform. Covers Desktop-specific APIs, OS conventions, navigation patterns, and UX principles.
## When to Use This Skill
**Auto-invoke when:**
- Working with `desktopApp/` module files
- Using Desktop-only APIs: `Window`, `Tray`, `MenuBar`, `Dialog`
- Implementing keyboard shortcuts, menu systems
- Desktop navigation (NavigationRail, multi-window)
- File system operations (file pickers, drag-drop)
- OS-specific behavior (macOS, Windows, Linux)
- Desktop UX patterns (keyboard-first, tooltips)
**Delegate to:**
- **kotlin-multiplatform**: Shared code questions, `jvmMain` source set structure
- **gradle-expert**: All `build.gradle.kts` issues, dependency conflicts
- **compose-expert**: General Compose patterns, `@Composable` best practices, Material3
## Scope
**In scope:**
- Desktop-only Compose APIs
- Window management, positioning, state
- MenuBar + keyboard shortcuts (OS-specific)
- System Tray integration
- Desktop navigation patterns (NavigationRail)
- File dialogs, Desktop.getDesktop()
- OS conventions (macOS vs Windows vs Linux)
- Desktop UX principles
**Out of scope:**
- Build configuration → **gradle-expert**
- Shared composables → **compose-expert**
- KMP structure → **kotlin-multiplatform**
---
## 1. Desktop Entry Point
### application {} DSL
Desktop apps start with the `application {}` block:
```kotlin
// desktopApp/src/jvmMain/kotlin/Main.kt
fun main() = application {
val windowState = rememberWindowState(
width = 1200.dp,
height = 800.dp,
position = WindowPosition.Aligned(Alignment.Center)
)
Window(
onCloseRequest = ::exitApplication,
state = windowState,
title = "Amethyst"
) {
MenuBar { /* ... */ }
App()
}
}
```
**Key points:**
- `application {}` is the root composable (JVM-only)
- `Window()` creates the main window
- `rememberWindowState()` manages size/position
- `onCloseRequest` handles window close
**See:** `desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt:87-138`
---
## 2. Window Management
### WindowState
```kotlin
val windowState = rememberWindowState(
width = 1200.dp,
height = 800.dp,
position = WindowPosition.Aligned(Alignment.Center)
)
Window(
state = windowState,
title = "My App",
resizable = true,
onCloseRequest = ::exitApplication
) {
// Content
}
```
### Multiple Windows
```kotlin
fun main() = application {
var showSettings by remember { mutableStateOf(false) }
Window(onCloseRequest = ::exitApplication, title = "Main") {
Button(onClick = { showSettings = true }) {
Text("Open Settings")
}
}
if (showSettings) {
Window(
onCloseRequest = { showSettings = false },
title = "Settings"
) {
// Settings UI
}
}
}
```
**Pattern:** Use state to control window visibility conditionally.
---
## 3. MenuBar System
### Basic MenuBar
```kotlin
Window(onCloseRequest = ::exitApplication, title = "App") {
MenuBar {
Menu("File") {
Item("New Note", onClick = { /* ... */ })
Separator()
Item("Quit", onClick = ::exitApplication)
}
Menu("Edit") {
Item("Copy", onClick = { /* ... */ })
Item("Paste", onClick = { /* ... */ })
}
}
App()
}
```
### Keyboard Shortcuts (OS-Aware)
**Current issue:** Main.kt hardcodes `ctrl = true` (Main.kt:105, 111, 117, 122, 123).
**OS-specific shortcuts:**
```kotlin
import androidx.compose.ui.input.key.Key
import androidx.compose.ui.input.key.KeyShortcut
// Detect OS
val isMacOS = System.getProperty("os.name").lowercase().contains("mac")
MenuBar {
Menu("File") {
Item(
"New Note",
shortcut = if (isMacOS) {
KeyShortcut(Key.N, meta = true) // Cmd+N on macOS
} else {
KeyShortcut(Key.N, ctrl = true) // Ctrl+N on Win/Linux
},
onClick = { /* ... */ }
)
Item(
"Settings",
shortcut = if (isMacOS) {
KeyShortcut(Key.Comma, meta = true) // Cmd+, on macOS
} else {
KeyShortcut(Key.Comma, ctrl = true) // Ctrl+, on Win/Linux
},
onClick = { /* ... */ }
)
Separator()
Item(
"Quit",
shortcut = if (isMacOS) {
KeyShortcut(Key.Q, meta = true) // Cmd+Q on macOS
} else {
KeyShortcut(Key.Q, ctrl = true) // Ctrl+Q on Win/Linux
},
onClick = ::exitApplication
)
}
}
```
**Standard shortcuts:**
| Action | macOS | Windows/Linux |
|--------|-------|---------------|
| New | Cmd+N | Ctrl+N |
| Open | Cmd+O | Ctrl+O |
| Save | Cmd+S | Ctrl+S |
| Quit | Cmd+Q | Ctrl+Q (Alt+F4) |
| Settings | Cmd+, | Ctrl+, |
| Copy | Cmd+C | Ctrl+C |
| Paste | Cmd+V | Ctrl+V |
| Undo | Cmd+Z | Ctrl+Z |
**See:** `references/keyboard-shortcuts.md` for full list.
---
## 4. System Tray
### Basic Tray
```kotlin
application {
var isVisible by remember { mutableStateOf(true) }
Tray(
icon = painterResource("icon.png"),
onAction = { isVisible = true },
menu = {
Item("Show", onClick = { isVisible = true })
Separator()
Item("Quit", onClick = ::exitApplication)
}
)
if (isVisible) {
Window(
onCloseRequest = { isVisible = false }, // Minimize to tray
title = "App"
) {
// Content
}
}
}
```
**Pattern:** Hide window to tray instead of closing.
**Current status:** Not implemented in Main.kt. Planned feature.
---
## 5. Desktop Navigation Patterns
### NavigationRail (Current Pattern)
Desktop uses **NavigationRail** (vertical sidebar) instead of Android's bottom navigation.
```kotlin
Row(Modifier.fillMaxSize()) {
// Sidebar
NavigationRail(
modifier = Modifier.width(80.dp).fillMaxHeight(),
containerColor = MaterialTheme.colorScheme.surfaceVariant
) {
NavigationRailItem(
icon = { Icon(Icons.Default.Home, "Feed") },
label = { Text("Feed") },
selected = currentScreen == AppScreen.Feed,
onClick = { currentScreen = AppScreen.Feed }
)
// More items...
}
VerticalDivider()
// Main content area
Box(Modifier.weight(1f).fillMaxHeight()) {
when (currentScreen) {
AppScreen.Feed -> FeedScreen()
// Other screens...
}
}
}
```
**See:** Main.kt:191-264
**Why NavigationRail?**
- Desktop has horizontal space (1200+ dp width)
- Vertical sidebar is standard desktop pattern
- Always visible (no tabs hidden)
- Icon + label both visible
**Android comparison:**
- Android: `BottomNavigationBar` (horizontal, bottom)
- Desktop: `NavigationRail` (vertical, left)
### Multi-Pane Layouts
Desktop can leverage wide screens:
```kotlin
Row {
// Left: Navigation
NavigationRail { /* ... */ }
// Center: Main content
Box(Modifier.weight(0.6f)) {
FeedScreen()
}
// Right: Details pane (desktop only)
if (selectedNote != null) {
VerticalDivider()
Box(Modifier.weight(0.4f)) {
NoteDetailPane(selectedNote)
}
}
}
```
**See:** `references/desktop-navigation.md`
---
## 6. File System Integration
### File Dialogs
```kotlin
// File picker (load)
val fileDialog = FileDialog(Frame(), "Select file", FileDialog.LOAD)
fileDialog.isVisible = true
val filePath = fileDialog.file?.let { "${fileDialog.directory}$it" }
// File picker (save)
val saveDialog = FileDialog(Frame(), "Save file", FileDialog.SAVE)
saveDialog.isVisible = true
val savePath = saveDialog.file?.let { "${saveDialog.directory}$it" }
```
**Note:** Compose Desktop doesn't have native file picker composable yet. Use AWT `FileDialog`.
### Open External URLs
```kotlin
// jvmMain actual implementation
actual fun openExternalUrl(url: String) {
if (Desktop.isDesktopSupported()) {
Desktop.getDesktop().browse(URI(url))
}
}
```
**Pattern:** Define `expect` in `commonMain`, implement `actual` in `jvmMain`.
### Drag & Drop (Future)
```kotlin
// Compose Desktop drag-drop (experimental)
Box(
modifier = Modifier
.onExternalDrag(
onDragStart = { /* ... */ },
onDrag = { /* ... */ },
onDragExit = { /* ... */ },
onDrop = { state ->
val dragData = state.dragData
// Handle dropped files
}
)
) {
Text("Drop files here")
}
```
---
## 7. OS-Specific Conventions
### Platform Detection
```kotlin
val osName = System.getProperty("os.name").lowercase()
val isMacOS = osName.contains("mac")
val isWindows = osName.contains("win")
val isLinux = osName.contains("nux") || osName.contains("nix")
```
### Menu Bar Placement
| OS | Behavior |
|----|----------|
| **macOS** | System-wide menu bar at top of screen |
| **Windows** | In-window menu bar |
| **Linux** | Varies by desktop environment |
Compose Desktop `MenuBar` adapts automatically.
### Keyboard Modifier Keys
| Modifier | macOS | Windows/Linux |
|----------|-------|---------------|
| Primary | `meta = true` (Cmd) | `ctrl = true` |
| Secondary | `ctrl = true` | `alt = true` |
| Shift | `shift = true` | `shift = true` |
**Best practice:** Detect OS and use appropriate modifier.
### System Tray Behavior
| OS | Tray Location |
|----|---------------|
| **macOS** | Top-right menu bar |
| **Windows** | Bottom-right taskbar |
| **Linux** | Top panel (varies) |
---
## 8. Desktop UX Principles
### Keyboard-First Design
**Every action should have:**
1. Mouse/touch interaction
2. Keyboard shortcut (if frequent)
3. Tooltip showing shortcut
```kotlin
IconButton(
onClick = { /* refresh */ },
modifier = Modifier.tooltipArea(
tooltip = {
Text("Refresh (${if (isMacOS) "Cmd" else "Ctrl"}+R)")
}
)
) {
Icon(Icons.Default.Refresh, "Refresh")
}
```
### Tooltip Best Practices
- Show keyboard shortcut in tooltip
- Use native modifier name (Cmd vs Ctrl)
- Brief description + shortcut
### Context Menus
Right-click should show context menu:
```kotlin
// Future: Compose Desktop context menu API
Box(
modifier = Modifier.contextMenuArea(
items = {
listOf(
ContextMenuItem("Copy") { /* ... */ },
ContextMenuItem("Paste") { /* ... */ }
)
}
)
) {
// Content
}
```
**Current:** Use popup or custom implementation.
### Window State Persistence
Save/restore window size/position:
```kotlin
// Save on close
windowState.size // DpSize
windowState.position // WindowPosition
// Restore on launch
val savedWidth = preferences.getInt("window.width", 1200)
val savedHeight = preferences.getInt("window.height", 800)
val windowState = rememberWindowState(
width = savedWidth.dp,
height = savedHeight.dp
)
```
---
## 9. Desktop Module Structure
```
desktopApp/
├── build.gradle.kts # Desktop-only build config
└── src/
└── jvmMain/
├── kotlin/
│ └── com/vitorpamplona/amethyst/desktop/
│ ├── Main.kt # Entry point, Window, MenuBar
│ ├── network/
│ │ ├── DesktopHttpClient.kt
│ │ └── DesktopRelayConnectionManager.kt
│ └── ui/
│ ├── FeedScreen.kt # Desktop screen layouts
│ └── LoginScreen.kt
└── resources/
├── icon.icns # macOS icon
├── icon.ico # Windows icon
└── icon.png # Linux icon
```
**Key files:**
- `Main.kt:87-138` - `application {}`, `Window`, `MenuBar`
- `Main.kt:183-264` - NavigationRail pattern
- `build.gradle.kts:45-73` - Desktop packaging config
---
## 10. Packaging & Distribution
### Build Configuration
```kotlin
// desktopApp/build.gradle.kts
compose.desktop {
application {
mainClass = "com.vitorpamplona.amethyst.desktop.MainKt"
nativeDistributions {
targetFormats(TargetFormat.Dmg, TargetFormat.Msi, TargetFormat.Deb)
packageName = "Amethyst"
packageVersion = "1.0.0"
macOS {
bundleID = "com.vitorpamplona.amethyst.desktop"
iconFile.set(project.file("src/jvmMain/resources/icon.icns"))
}
windows {
iconFile.set(project.file("src/jvmMain/resources/icon.ico"))
menuGroup = "Amethyst"
}
linux {
iconFile.set(project.file("src/jvmMain/resources/icon.png"))
}
}
}
}
```
**See:** desktopApp/build.gradle.kts:45-73
### Gradle Tasks
```bash
# Run desktop app
./gradlew :desktopApp:run
# Package for distribution
./gradlew :desktopApp:packageDmg # macOS
./gradlew :desktopApp:packageMsi # Windows
./gradlew :desktopApp:packageDeb # Linux
```
**Delegate packaging issues to gradle-expert.**
---
## Common Patterns
### Pattern: OS-Aware Shortcuts Helper
```kotlin
// commons/src/jvmMain/kotlin/shortcuts/ShortcutUtils.kt
object DesktopShortcuts {
private val isMacOS = System.getProperty("os.name")
.lowercase().contains("mac")
fun primary(key: Key) = if (isMacOS) {
KeyShortcut(key, meta = true)
} else {
KeyShortcut(key, ctrl = true)
}
fun primaryShift(key: Key) = if (isMacOS) {
KeyShortcut(key, meta = true, shift = true)
} else {
KeyShortcut(key, ctrl = true, shift = true)
}
val modifierName = if (isMacOS) "Cmd" else "Ctrl"
}
// Usage in MenuBar
Item(
"New Note",
shortcut = DesktopShortcuts.primary(Key.N),
onClick = { /* ... */ }
)
```
### Pattern: Shared Composables, Platform Layouts
```kotlin
// commons/commonMain - Shared NoteCard
@Composable
fun NoteCard(note: NoteDisplayData) {
// Business logic, UI component (shared)
}
// desktopApp/jvmMain - Desktop layout
@Composable
fun FeedScreen() {
Column {
FeedHeader(/* ... */) // Shared from commons
LazyColumn {
items(notes) { note ->
NoteCard(note) // Shared composable
}
}
}
}
// amethyst/androidMain - Android layout
@Composable
fun FeedScreen() {
Scaffold(
bottomBar = { BottomNavigationBar() } // Android-specific
) {
LazyColumn {
items(notes) { note ->
NoteCard(note) // Same shared composable
}
}
}
}
```
**Philosophy:** Share UI components (cards, buttons), keep navigation/layout platform-specific.
---
## Resources
### Official Documentation
- [Desktop-only API | Kotlin Multiplatform](https://www.jetbrains.com/help/kotlin-multiplatform-dev/compose-desktop-components.html)
- [Top-level windows management](https://kotlinlang.org/docs/multiplatform/compose-desktop-top-level-windows-management.html)
- [Tray/MenuBar Tutorial](https://github.com/JetBrains/compose-multiplatform/blob/master/tutorials/Tray_Notifications_MenuBar_new/README.md)
### Bundled References
- `references/desktop-compose-apis.md` - Complete Desktop API catalog
- `references/desktop-navigation.md` - NavigationRail vs BottomNav patterns
- `references/keyboard-shortcuts.md` - Standard shortcuts by OS
- `references/os-detection.md` - Platform detection patterns
### Codebase Examples
- Main.kt:87-138 - Window, MenuBar entry point
- Main.kt:183-264 - NavigationRail pattern
- FeedScreen.kt:49-136 - Desktop screen layout
- LoginScreen.kt:44-97 - Centered desktop login
---
## Questions to Ask
When working on desktop features:
1. **Should this be shared or desktop-only?**
- Business logic → Share in `commonMain`
- Navigation/layout → Keep in `desktopApp/jvmMain`
2. **Does this need OS-specific behavior?**
- Keyboard shortcuts → Yes (Cmd vs Ctrl)
- File paths → Yes (separators)
- Icons → Yes (per-OS formats)
3. **Is there a desktop UX convention?**
- Check MenuBar standards
- Consider keyboard-first design
- Tooltips for all actions
4. **Does this need gradle-expert?**
- Any `build.gradle.kts` changes → Delegate
- Packaging/distribution issues → Delegate
---
## Anti-Patterns
**Hardcoding Ctrl everywhere**
```kotlin
// Main.kt:105 - Current issue
shortcut = KeyShortcut(Key.N, ctrl = true) // Wrong on macOS
```
**OS-aware shortcuts**
```kotlin
shortcut = DesktopShortcuts.primary(Key.N)
```
---
**Using Android navigation on Desktop**
```kotlin
Scaffold(bottomBar = { BottomNavigationBar() }) // Wrong for desktop
```
**NavigationRail for desktop**
```kotlin
Row {
NavigationRail { /* ... */ }
MainContent()
}
```
---
**No keyboard shortcuts**
```kotlin
IconButton(onClick = { refresh() }) {
Icon(Icons.Default.Refresh, "Refresh")
}
```
**Shortcuts + tooltips**
```kotlin
IconButton(
onClick = { refresh() },
modifier = Modifier.tooltipArea("Refresh (Cmd+R)")
) {
Icon(Icons.Default.Refresh, "Refresh")
}
```
---
## Next Steps
When implementing desktop features:
1. **Read** `references/desktop-compose-apis.md` for API catalog
2. **Check** `references/keyboard-shortcuts.md` for standard shortcuts
3. **Reference** Main.kt:87-264 for current patterns
4. **Test** on all 3 platforms (macOS, Windows, Linux) if possible
5. **Delegate** build issues to gradle-expert
6. **Share** UI components via compose-expert, not desktop-expert
---
**Version:** 1.0.0
**Last Updated:** 2025-12-30
**Codebase Reference:** AmethystMultiplatform commit 258c4e011
@@ -0,0 +1,597 @@
# Desktop Compose APIs Catalog
Complete reference for Compose Multiplatform Desktop-only APIs.
## Window Management
### application
Root entry point for desktop apps.
```kotlin
fun main() = application {
Window(onCloseRequest = ::exitApplication) {
Text("Hello Desktop")
}
}
```
### Window
Creates a window.
```kotlin
Window(
onCloseRequest: () -> Unit,
state: WindowState = rememberWindowState(),
visible: Boolean = true,
title: String = "Untitled",
icon: Painter? = null,
undecorated: Boolean = false,
transparent: Boolean = false,
resizable: Boolean = true,
enabled: Boolean = true,
focusable: Boolean = true,
alwaysOnTop: Boolean = false,
onPreviewKeyEvent: ((KeyEvent) -> Boolean) = { false },
onKeyEvent: ((KeyEvent) -> Boolean) = { false },
content: @Composable FrameWindowScope.() -> Unit
)
```
**Example:**
```kotlin
val windowState = rememberWindowState(
width = 1200.dp,
height = 800.dp,
position = WindowPosition.Aligned(Alignment.Center)
)
Window(
onCloseRequest = ::exitApplication,
state = windowState,
title = "My App",
resizable = true
) {
// Content
}
```
### rememberWindowState
Manages window size and position.
```kotlin
@Composable
fun rememberWindowState(
placement: WindowPlacement = WindowPlacement.Floating,
isMinimized: Boolean = false,
position: WindowPosition = WindowPosition.PlatformDefault,
width: Dp = Dp.Unspecified,
height: Dp = Dp.Unspecified
): WindowState
```
**WindowPlacement:**
- `Floating` - Normal window
- `Maximized` - Fullscreen
- `Fullscreen` - Fullscreen without decorations
**WindowPosition:**
- `PlatformDefault` - OS decides
- `Aligned(alignment)` - Center, TopStart, etc.
- `Absolute(x, y)` - Fixed position in pixels
### DialogWindow
Modal dialog.
```kotlin
DialogWindow(
onCloseRequest: () -> Unit,
state: DialogState = rememberDialogState(),
visible: Boolean = true,
title: String = "Dialog",
icon: Painter? = null,
undecorated: Boolean = false,
transparent: Boolean = false,
resizable: Boolean = true,
enabled: Boolean = true,
focusable: Boolean = true,
content: @Composable DialogWindowScope.() -> Unit
)
```
**Example:**
```kotlin
var showDialog by remember { mutableStateOf(false) }
if (showDialog) {
DialogWindow(
onCloseRequest = { showDialog = false },
title = "Confirm"
) {
Column(Modifier.padding(16.dp)) {
Text("Are you sure?")
Row {
Button(onClick = { showDialog = false }) {
Text("Cancel")
}
Button(onClick = { /* confirm */ }) {
Text("OK")
}
}
}
}
}
```
---
## MenuBar
### MenuBar
Native menu bar for windows.
```kotlin
@Composable
fun FrameWindowScope.MenuBar(
content: @Composable MenuBarScope.() -> Unit
)
```
**Example:**
```kotlin
Window(onCloseRequest = ::exitApplication) {
MenuBar {
Menu("File") {
Item("New", onClick = { /* ... */ })
Item("Open", onClick = { /* ... */ })
Separator()
Item("Quit", onClick = ::exitApplication)
}
Menu("Edit") {
Item("Copy", onClick = { /* ... */ })
Item("Paste", onClick = { /* ... */ })
}
}
}
```
### Menu
Top-level menu.
```kotlin
@Composable
fun MenuBarScope.Menu(
text: String,
mnemonic: Char? = null,
enabled: Boolean = true,
content: @Composable MenuScope.() -> Unit
)
```
### Item
Menu item.
```kotlin
@Composable
fun MenuScope.Item(
text: String,
onClick: () -> Unit,
shortcut: KeyShortcut? = null,
mnemonic: Char? = null,
enabled: Boolean = true,
icon: Painter? = null
)
```
**With keyboard shortcut:**
```kotlin
Item(
text = "Save",
onClick = { save() },
shortcut = KeyShortcut(Key.S, ctrl = true),
icon = painterResource("save.png")
)
```
### Separator
Menu separator line.
```kotlin
@Composable
fun MenuScope.Separator()
```
### CheckboxItem
Toggleable menu item.
```kotlin
@Composable
fun MenuScope.CheckboxItem(
text: String,
checked: Boolean,
onCheckedChange: (Boolean) -> Unit,
shortcut: KeyShortcut? = null,
mnemonic: Char? = null,
enabled: Boolean = true
)
```
**Example:**
```kotlin
var darkMode by remember { mutableStateOf(false) }
Menu("View") {
CheckboxItem(
text = "Dark Mode",
checked = darkMode,
onCheckedChange = { darkMode = it },
shortcut = KeyShortcut(Key.D, ctrl = true)
)
}
```
### RadioButtonItem
Radio button menu item.
```kotlin
@Composable
fun MenuScope.RadioButtonItem(
text: String,
selected: Boolean,
onClick: () -> Unit,
shortcut: KeyShortcut? = null,
mnemonic: Char? = null,
enabled: Boolean = true
)
```
---
## System Tray
### Tray
System tray icon with menu.
```kotlin
@Composable
fun ApplicationScope.Tray(
icon: Painter,
state: TrayState = rememberTrayState(),
tooltip: String? = null,
onAction: () -> Unit = {},
menu: @Composable MenuScope.() -> Unit = {}
)
```
**Example:**
```kotlin
application {
var isVisible by remember { mutableStateOf(true) }
Tray(
icon = painterResource("tray-icon.png"),
tooltip = "My App",
onAction = { isVisible = true },
menu = {
Item("Show Window", onClick = { isVisible = true })
Separator()
Item("Quit", onClick = ::exitApplication)
}
)
if (isVisible) {
Window(
onCloseRequest = { isVisible = false },
title = "App"
) {
// Content
}
}
}
```
### rememberTrayState
Manages tray state.
```kotlin
@Composable
fun rememberTrayState(): TrayState
```
---
## Notifications
### Notification (via Tray)
Show desktop notifications through tray.
```kotlin
val trayState = rememberTrayState()
Tray(
icon = painterResource("icon.png"),
state = trayState
)
// Send notification
LaunchedEffect(Unit) {
trayState.sendNotification(
Notification(
title = "Message",
message = "You have a new message",
type = Notification.Type.Info
)
)
}
```
**Notification types:**
- `Info` - Information
- `Warning` - Warning
- `Error` - Error
---
## Keyboard
### KeyShortcut
Keyboard shortcut definition.
```kotlin
data class KeyShortcut(
val key: Key,
val ctrl: Boolean = false,
val meta: Boolean = false,
val alt: Boolean = false,
val shift: Boolean = false
)
```
**Examples:**
```kotlin
// Ctrl+S (Windows/Linux)
KeyShortcut(Key.S, ctrl = true)
// Cmd+S (macOS)
KeyShortcut(Key.S, meta = true)
// Ctrl+Shift+N
KeyShortcut(Key.N, ctrl = true, shift = true)
// Alt+F4
KeyShortcut(Key.F4, alt = true)
```
### onPreviewKeyEvent / onKeyEvent
Window-level keyboard handlers.
```kotlin
Window(
onCloseRequest = ::exitApplication,
onPreviewKeyEvent = { event ->
if (event.key == Key.Escape && event.type == KeyEventType.KeyDown) {
// Handle Escape
true // Consume event
} else {
false // Propagate
}
}
) {
// Content
}
```
---
## Mouse
### PointerMoveFilter (Deprecated, use Modifier.pointerInput)
```kotlin
Box(
modifier = Modifier
.pointerInput(Unit) {
awaitPointerEventScope {
while (true) {
val event = awaitPointerEvent()
// Handle mouse events
}
}
}
)
```
### Mouse cursor
```kotlin
Box(
modifier = Modifier.pointerHoverIcon(
icon = PointerIcon(Cursor.getPredefinedCursor(Cursor.HAND_CURSOR))
)
) {
Text("Hover me")
}
```
**Cursor types:**
- `DEFAULT_CURSOR`
- `HAND_CURSOR`
- `TEXT_CURSOR`
- `CROSSHAIR_CURSOR`
- `WAIT_CURSOR`
- `MOVE_CURSOR`
- `E_RESIZE_CURSOR`, `W_RESIZE_CURSOR`, etc.
---
## Drag & Drop (Experimental)
### onExternalDrag
Handle drag-and-drop from external sources.
```kotlin
Box(
modifier = Modifier
.size(200.dp)
.background(Color.LightGray)
.onExternalDrag(
onDragStart = { externalDragValue ->
println("Drag started")
},
onDrag = { externalDragValue ->
println("Dragging: ${externalDragValue.dragData}")
},
onDragExit = {
println("Drag exited")
},
onDrop = { externalDragValue ->
val dragData = externalDragValue.dragData
when (dragData) {
is DragData.FilesList -> {
println("Files dropped: ${dragData.readFiles()}")
}
is DragData.Text -> {
println("Text dropped: ${dragData.readText()}")
}
else -> {}
}
}
)
) {
Text("Drop files here", Modifier.align(Alignment.Center))
}
```
---
## Resources
### painterResource
Load images from resources.
```kotlin
val icon = painterResource("icon.png")
Icon(
painter = icon,
contentDescription = "App icon"
)
```
**Resource location:** `src/jvmMain/resources/`
---
## Platform Integration
### Desktop.getDesktop() (AWT)
Access system desktop features (not Compose API, but commonly used).
```kotlin
import java.awt.Desktop
import java.net.URI
// Open URL in browser
if (Desktop.isDesktopSupported()) {
Desktop.getDesktop().browse(URI("https://example.com"))
}
// Open file with default app
Desktop.getDesktop().open(File("/path/to/file.pdf"))
// Open email client
Desktop.getDesktop().mail(URI("mailto:user@example.com"))
```
### FileDialog (AWT)
File picker dialogs.
```kotlin
import java.awt.FileDialog
import java.awt.Frame
// Open file
val fileDialog = FileDialog(Frame(), "Select file", FileDialog.LOAD)
fileDialog.isVisible = true
val selectedFile = fileDialog.file
val directory = fileDialog.directory
// Save file
val saveDialog = FileDialog(Frame(), "Save file", FileDialog.SAVE)
saveDialog.file = "document.txt"
saveDialog.isVisible = true
```
---
## SwingPanel (Interop)
Embed Swing components in Compose.
```kotlin
import androidx.compose.ui.awt.SwingPanel
import javax.swing.JButton
SwingPanel(
factory = {
JButton("Swing Button").apply {
addActionListener {
println("Swing button clicked")
}
}
},
modifier = Modifier.size(200.dp, 50.dp)
)
```
---
## ComposePanel (Reverse Interop)
Embed Compose in Swing.
```kotlin
import androidx.compose.ui.awt.ComposePanel
import javax.swing.JFrame
val frame = JFrame("Swing Frame")
val composePanel = ComposePanel()
composePanel.setContent {
Text("Compose in Swing")
}
frame.contentPane.add(composePanel)
frame.setSize(400, 300)
frame.isVisible = true
```
---
## Version Requirements
- **Kotlin:** 2.0+
- **Compose Multiplatform:** 1.7.0+
- **JVM Target:** 11+ (recommend 21)
**See also:**
- [Official Desktop API docs](https://www.jetbrains.com/help/kotlin-multiplatform-dev/compose-desktop-components.html)
- [Compose Multiplatform repo](https://github.com/JetBrains/compose-multiplatform)
@@ -0,0 +1,464 @@
# Desktop Navigation Patterns
Comparison of mobile vs desktop navigation patterns in AmethystMultiplatform.
## Core Difference
| Platform | Pattern | Location | Rationale |
|----------|---------|----------|-----------|
| **Android** | Bottom Navigation Bar | Horizontal, bottom | Thumb reach on mobile |
| **Desktop** | Navigation Rail | Vertical, left sidebar | Horizontal screen space |
---
## Desktop: NavigationRail
### Current Implementation
**File:** `desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt:191-264`
```kotlin
@Composable
fun MainContent(
currentScreen: AppScreen,
onScreenChange: (AppScreen) -> Unit,
// ...
) {
Row(Modifier.fillMaxSize()) {
// LEFT: Vertical Sidebar (NavigationRail)
NavigationRail(
modifier = Modifier.width(80.dp).fillMaxHeight(),
containerColor = MaterialTheme.colorScheme.surfaceVariant
) {
Spacer(Modifier.height(16.dp))
// Top navigation items
NavigationRailItem(
icon = { Icon(Icons.Default.Home, "Feed") },
label = { Text("Feed") },
selected = currentScreen == AppScreen.Feed,
onClick = { onScreenChange(AppScreen.Feed) }
)
NavigationRailItem(
icon = { Icon(Icons.Default.Search, "Search") },
label = { Text("Search") },
selected = currentScreen == AppScreen.Search,
onClick = { onScreenChange(AppScreen.Search) }
)
NavigationRailItem(
icon = { Icon(Icons.Default.Email, "Messages") },
label = { Text("DMs") },
selected = currentScreen == AppScreen.Messages,
onClick = { onScreenChange(AppScreen.Messages) }
)
NavigationRailItem(
icon = { Icon(Icons.Default.Notifications, "Notifications") },
label = { Text("Alerts") },
selected = currentScreen == AppScreen.Notifications,
onClick = { onScreenChange(AppScreen.Notifications) }
)
NavigationRailItem(
icon = { Icon(Icons.Default.Person, "Profile") },
label = { Text("Profile") },
selected = currentScreen == AppScreen.Profile,
onClick = { onScreenChange(AppScreen.Profile) }
)
// Push Settings to bottom
Spacer(Modifier.weight(1f))
HorizontalDivider(Modifier.padding(horizontal = 16.dp))
NavigationRailItem(
icon = { Icon(Icons.Default.Settings, "Settings") },
label = { Text("Settings") },
selected = currentScreen == AppScreen.Settings,
onClick = { onScreenChange(AppScreen.Settings) }
)
Spacer(Modifier.height(16.dp))
}
VerticalDivider()
// RIGHT: Main Content Area
Box(modifier = Modifier.weight(1f).fillMaxHeight().padding(24.dp)) {
when (currentScreen) {
AppScreen.Feed -> FeedScreen(relayManager)
AppScreen.Search -> SearchPlaceholder()
AppScreen.Messages -> MessagesPlaceholder()
AppScreen.Notifications -> NotificationsPlaceholder()
AppScreen.Profile -> ProfileScreen(account, accountManager)
AppScreen.Settings -> RelaySettingsScreen(relayManager)
}
}
}
}
```
### Layout Structure
```
┌────────────────────────────────────────┐
│ [Menu Bar: File, Edit, View, Help] │ ← MenuBar (OS-native)
├──────┬─────────────────────────────────┤
│ │ │
│ [🏠] │ │
│ Feed │ │
│ │ │
│ [🔍] │ Main Content Area │
│Search│ (Feed, Messages, etc.) │
│ │ │
│ [✉️] │ │
│ DMs │ │
│ │ │
│ [🔔] │ │
│Alerts│ │
│ │ │
│ [👤] │ │
│Profile │
│ │ │
│ ─ │ │
│ [⚙️] │ │
│Settings │
│ │ │
└──────┴─────────────────────────────────┘
80dp Remaining width (weight=1f)
```
### Key Features
1. **Always visible:** All nav items visible at once
2. **Icon + Label:** Both shown (not just icons)
3. **Vertical list:** Natural reading order
4. **Settings at bottom:** Separated by divider + Spacer.weight(1f)
5. **80dp width:** Standard NavigationRail width
---
## Android: BottomNavigationBar (Future)
### Expected Implementation
**Location:** `amethyst/src/androidMain/kotlin/...` (not yet implemented)
```kotlin
@Composable
fun MainScreen(
currentScreen: AppScreen,
onScreenChange: (AppScreen) -> Unit
) {
Scaffold(
bottomBar = {
NavigationBar {
NavigationBarItem(
icon = { Icon(Icons.Default.Home, "Feed") },
label = { Text("Feed") },
selected = currentScreen == AppScreen.Feed,
onClick = { onScreenChange(AppScreen.Feed) }
)
NavigationBarItem(
icon = { Icon(Icons.Default.Search, "Search") },
label = { Text("Search") },
selected = currentScreen == AppScreen.Search,
onClick = { onScreenChange(AppScreen.Search) }
)
NavigationBarItem(
icon = { Icon(Icons.Default.Email, "Messages") },
label = { Text("Messages") },
selected = currentScreen == AppScreen.Messages,
onClick = { onScreenChange(AppScreen.Messages) }
)
NavigationBarItem(
icon = { Icon(Icons.Default.Person, "Profile") },
label = { Text("Profile") },
selected = currentScreen == AppScreen.Profile,
onClick = { onScreenChange(AppScreen.Profile) }
)
}
}
) { paddingValues ->
Box(Modifier.padding(paddingValues)) {
when (currentScreen) {
AppScreen.Feed -> FeedScreen()
AppScreen.Search -> SearchScreen()
AppScreen.Messages -> MessagesScreen()
AppScreen.Profile -> ProfileScreen()
// Settings accessed via Profile or overflow menu
}
}
}
}
```
### Layout Structure
```
┌─────────────────────────────────────┐
│ │
│ │
│ Main Content Area │
│ (Feed, Messages, etc.) │
│ │
│ │
│ │
├─────────────────────────────────────┤
│ [🏠] [🔍] [✉️] [👤] │ ← NavigationBar
│ Feed Search DMs Profile │
└─────────────────────────────────────┘
```
### Key Differences from Desktop
1. **Bottom placement:** Thumb reach
2. **Horizontal layout:** Limited vertical space
3. **Fewer items:** 3-5 primary destinations
4. **Label optional:** Can hide on small screens
5. **Settings hidden:** In profile or overflow
---
## Shared Navigation State
Both platforms use the same `AppScreen` enum from `commons`.
**File:** `commons/src/commonMain/kotlin/.../navigation/AppScreen.kt` (expected)
```kotlin
// Shared navigation destinations
enum class AppScreen {
Feed,
Search,
Messages,
Notifications,
Profile,
Settings
}
```
**State management (shared):**
```kotlin
// commons/src/jvmAndroid/kotlin/.../navigation/NavigationViewModel.kt
class NavigationViewModel : ViewModel() {
private val _currentScreen = MutableStateFlow(AppScreen.Feed)
val currentScreen: StateFlow<AppScreen> = _currentScreen.asStateFlow()
fun navigateTo(screen: AppScreen) {
_currentScreen.value = screen
}
}
```
---
## Multi-Pane Desktop Layout (Advanced)
Desktop can utilize horizontal space for multi-pane layouts.
### Two-Pane Layout
```kotlin
Row(Modifier.fillMaxSize()) {
// Left: NavigationRail (fixed 80dp)
NavigationRail { /* ... */ }
VerticalDivider()
// Center: Main content (60% width)
Box(Modifier.weight(0.6f)) {
FeedScreen()
}
// Right: Detail pane (40% width, conditional)
if (selectedNote != null) {
VerticalDivider()
Box(Modifier.weight(0.4f)) {
NoteDetailPane(selectedNote)
}
}
}
```
### Layout:
```
┌──────┬───────────────────┬─────────────┐
│ │ │ │
│ Nav │ Feed List │ Detail │
│ Rail │ (60%) │ Pane │
│ │ │ (40%) │
│ │ │ │
└──────┴───────────────────┴─────────────┘
80dp weight(0.6f) weight(0.4f)
```
**Use cases:**
- Email: List + message detail
- Notes: List + editor
- Settings: Categories + options
---
## Keyboard Navigation
Desktop should support keyboard navigation.
### Tab Navigation
```kotlin
NavigationRail(
modifier = Modifier.focusable()
) {
NavigationRailItem(
icon = { Icon(Icons.Default.Home, "Feed") },
label = { Text("Feed") },
selected = currentScreen == AppScreen.Feed,
onClick = { onScreenChange(AppScreen.Feed) },
modifier = Modifier.focusable()
)
// More items...
}
```
### Keyboard Shortcuts
```kotlin
Window(
onPreviewKeyEvent = { event ->
when {
event.key == Key.One && event.isCtrlPressed ->
onScreenChange(AppScreen.Feed).also { true }
event.key == Key.Two && event.isCtrlPressed ->
onScreenChange(AppScreen.Search).also { true }
event.key == Key.Three && event.isCtrlPressed ->
onScreenChange(AppScreen.Messages).also { true }
else -> false
}
}
) {
// Content
}
```
**Standard:**
- Ctrl+1: First nav item (Feed)
- Ctrl+2: Second nav item (Search)
- Ctrl+3: Third nav item (Messages)
- Ctrl+Comma: Settings
---
## Navigation Transitions
### Desktop (Instant)
No fancy animations. Instant switch.
```kotlin
Box {
when (currentScreen) {
AppScreen.Feed -> FeedScreen()
AppScreen.Search -> SearchScreen()
}
}
```
### Android (Animated, Future)
Can use Navigation Compose for transitions.
```kotlin
NavHost(navController, startDestination = "feed") {
composable("feed") { FeedScreen() }
composable("search") { SearchScreen() }
}
```
---
## Best Practices
### Desktop NavigationRail
**DO:**
- Keep width 72-80dp
- Show both icon and label
- Use Spacer.weight(1f) for bottom items
- Separate sections with HorizontalDivider
- Limit to 5-7 primary items
**DON'T:**
- Use bottom navigation on desktop
- Hide labels (plenty of space)
- Make it collapsible (not standard)
- Use hamburger menu (not desktop pattern)
### Android NavigationBar
**DO:**
- Limit to 3-5 items
- Use bottom placement
- Consider label visibility on small screens
- Use standard icons
**DON'T:**
- Put more than 5 items
- Use top placement (deprecated)
- Put critical actions only in nav bar
---
## Migration Strategy
When adding Android support:
1. **Extract shared state:** Move `AppScreen` to `commons/commonMain`
2. **Platform layouts:** Keep `NavigationRail` in `desktopApp/jvmMain`, `NavigationBar` in `amethyst/androidMain`
3. **Shared screens:** Composables in `commons/commonMain` (FeedScreen content)
4. **Platform chrome:** Navigation containers in platform modules
**Example:**
```kotlin
// commons/commonMain - Shared screen content
@Composable
fun FeedContent(notes: List<Note>) {
LazyColumn {
items(notes) { note ->
NoteCard(note)
}
}
}
// desktopApp/jvmMain - Desktop wrapper
@Composable
fun FeedScreen() {
Column {
FeedHeader() // Desktop-specific header
FeedContent(notes) // Shared content
}
}
// amethyst/androidMain - Android wrapper
@Composable
fun FeedScreen() {
Scaffold(
topBar = { TopAppBar { Text("Feed") } }
) {
FeedContent(notes) // Same shared content
}
}
```
---
## References
- **Current Desktop:** Main.kt:191-264
- **Material3 NavigationRail:** [Material Design Docs](https://m3.material.io/components/navigation-rail)
- **Material3 NavigationBar:** [Material Design Docs](https://m3.material.io/components/navigation-bar)
@@ -0,0 +1,400 @@
# Keyboard Shortcuts Reference
Standard keyboard shortcuts for desktop applications across macOS, Windows, and Linux.
## Primary Modifier Keys
| Platform | Primary | Secondary | Tertiary |
|----------|---------|-----------|----------|
| **macOS** | Cmd (⌘) / `meta` | Option (⌥) / `alt` | Ctrl (⌃) / `ctrl` |
| **Windows** | Ctrl / `ctrl` | Alt / `alt` | Win / `meta` |
| **Linux** | Ctrl / `ctrl` | Alt / `alt` | Super / `meta` |
**In Compose Desktop:**
```kotlin
// macOS
KeyShortcut(Key.N, meta = true) // Cmd+N
// Windows/Linux
KeyShortcut(Key.N, ctrl = true) // Ctrl+N
```
---
## File Operations
| Action | macOS | Windows | Linux | Notes |
|--------|-------|---------|-------|-------|
| **New** | Cmd+N | Ctrl+N | Ctrl+N | Create new |
| **Open** | Cmd+O | Ctrl+O | Ctrl+O | Open file |
| **Save** | Cmd+S | Ctrl+S | Ctrl+S | Save current |
| **Save As** | Cmd+Shift+S | Ctrl+Shift+S | Ctrl+Shift+S | Save with new name |
| **Close** | Cmd+W | Ctrl+W | Ctrl+W | Close window/tab |
| **Quit** | Cmd+Q | Ctrl+Q | Ctrl+Q | Exit app |
| **Print** | Cmd+P | Ctrl+P | Ctrl+P | Print |
**Compose Implementation:**
```kotlin
val isMacOS = System.getProperty("os.name").lowercase().contains("mac")
MenuBar {
Menu("File") {
Item(
"New Note",
shortcut = if (isMacOS) {
KeyShortcut(Key.N, meta = true)
} else {
KeyShortcut(Key.N, ctrl = true)
},
onClick = { createNewNote() }
)
Item(
"Save",
shortcut = if (isMacOS) {
KeyShortcut(Key.S, meta = true)
} else {
KeyShortcut(Key.S, ctrl = true)
},
onClick = { save() }
)
Separator()
Item(
"Quit",
shortcut = if (isMacOS) {
KeyShortcut(Key.Q, meta = true)
} else {
KeyShortcut(Key.Q, ctrl = true)
},
onClick = ::exitApplication
)
}
}
```
---
## Edit Operations
| Action | macOS | Windows | Linux | Notes |
|--------|-------|---------|-------|-------|
| **Undo** | Cmd+Z | Ctrl+Z | Ctrl+Z | Universal |
| **Redo** | Cmd+Shift+Z | Ctrl+Y | Ctrl+Y | Windows/Linux use Y |
| **Cut** | Cmd+X | Ctrl+X | Ctrl+X | Universal |
| **Copy** | Cmd+C | Ctrl+C | Ctrl+C | Universal |
| **Paste** | Cmd+V | Ctrl+V | Ctrl+V | Universal |
| **Select All** | Cmd+A | Ctrl+A | Ctrl+A | Universal |
| **Find** | Cmd+F | Ctrl+F | Ctrl+F | Search |
| **Find Next** | Cmd+G | F3 | F3 | Next result |
| **Replace** | Cmd+Option+F | Ctrl+H | Ctrl+H | Find & replace |
**Note:** Undo/Redo typically handled by text fields automatically.
---
## Navigation
| Action | macOS | Windows | Linux | Notes |
|--------|-------|---------|-------|-------|
| **Tab 1** | Cmd+1 | Ctrl+1 | Ctrl+1 | First tab/view |
| **Tab 2** | Cmd+2 | Ctrl+2 | Ctrl+2 | Second tab/view |
| **Tab 3** | Cmd+3 | Ctrl+3 | Ctrl+3 | Third tab/view |
| **Next Tab** | Cmd+Option+→ | Ctrl+Tab | Ctrl+Tab | Cycle forward |
| **Prev Tab** | Cmd+Option+← | Ctrl+Shift+Tab | Ctrl+Shift+Tab | Cycle back |
| **Go Back** | Cmd+[ | Alt+← | Alt+← | Browser-style |
| **Go Forward** | Cmd+] | Alt+→ | Alt+→ | Browser-style |
**Compose Implementation:**
```kotlin
Window(
onPreviewKeyEvent = { event ->
if (event.type == KeyEventType.KeyDown) {
when {
event.key == Key.One && event.isPrimaryPressed() -> {
navigateTo(AppScreen.Feed)
true
}
event.key == Key.Two && event.isPrimaryPressed() -> {
navigateTo(AppScreen.Search)
true
}
event.key == Key.Three && event.isPrimaryPressed() -> {
navigateTo(AppScreen.Messages)
true
}
else -> false
}
} else false
}
) {
// Content
}
// Helper extension
fun KeyEvent.isPrimaryPressed() = if (isMacOS) isMetaPressed else isCtrlPressed
```
---
## Window Management
| Action | macOS | Windows | Linux | Notes |
|--------|-------|---------|-------|-------|
| **New Window** | Cmd+N | Ctrl+N | Ctrl+N | New instance |
| **Close Window** | Cmd+W | Alt+F4 | Alt+F4 | Close current |
| **Minimize** | Cmd+M | Win+Down | Super+Down | Minimize to dock/taskbar |
| **Maximize** | Cmd+Ctrl+F | Win+Up | Super+Up | Fullscreen/maximize |
| **Hide App** | Cmd+H | - | - | macOS only |
| **Switch Window** | Cmd+` | Alt+Tab | Alt+Tab | Between app windows |
**Note:** Window management often handled by OS, not app shortcuts.
---
## App-Specific (Amethyst)
### Nostr Actions
| Action | macOS | Windows | Linux | Description |
|--------|-------|---------|-------|-------------|
| **New Note** | Cmd+N | Ctrl+N | Ctrl+N | Compose new post |
| **Refresh Feed** | Cmd+R | Ctrl+R | Ctrl+R | Reload timeline |
| **Search** | Cmd+K | Ctrl+K | Ctrl+K | Quick search |
| **DMs** | Cmd+Shift+M | Ctrl+Shift+M | Ctrl+Shift+M | Open messages |
| **Settings** | Cmd+, | Ctrl+, | Ctrl+, | Open preferences |
| **Notifications** | Cmd+Shift+N | Ctrl+Shift+N | Ctrl+Shift+N | View alerts |
**Implementation:**
```kotlin
MenuBar {
Menu("File") {
Item(
"New Note",
shortcut = DesktopShortcuts.primary(Key.N),
onClick = { showComposeDialog() }
)
Item(
"Settings",
shortcut = DesktopShortcuts.primary(Key.Comma),
onClick = { navigateTo(AppScreen.Settings) }
)
}
Menu("View") {
Item(
"Refresh Feed",
shortcut = DesktopShortcuts.primary(Key.R),
onClick = { refreshFeed() }
)
Item(
"Search",
shortcut = DesktopShortcuts.primary(Key.K),
onClick = { focusSearch() }
)
}
}
```
---
## Accessibility
| Action | macOS | Windows | Linux | Description |
|--------|-------|---------|-------|-------------|
| **Zoom In** | Cmd++ | Ctrl++ | Ctrl++ | Increase size |
| **Zoom Out** | Cmd+- | Ctrl+- | Ctrl+- | Decrease size |
| **Reset Zoom** | Cmd+0 | Ctrl+0 | Ctrl+0 | Default size |
| **Help** | Cmd+? | F1 | F1 | Show help |
---
## Best Practices
### 1. OS-Aware Helper
Create a utility for OS detection:
```kotlin
// commons/src/jvmMain/kotlin/utils/PlatformShortcuts.kt
object DesktopShortcuts {
private val isMacOS = System.getProperty("os.name")
.lowercase()
.contains("mac")
fun primary(key: Key) = if (isMacOS) {
KeyShortcut(key, meta = true)
} else {
KeyShortcut(key, ctrl = true)
}
fun primaryShift(key: Key) = if (isMacOS) {
KeyShortcut(key, meta = true, shift = true)
} else {
KeyShortcut(key, ctrl = true, shift = true)
}
fun primaryAlt(key: Key) = if (isMacOS) {
KeyShortcut(key, meta = true, alt = true)
} else {
KeyShortcut(key, ctrl = true, alt = true)
}
val modifierName = if (isMacOS) "Cmd" else "Ctrl"
val secondaryName = if (isMacOS) "Option" else "Alt"
}
```
**Usage:**
```kotlin
Item(
"Save",
shortcut = DesktopShortcuts.primary(Key.S),
onClick = { save() }
)
```
### 2. Show Shortcuts in Tooltips
```kotlin
IconButton(
onClick = { refresh() },
modifier = Modifier.tooltipArea {
Text("Refresh (${DesktopShortcuts.modifierName}+R)")
}
) {
Icon(Icons.Default.Refresh, "Refresh")
}
```
### 3. Shortcuts Menu
Provide a "Keyboard Shortcuts" help menu:
```kotlin
Menu("Help") {
Item("Keyboard Shortcuts", onClick = { showShortcutsDialog() })
}
// Dialog content
@Composable
fun ShortcutsDialog() {
Dialog(onDismissRequest = { /* close */ }) {
Surface {
Column(Modifier.padding(16.dp)) {
Text("Keyboard Shortcuts", style = MaterialTheme.typography.headlineMedium)
Spacer(Modifier.height(16.dp))
ShortcutRow("New Note", "${DesktopShortcuts.modifierName}+N")
ShortcutRow("Save", "${DesktopShortcuts.modifierName}+S")
ShortcutRow("Search", "${DesktopShortcuts.modifierName}+K")
ShortcutRow("Settings", "${DesktopShortcuts.modifierName}+,")
// ...
}
}
}
}
@Composable
fun ShortcutRow(action: String, shortcut: String) {
Row(
Modifier.fillMaxWidth().padding(vertical = 4.dp),
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(action, style = MaterialTheme.typography.bodyMedium)
Text(
shortcut,
style = MaterialTheme.typography.bodySmall,
fontFamily = FontFamily.Monospace,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
```
### 4. Avoid Conflicts
**Check for OS-level shortcuts:**
| macOS Reserved | Description |
|----------------|-------------|
| Cmd+Tab | Switch apps |
| Cmd+Space | Spotlight |
| Cmd+H | Hide window |
| Cmd+M | Minimize |
| Cmd+Q | Quit |
| Cmd+W | Close window |
**Windows Reserved:**
| Windows Reserved | Description |
|-----------------|-------------|
| Win+D | Show desktop |
| Win+E | File Explorer |
| Win+L | Lock screen |
| Alt+Tab | Switch apps |
| Alt+F4 | Close window |
**Don't override these unless critical.**
---
## Testing Shortcuts
```kotlin
// Test OS detection
@Test
fun testOsDetection() {
val osName = System.getProperty("os.name")
println("OS: $osName")
val isMacOS = osName.lowercase().contains("mac")
println("Is macOS: $isMacOS")
val shortcut = if (isMacOS) {
KeyShortcut(Key.N, meta = true)
} else {
KeyShortcut(Key.N, ctrl = true)
}
println("Primary modifier for New: $shortcut")
}
```
---
## Current Issues in Amethyst
**Main.kt:105-123** hardcodes `ctrl = true`:
```kotlin
// ❌ WRONG: Hardcoded Ctrl (doesn't work on macOS)
Item(
"New Note",
shortcut = KeyShortcut(Key.N, ctrl = true), // Should be Cmd on macOS
onClick = { /* ... */ }
)
```
**Fix:**
```kotlin
// ✅ CORRECT: OS-aware
Item(
"New Note",
shortcut = DesktopShortcuts.primary(Key.N),
onClick = { /* ... */ }
)
```
---
## References
- [macOS Keyboard Shortcuts](https://support.apple.com/en-us/102650)
- [Windows Keyboard Shortcuts](https://support.microsoft.com/en-us/windows/keyboard-shortcuts-in-windows-dcc61a57-8ff0-cffe-9796-cb9706c75eec)
- [GNOME Keyboard Shortcuts](https://help.gnome.org/users/gnome-help/stable/shell-keyboard-shortcuts.html)
- [Material Design: Keyboard Shortcuts](https://m3.material.io/foundations/interaction/keyboard)
- [Compose Desktop: Keyboard Events](https://www.jetbrains.com/help/kotlin-multiplatform-dev/compose-desktop-keyboard.html)
@@ -0,0 +1,579 @@
# OS Detection & Platform-Specific Code
Patterns for detecting operating system and implementing platform-specific behavior in Compose Desktop.
## OS Detection
### Basic Detection
```kotlin
val osName = System.getProperty("os.name").lowercase()
val isMacOS = osName.contains("mac")
val isWindows = osName.contains("win")
val isLinux = osName.contains("nux") || osName.contains("nix")
```
### System Properties
```kotlin
// OS name
System.getProperty("os.name")
// Examples: "Mac OS X", "Windows 10", "Linux"
// OS version
System.getProperty("os.version")
// Examples: "14.2.1", "10.0", "6.5.0-14-generic"
// OS architecture
System.getProperty("os.arch")
// Examples: "aarch64", "x86_64", "amd64"
// User home directory
System.getProperty("user.home")
// Examples: "/Users/username", "C:\Users\username", "/home/username"
// File separator
System.getProperty("file.separator")
// Examples: "/" (Unix), "\" (Windows)
// Path separator
System.getProperty("path.separator")
// Examples: ":" (Unix), ";" (Windows)
```
---
## PlatformDetector Utility
Create a centralized utility for platform detection.
**File:** `commons/src/jvmMain/kotlin/utils/PlatformDetector.kt`
```kotlin
package com.vitorpamplona.amethyst.commons.utils
object PlatformDetector {
private val osName = System.getProperty("os.name").lowercase()
val isMacOS: Boolean = osName.contains("mac")
val isWindows: Boolean = osName.contains("win")
val isLinux: Boolean = osName.contains("nux") || osName.contains("nix")
val platform: Platform = when {
isMacOS -> Platform.MacOS
isWindows -> Platform.Windows
isLinux -> Platform.Linux
else -> Platform.Unknown
}
enum class Platform {
MacOS,
Windows,
Linux,
Unknown
}
// File paths
val fileSeparator: String = System.getProperty("file.separator")
val pathSeparator: String = System.getProperty("path.separator")
// User directories
val userHome: String = System.getProperty("user.home")
val appDataDir: String = when (platform) {
Platform.MacOS -> "$userHome/Library/Application Support"
Platform.Windows -> System.getenv("APPDATA") ?: "$userHome\\AppData\\Roaming"
Platform.Linux -> System.getenv("XDG_CONFIG_HOME") ?: "$userHome/.config"
Platform.Unknown -> userHome
}
// Modifier key names
val primaryModifierName: String = if (isMacOS) "Cmd" else "Ctrl"
val secondaryModifierName: String = if (isMacOS) "Option" else "Alt"
fun platformSpecific(
macOS: () -> Unit = {},
windows: () -> Unit = {},
linux: () -> Unit = {},
fallback: () -> Unit = {}
) {
when (platform) {
Platform.MacOS -> macOS()
Platform.Windows -> windows()
Platform.Linux -> linux()
Platform.Unknown -> fallback()
}
}
}
```
**Usage:**
```kotlin
// Simple check
if (PlatformDetector.isMacOS) {
// macOS-specific code
}
// Pattern matching
when (PlatformDetector.platform) {
Platform.MacOS -> setupMacDock()
Platform.Windows -> setupWindowsTray()
Platform.Linux -> setupLinuxTray()
Platform.Unknown -> showWarning()
}
// Platform-specific execution
PlatformDetector.platformSpecific(
macOS = { setupMacMenuBar() },
windows = { setupWindowsMenu() },
linux = { setupLinuxMenu() }
)
// File paths
val configPath = "${PlatformDetector.appDataDir}${PlatformDetector.fileSeparator}amethyst"
```
---
## Platform-Specific UI
### Keyboard Shortcuts Helper
```kotlin
package com.vitorpamplona.amethyst.commons.utils
import androidx.compose.ui.input.key.Key
import androidx.compose.ui.input.key.KeyShortcut
object DesktopShortcuts {
private val isMacOS = PlatformDetector.isMacOS
fun primary(key: Key) = if (isMacOS) {
KeyShortcut(key, meta = true)
} else {
KeyShortcut(key, ctrl = true)
}
fun primaryShift(key: Key) = if (isMacOS) {
KeyShortcut(key, meta = true, shift = true)
} else {
KeyShortcut(key, ctrl = true, shift = true)
}
fun primaryAlt(key: Key) = if (isMacOS) {
KeyShortcut(key, meta = true, alt = true)
} else {
KeyShortcut(key, ctrl = true, alt = true)
}
val modifierName = PlatformDetector.primaryModifierName
val secondaryName = PlatformDetector.secondaryModifierName
fun formatShortcut(key: String, withPrimary: Boolean = true): String {
return if (withPrimary) "$modifierName+$key" else key
}
}
```
### File Paths Helper
```kotlin
package com.vitorpamplona.amethyst.commons.utils
import java.io.File
object FilePaths {
private val separator = PlatformDetector.fileSeparator
fun join(vararg parts: String): String {
return parts.joinToString(separator)
}
fun appConfig(appName: String): String {
return join(PlatformDetector.appDataDir, appName)
}
fun appCache(appName: String): String {
return when (PlatformDetector.platform) {
PlatformDetector.Platform.MacOS ->
join(PlatformDetector.userHome, "Library", "Caches", appName)
PlatformDetector.Platform.Windows ->
join(System.getenv("LOCALAPPDATA") ?: "${PlatformDetector.userHome}\\AppData\\Local", appName)
PlatformDetector.Platform.Linux ->
join(System.getenv("XDG_CACHE_HOME") ?: "${PlatformDetector.userHome}/.cache", appName)
else -> join(PlatformDetector.userHome, ".cache", appName)
}
}
fun ensureDirectory(path: String): File {
return File(path).apply {
if (!exists()) {
mkdirs()
}
}
}
}
// Usage
val configDir = FilePaths.ensureDirectory(FilePaths.appConfig("amethyst"))
val cacheDir = FilePaths.ensureDirectory(FilePaths.appCache("amethyst"))
```
---
## Platform-Specific Features
### Open External URL
```kotlin
// commons/src/commonMain/kotlin/utils/ExternalUrl.kt
expect fun openExternalUrl(url: String)
// commons/src/jvmMain/kotlin/utils/ExternalUrl.jvm.kt
import java.awt.Desktop
import java.net.URI
actual fun openExternalUrl(url: String) {
if (Desktop.isDesktopSupported() && Desktop.getDesktop().isSupported(Desktop.Action.BROWSE)) {
Desktop.getDesktop().browse(URI(url))
}
}
```
### File Picker
```kotlin
// Platform-specific file picker
fun showFilePicker(
title: String = "Select file",
mode: FilePickerMode = FilePickerMode.Load
): String? {
val fileDialog = java.awt.FileDialog(
java.awt.Frame(),
title,
when (mode) {
FilePickerMode.Load -> java.awt.FileDialog.LOAD
FilePickerMode.Save -> java.awt.FileDialog.SAVE
}
)
// macOS-specific: Enable file selection features
if (PlatformDetector.isMacOS) {
System.setProperty("apple.awt.fileDialogForDirectories", "false")
}
fileDialog.isVisible = true
return fileDialog.file?.let { "${fileDialog.directory}$it" }
}
enum class FilePickerMode {
Load,
Save
}
```
### Directory Picker (macOS)
```kotlin
fun showDirectoryPicker(title: String = "Select directory"): String? {
if (PlatformDetector.isMacOS) {
// macOS-specific directory picker
System.setProperty("apple.awt.fileDialogForDirectories", "true")
}
val fileDialog = java.awt.FileDialog(java.awt.Frame(), title, java.awt.FileDialog.LOAD)
fileDialog.isVisible = true
if (PlatformDetector.isMacOS) {
System.setProperty("apple.awt.fileDialogForDirectories", "false")
}
return fileDialog.directory
}
```
---
## Window Decorations
### macOS-Specific
```kotlin
// Unified title bar (macOS Big Sur+)
if (PlatformDetector.isMacOS) {
Window(
undecorated = false,
transparent = true,
// ...
) {
// Custom title bar
}
}
```
### Windows-Specific
```kotlin
// Custom window chrome (Windows)
if (PlatformDetector.isWindows) {
Window(
undecorated = true,
// Custom decorations
) {
Column {
// Custom title bar with min/max/close buttons
WindowTitleBar()
// Content
}
}
}
```
---
## System Tray Icons
Different icon formats per OS:
```kotlin
fun getTrayIcon(): Painter {
return when (PlatformDetector.platform) {
Platform.MacOS -> painterResource("tray-icon-mac.png") // Template icon
Platform.Windows -> painterResource("tray-icon-win.ico")
Platform.Linux -> painterResource("tray-icon-linux.png")
else -> painterResource("tray-icon.png")
}
}
// macOS: Template icons (black/transparent)
// Windows: ICO format, 16x16
// Linux: PNG, typically 24x24
```
---
## Native Notifications
```kotlin
// Platform-specific notification implementation
fun sendNotification(title: String, message: String) {
PlatformDetector.platformSpecific(
macOS = {
// macOS: Use NSUserNotification (via tray)
trayState.sendNotification(
Notification(title, message, Notification.Type.Info)
)
},
windows = {
// Windows: Use Windows toast notifications
trayState.sendNotification(
Notification(title, message, Notification.Type.Info)
)
},
linux = {
// Linux: Use libnotify (via tray)
trayState.sendNotification(
Notification(title, message, Notification.Type.Info)
)
}
)
}
```
---
## Architecture Detection
```kotlin
object ArchDetector {
private val arch = System.getProperty("os.arch").lowercase()
val isArm: Boolean = arch.contains("aarch") || arch.contains("arm")
val isX64: Boolean = arch.contains("x86_64") || arch.contains("amd64")
val isX86: Boolean = arch.contains("x86") && !isX64
val architecture: Architecture = when {
isArm -> Architecture.ARM
isX64 -> Architecture.X64
isX86 -> Architecture.X86
else -> Architecture.Unknown
}
enum class Architecture {
ARM,
X64,
X86,
Unknown
}
}
// Usage: Load correct native library
fun loadNativeLib() {
val libName = when {
PlatformDetector.isMacOS && ArchDetector.isArm -> "libsecp256k1-macos-arm64"
PlatformDetector.isMacOS && ArchDetector.isX64 -> "libsecp256k1-macos-x64"
PlatformDetector.isWindows && ArchDetector.isX64 -> "libsecp256k1-win-x64"
PlatformDetector.isLinux && ArchDetector.isX64 -> "libsecp256k1-linux-x64"
else -> throw UnsupportedOperationException("Unsupported platform")
}
System.loadLibrary(libName)
}
```
---
## Testing Platform Detection
```kotlin
@Test
fun testPlatformDetection() {
println("OS: ${System.getProperty("os.name")}")
println("Version: ${System.getProperty("os.version")}")
println("Arch: ${System.getProperty("os.arch")}")
println()
println("Is macOS: ${PlatformDetector.isMacOS}")
println("Is Windows: ${PlatformDetector.isWindows}")
println("Is Linux: ${PlatformDetector.isLinux}")
println("Platform: ${PlatformDetector.platform}")
println()
println("User home: ${PlatformDetector.userHome}")
println("App data: ${PlatformDetector.appDataDir}")
println("File separator: ${PlatformDetector.fileSeparator}")
}
// Example output (macOS):
// OS: Mac OS X
// Version: 14.2.1
// Arch: aarch64
//
// Is macOS: true
// Is Windows: false
// Is Linux: false
// Platform: MacOS
//
// User home: /Users/username
// App data: /Users/username/Library/Application Support
// File separator: /
```
---
## Best Practices
### 1. Centralize Detection
**DO:** Use PlatformDetector singleton
```kotlin
if (PlatformDetector.isMacOS) { /* ... */ }
```
**DON'T:** Repeat detection everywhere
```kotlin
if (System.getProperty("os.name").lowercase().contains("mac")) { /* ... */ }
```
### 2. Use expect/actual for Platform APIs
```kotlin
// commonMain
expect fun openFile(path: String)
// jvmMain (Desktop)
actual fun openFile(path: String) {
Desktop.getDesktop().open(File(path))
}
// androidMain
actual fun openFile(path: String) {
context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(path)))
}
```
### 3. Graceful Degradation
```kotlin
fun openBrowser(url: String) {
try {
if (Desktop.isDesktopSupported()) {
Desktop.getDesktop().browse(URI(url))
} else {
// Fallback: Copy to clipboard
Toolkit.getDefaultToolkit().systemClipboard.setContents(
StringSelection(url),
null
)
showMessage("URL copied to clipboard: $url")
}
} catch (e: Exception) {
showError("Failed to open browser: ${e.message}")
}
}
```
### 4. Test on All Platforms
Always test platform-specific code on:
- macOS (Intel + Apple Silicon if possible)
- Windows (10/11)
- Linux (Ubuntu/Fedora)
---
## Common Patterns
### Pattern: Config File Location
```kotlin
fun getConfigFile(filename: String): File {
val configDir = when (PlatformDetector.platform) {
Platform.MacOS ->
File("${PlatformDetector.userHome}/Library/Application Support/Amethyst")
Platform.Windows ->
File("${System.getenv("APPDATA")}\\Amethyst")
Platform.Linux ->
File("${PlatformDetector.userHome}/.config/amethyst")
else ->
File("${PlatformDetector.userHome}/.amethyst")
}
if (!configDir.exists()) {
configDir.mkdirs()
}
return File(configDir, filename)
}
// Usage
val settingsFile = getConfigFile("settings.json")
```
### Pattern: Platform-Specific Resources
```kotlin
fun getPlatformIcon(name: String): Painter {
val extension = when (PlatformDetector.platform) {
Platform.MacOS -> "icns"
Platform.Windows -> "ico"
else -> "png"
}
return painterResource("$name.$extension")
}
// Resources:
// src/jvmMain/resources/app-icon.icns (macOS)
// src/jvmMain/resources/app-icon.ico (Windows)
// src/jvmMain/resources/app-icon.png (Linux)
```
---
## References
- [System Properties (Java)](https://docs.oracle.com/en/java/javase/21/docs/api/java.base/java/lang/System.html#getProperties())
- [Desktop API (Java)](https://docs.oracle.com/en/java/javase/21/docs/api/java.desktop/java/awt/Desktop.html)
- [File System Standards (XDG)](https://specifications.freedesktop.org/basedir-spec/basedir-spec-latest.html)
+548
View File
@@ -0,0 +1,548 @@
---
name: gradle-expert
description: Build optimization, dependency resolution, and multi-module KMP troubleshooting for AmethystMultiplatform. Use when working with: (1) Gradle build files (build.gradle.kts, settings.gradle), (2) Version catalog (libs.versions.toml), (3) Build errors and dependency conflicts, (4) Module dependencies and source sets, (5) Desktop packaging (DMG/MSI/DEB), (6) Build performance optimization, (7) Proguard/R8 configuration, (8) Common KMP + Android Gradle issues (Compose conflicts, secp256k1 JNI variants, source set problems).
---
# Gradle Expert
Build system expertise for AmethystMultiplatform's 4-module KMP architecture. Focus: practical troubleshooting, dependency resolution, and project-specific optimizations.
## Build Architecture Mental Model
Think of this project as **4 layers**:
```
┌─────────────┬─────────────┐
│ :amethyst │ :desktopApp │ ← Platform apps (navigation, layouts)
│ (Android) │ (JVM) │
└──────┬──────┴──────┬──────┘
│ │
└──────┬──────┘
┌─────────────┐
│ :commons │ ← Shared UI (KMP with jvmAndroid)
│ (KMP UI) │
└──────┬──────┘
┌─────────────┐
│ :quartz │ ← Core library (KMP: Android/JVM/iOS)
│(KMP Library)│
└─────────────┘
```
**Key insight:** Dependencies flow DOWN. Lower modules never depend on upper modules. This enables code sharing without circular dependencies.
**The jvmAndroid pattern:** Unique to this project. A custom source set between commonMain and {androidMain, jvmMain} for JVM-specific code shared by Android and Desktop. Not standard KMP, but critical for this architecture.
## Version Catalog Philosophy
All dependencies centralized in `gradle/libs.versions.toml`. Think "single source of truth."
**Pattern:**
```toml
[versions]
kotlin = "2.3.0"
[libraries]
okhttp = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okhttp" }
[plugins]
kotlinMultiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" }
```
**Usage:**
```kotlin
dependencies {
implementation(libs.okhttp) // Type-safe, IDE-autocompleted
}
```
**Critical alignments:**
- **Kotlin ecosystem:** All Kotlin plugins MUST share same version
- **Compose ecosystem:** Compose Multiplatform version → Kotlin version (check compatibility matrix)
- **secp256k1 variants:** All three variants (common, jni-android, jni-jvm) MUST share same version
See [references/version-catalog-guide.md](references/version-catalog-guide.md) for comprehensive patterns.
## Common Build Tasks
### Quick Reference
```bash
# Full builds
./gradlew build # All modules
./gradlew clean build # Clean build
# Desktop
./gradlew :desktopApp:run # Run desktop app
./gradlew :desktopApp:packageDmg # macOS package
# Module-specific
./gradlew :quartz:build # KMP library only
./gradlew :commons:build # Shared UI only
# Analysis
./gradlew dependencies # Dependency tree
./gradlew build --scan # Online diagnostics
```
See [references/build-commands.md](references/build-commands.md) for comprehensive command reference.
## Module Structure & Dependencies
### Dependency Flow
**Desktop build chain:**
```
:desktopApp → :commons (jvmMain) → :quartz (jvmMain → jvmAndroid → commonMain)
```
**Android build chain:**
```
:amethyst → :commons (androidMain) → :quartz (androidMain → jvmAndroid → commonMain)
```
**Key source set pattern (quartz & commons):**
```
commonMain # Truly cross-platform code
├─ jvmAndroid # JVM-specific, shared by Android + Desktop
│ ├─ androidMain
│ └─ jvmMain
└─ iosMain # iOS-specific (quartz only)
```
**Dependency config types:**
- Use `api` when types appear in module's public API or expect/actual declarations
- Use `implementation` for internal implementation details
- Example: quartz exposes secp256k1 (`api`), but hides okhttp (`implementation`)
See [references/dependency-graph.md](references/dependency-graph.md) for module visualization and transitive dependency flow.
## Critical Dependency Patterns
### 1. secp256k1 (Crypto Library)
**The problem:** KMP library with platform-specific JNI bindings. Wrong variant = runtime crash.
**Pattern:**
```kotlin
// commonMain - API only
api(libs.secp256k1.kmp.common)
// androidMain - Android JNI
api(libs.secp256k1.kmp.jni.android)
// jvmMain - Desktop JVM JNI
implementation(libs.secp256k1.kmp.jni.jvm)
```
**Why api in androidMain?** Types leak to consumers (:amethyst).
**Common error:** Desktop using jni-android variant → `UnsatisfiedLinkError: no secp256k1jni in java.library.path`
**Fix:** Check source set dependencies. jvmMain must use jni-jvm, never jni-android.
### 2. JNA (for LibSodium Encryption)
**The problem:** Android needs AAR packaging, JVM needs JAR. Same library, different artifact types.
**Pattern:**
```kotlin
// androidMain
implementation("com.goterl:lazysodium-android:5.2.0@aar") // @aar explicit
implementation("net.java.dev.jna:jna:5.18.1@aar")
// jvmMain
implementation(libs.lazysodium.java) // JAR implicit
implementation(libs.jna)
```
**Critical:** Never put JNA in jvmAndroid or commonMain. Platform-specific packaging only.
### 3. Compose Versions
**The problem:** Two Compose ecosystems (Multiplatform + AndroidX) must align, or duplicate classes.
**Current project config:**
```toml
composeMultiplatform = "1.9.3" # Plugin + runtime
composeBom = "2025.12.01" # AndroidX Compose BOM
kotlin = "2.3.0"
```
**Rule:** Compose Multiplatform version must be compatible with Kotlin version. Check: https://www.jetbrains.com/help/kotlin-multiplatform-dev/compose-compatibility-and-versioning.html
**In KMP modules (quartz, commons):**
```kotlin
// ✅ Use Compose Multiplatform
implementation(compose.ui)
implementation(compose.material3)
// ❌ DON'T use AndroidX BOM in KMP modules
// implementation(libs.androidx.compose.bom)
```
**In Android-only modules (amethyst):**
```kotlin
// Can use AndroidX BOM
val composeBom = platform(libs.androidx.compose.bom)
implementation(composeBom)
```
## Desktop Packaging Basics
**TargetFormat options:**
```kotlin
// In desktopApp/build.gradle.kts
nativeDistributions {
targetFormats(TargetFormat.Dmg, TargetFormat.Msi, TargetFormat.Deb)
packageName = "Amethyst"
packageVersion = "1.0.0"
macOS {
bundleID = "com.vitorpamplona.amethyst.desktop"
iconFile.set(project.file("src/jvmMain/resources/icon.icns"))
}
}
```
**Package tasks:**
```bash
./gradlew :desktopApp:packageDmg # macOS
./gradlew :desktopApp:packageMsi # Windows
./gradlew :desktopApp:packageDeb # Linux
```
**Output locations:**
- macOS: `desktopApp/build/compose/binaries/main/dmg/`
- Windows: `desktopApp/build/compose/binaries/main/msi/`
- Linux: `desktopApp/build/compose/binaries/main/deb/`
**Icon requirements:**
- macOS: `.icns` (multi-resolution: 512, 256, 128, 32)
- Windows: `.ico` (256, 128, 64, 32, 16)
- Linux: `.png` (512x512)
**Common issues:**
- Main class not found → Verify `mainClass = "...MainKt"` (Kotlin adds `Kt` suffix)
- Native libs missing → Ensure secp256k1-kmp-jni-jvm in dependencies
- Icon not found → Check file exists at path, use absolute path if needed
## Build Performance Optimization
**Add to `gradle.properties`:**
```properties
# Daemon (faster subsequent builds)
org.gradle.daemon=true
org.gradle.jvmargs=-Xmx4g -XX:MaxMetaspaceSize=1g
# Parallel execution (multi-module speedup)
org.gradle.parallel=true
org.gradle.workers.max=8
# Caching (incremental builds)
org.gradle.caching=true
org.gradle.configuration-cache=true
# Kotlin daemon
kotlin.incremental=true
kotlin.daemon.jvmargs=-Xmx2g
```
**Impact:** Typically 30-50% faster builds after first run.
**Measure impact:**
```bash
./gradlew clean build --profile
# Report: build/reports/profile/profile-<timestamp>.html
```
**When to clean build:**
- After changing version catalog
- After adding/removing source sets
- When seeing unexplained errors
**When NOT to clean:**
- Regular development iteration
- Small code changes
- Incremental compilation works fine
Use script: `scripts/analyze-build-time.sh` for automated profiling.
## Troubleshooting: Practical Patterns
### Pattern 1: Version Conflict
**Symptom:** `Duplicate class` or `NoSuchMethodError`
**Diagnosis:**
```bash
./gradlew dependencyInsight --dependency <library-name>
```
**Fix options:**
1. Align versions in libs.versions.toml (preferred)
2. Force resolution:
```kotlin
configurations.all {
resolutionStrategy {
force(libs.okhttp.get().toString())
}
}
```
### Pattern 2: Source Set Issues
**Symptom:** `Unresolved reference` to JVM library in shared code
**Diagnosis:** Check source set hierarchy. JVM-only libs (jackson, okhttp) can't be in commonMain.
**Fix:** Move to jvmAndroid or platform-specific source set.
```kotlin
// ❌ Wrong
commonMain {
dependencies {
implementation(libs.jackson.module.kotlin) // JVM-only!
}
}
// ✅ Correct
val jvmAndroid = create("jvmAndroid") {
dependsOn(commonMain.get())
dependencies {
api(libs.jackson.module.kotlin) // JVM code, shared by Android + Desktop
}
}
```
### Pattern 3: Proguard Stripping Native Libs
**Symptom:** `NoClassDefFoundError` for secp256k1, JNA, or LibSodium in release builds
**Fix:** Update proguard rules in `quartz/proguard-rules.pro`:
```proguard
# Native libraries
-keep class fr.acinq.secp256k1.** { *; }
-keep class com.goterl.lazysodium.** { *; }
-keep class com.sun.jna.** { *; }
# Jackson (reflection-based)
-keep class com.vitorpamplona.quartz.** { *; }
-keepattributes *Annotation*
-keepattributes Signature
```
### Pattern 4: Compose Compiler Mismatch
**Symptom:** `IllegalStateException: Version mismatch: runtime 1.10.0 but compiler 1.9.0`
**Fix:** Update Compose Multiplatform version in libs.versions.toml to match Kotlin version compatibility.
Check: https://www.jetbrains.com/help/kotlin-multiplatform-dev/compose-compatibility-and-versioning.html
### Pattern 5: Wrong JVM Target
**Symptom:** `Unsupported class file major version 65`
**Fix:** Ensure Java 21 everywhere:
```bash
# Check current Java
java -version # Should show 21
# Set JAVA_HOME
export JAVA_HOME=/opt/homebrew/opt/openjdk@21/libexec/openjdk.jdk/Contents/Home
# Stop Gradle daemon to pick up new Java
./gradlew --stop
```
Verify all build files use JVM 21:
```kotlin
kotlin {
jvm {
compilerOptions {
jvmTarget.set(JvmTarget.JVM_21)
}
}
}
android {
compileOptions {
sourceCompatibility = JavaVersion.VERSION_21
targetCompatibility = JavaVersion.VERSION_21
}
}
```
## Comprehensive Error Guide
For detailed troubleshooting of specific errors, see [references/common-errors.md](references/common-errors.md). Covers:
- Compose version conflicts
- secp256k1 JNI errors
- Source set dependency issues
- Proguard/R8 problems
- Desktop packaging errors
- Kotlin compilation errors
- Dependency resolution failures
- JVM/JDK version issues
Each error includes: symptom, cause, solution, verification steps.
## Quick Diagnostic Commands
```bash
# Check dependencies for specific module
./gradlew :quartz:dependencies
# Find specific library in dependency tree
./gradlew dependencyInsight --dependency okhttp
# Build with detailed logging
./gradlew build --info
# Generate interactive build scan (best diagnostics)
./gradlew build --scan
# Profile build performance
./gradlew clean build --profile
# Stop all Gradle daemons (fresh start)
./gradlew --stop
# Check Gradle version
./gradlew --version
```
## Scripts & References
### Diagnostic Scripts
- `scripts/analyze-build-time.sh` - Profile build performance, generate optimization report
- `scripts/fix-dependency-conflicts.sh` - Diagnose common dependency conflicts, suggest fixes
### Reference Docs
- `references/build-commands.md` - Comprehensive command reference for all tasks
- `references/dependency-graph.md` - Module dependencies, source set hierarchy, transitive deps
- `references/version-catalog-guide.md` - Version catalog patterns, usage, best practices
- `references/common-errors.md` - Troubleshooting guide for frequent build issues
## Workflow Examples
### Example 1: Adding New Dependency
**Task:** Add kotlinx.datetime to quartz
**Steps:**
1. **Update version catalog** (gradle/libs.versions.toml):
```toml
[versions]
kotlinxDatetime = "0.6.0"
[libraries]
kotlinx-datetime = { group = "org.jetbrains.kotlinx", name = "kotlinx-datetime", version.ref = "kotlinxDatetime" }
```
2. **Add to build file** (quartz/build.gradle.kts):
```kotlin
sourceSets {
commonMain {
dependencies {
implementation(libs.kotlinx.datetime) // KMP library, goes in commonMain
}
}
}
```
3. **Sync & verify:**
```bash
./gradlew :quartz:dependencies | grep datetime
```
### Example 2: Fixing secp256k1 Error on Desktop
**Error:** `UnsatisfiedLinkError: no secp256k1jni in java.library.path` when running desktop app
**Diagnosis:**
```bash
./gradlew :desktopApp:dependencies --configuration runtimeClasspath | grep secp256k1
# Shows: secp256k1-kmp-jni-android ← WRONG!
```
**Fix:**
```kotlin
// In quartz/build.gradle.kts
jvmMain {
dependencies {
// Change from:
// implementation(libs.secp256k1.kmp.jni.android) ❌
// To:
implementation(libs.secp256k1.kmp.jni.jvm) // ✅
}
}
```
**Verify:**
```bash
./gradlew :desktopApp:dependencies --configuration runtimeClasspath | grep secp256k1
# Now shows: secp256k1-kmp-jni-jvm ✅
./gradlew :desktopApp:run # Should work
```
### Example 3: Optimizing Build Time
**Current:** Clean build takes 5 minutes
**Steps:**
1. **Baseline measurement:**
```bash
./gradlew clean build --profile
# Check: build/reports/profile/profile-*.html
```
2. **Add optimizations** to gradle.properties:
```properties
org.gradle.daemon=true
org.gradle.parallel=true
org.gradle.caching=true
org.gradle.configuration-cache=true
org.gradle.jvmargs=-Xmx4g
kotlin.incremental=true
```
3. **Re-measure:**
```bash
./gradlew clean build --profile
```
**Expected improvement:** 30-50% faster on subsequent builds (incremental builds much faster).
## Delegation Patterns
**When to delegate to other skills:**
- **Source set architecture** (jvmAndroid pattern, expect/actual) → Use `kotlin-multiplatform` skill
- **Compose UI issues** (composables, state management) → Use `compose-expert` skill (when available)
- **Kotlin language issues** (Flow, sealed classes, DSLs) → Use `kotlin-expert` skill
- **Desktop-specific features** (Window management, MenuBar, tray) → Use `desktop-expert` skill
**This skill handles:** Build system, dependencies, versioning, module structure, packaging, performance.
## Core Principles for This Build System
1. **Centralize versions:** Never hardcode versions in build.gradle.kts. Always use libs.versions.toml.
2. **Respect source set hierarchy:** Dependencies flow downward. jvmAndroid depends on commonMain, never the reverse.
3. **Platform-specific variants matter:** secp256k1, JNA must use correct variant per platform. Check when errors occur.
4. **Clean builds are expensive:** Use incremental compilation. Only clean when truly needed (source set changes, version updates).
5. **Compose alignment is critical:** Compose Multiplatform version must match Kotlin version. Check compatibility matrix.
6. **Proguard for native libs:** All JNI libraries need explicit `-keep` rules in release builds.
7. **Java 21 everywhere:** All modules, all targets, consistent JVM version.
@@ -0,0 +1,214 @@
# Build Commands Reference
## Table of Contents
- [Core Build Tasks](#core-build-tasks)
- [Module-Specific Builds](#module-specific-builds)
- [Desktop Tasks](#desktop-tasks)
- [Android Tasks](#android-tasks)
- [Testing](#testing)
- [Analysis & Diagnostics](#analysis--diagnostics)
- [Performance Optimization](#performance-optimization)
## Core Build Tasks
### Full Project Build
```bash
./gradlew build # Build all modules
./gradlew clean build # Clean build
./gradlew assemble # Build without tests
```
### Incremental Builds
```bash
./gradlew :quartz:build # Build only quartz module
./gradlew :commons:build # Build only commons module
./gradlew :desktopApp:build # Build only desktop app
```
## Module-Specific Builds
### Quartz (KMP Library)
```bash
./gradlew :quartz:build # All targets
./gradlew :quartz:compileKotlinJvm # JVM target only
./gradlew :quartz:compileDebugKotlinAndroid # Android target only
./gradlew :quartz:linkDebugFrameworkIosArm64 # iOS framework
./gradlew :quartz:publishToMavenLocal # Publish locally
```
### Commons (Shared UI)
```bash
./gradlew :commons:build # All targets
./gradlew :commons:compileKotlinJvm # Desktop target
./gradlew :commons:compileDebugKotlinAndroid # Android target
```
## Desktop Tasks
### Run Desktop App
```bash
./gradlew :desktopApp:run # Run desktop app
./gradlew :desktopApp:runDistributable # Run packaged version
```
### Package Desktop App
```bash
./gradlew :desktopApp:createDistributable # Create runnable package
./gradlew :desktopApp:packageDmg # macOS DMG
./gradlew :desktopApp:packageMsi # Windows MSI
./gradlew :desktopApp:packageDeb # Linux DEB
```
### Distribution Location
- macOS: `desktopApp/build/compose/binaries/main/dmg/`
- Windows: `desktopApp/build/compose/binaries/main/msi/`
- Linux: `desktopApp/build/compose/binaries/main/deb/`
## Android Tasks
### Compile & Assemble
```bash
./gradlew :amethyst:assembleDebug # Debug APK
./gradlew :amethyst:assembleRelease # Release APK
./gradlew :amethyst:bundleRelease # Release AAB
```
### Install & Run
```bash
./gradlew :amethyst:installDebug # Install debug on device
adb shell am start -n com.vitorpamplona.amethyst/.MainActivity
```
### Proguard/R8
```bash
./gradlew :quartz:minifyReleaseWithR8 # Test R8 minification
```
## Testing
### Unit Tests
```bash
./gradlew test # All unit tests
./gradlew :quartz:jvmTest # JVM unit tests
./gradlew :quartz:testDebugUnitTest # Android unit tests
./gradlew :commons:test # Commons tests
```
### Android Instrumented Tests
```bash
./gradlew :quartz:connectedAndroidTest # Requires device/emulator
```
### Test Reports
```bash
# Reports location: <module>/build/reports/tests/
open quartz/build/reports/tests/jvmTest/index.html
```
## Analysis & Diagnostics
### Dependency Analysis
```bash
./gradlew dependencies # All dependencies
./gradlew :quartz:dependencies # Quartz dependencies
./gradlew dependencyInsight --dependency okhttp # Specific dependency
```
### Build Scan
```bash
./gradlew build --scan # Upload to scans.gradle.com
```
### Performance Profiling
```bash
./gradlew build --profile # Generate profile report
# Report: build/reports/profile/profile-<timestamp>.html
```
### Task Dependencies
```bash
./gradlew :desktopApp:run --dry-run # Show task graph
./gradlew :desktopApp:dependencies --scan # Visualize dependencies
```
## Performance Optimization
### Configuration Cache
```bash
./gradlew build --configuration-cache # Enable config cache
./gradlew build --configuration-cache-problems=warn
```
### Build Cache
```bash
./gradlew build --build-cache # Enable build cache
./gradlew cleanBuildCache # Clear build cache
```
### Parallel Execution
```bash
./gradlew build --parallel --max-workers=8 # Parallel with 8 workers
```
### Daemon Management
```bash
./gradlew --stop # Stop Gradle daemon
./gradlew --status # Daemon status
```
### Incremental Compilation
```bash
# Already enabled by default in Kotlin, but can verify:
./gradlew :quartz:compileKotlinJvm --info | grep "Incremental"
```
## gradle.properties Optimizations
Add to `gradle.properties` for faster builds:
```properties
# Daemon
org.gradle.daemon=true
org.gradle.jvmargs=-Xmx4g -XX:MaxMetaspaceSize=1g
# Parallel
org.gradle.parallel=true
org.gradle.workers.max=8
# Caching
org.gradle.caching=true
org.gradle.configuration-cache=true
# Kotlin
kotlin.incremental=true
kotlin.daemon.jvmargs=-Xmx2g
```
## Common Workflows
### Full Desktop Build & Run
```bash
./gradlew :desktopApp:clean :desktopApp:run
```
### Quick Desktop Iteration
```bash
# No clean - incremental compilation
./gradlew :desktopApp:run
```
### Android Release Build
```bash
./gradlew :amethyst:clean :amethyst:bundleRelease
```
### Test All KMP Targets
```bash
./gradlew :quartz:test :quartz:testDebugUnitTest
```
### Publish Quartz Locally for Testing
```bash
./gradlew :quartz:publishToMavenLocal
# Then update version in consumer project to test
```
@@ -0,0 +1,643 @@
# Common Build Errors & Solutions
## Table of Contents
- [Compose Version Conflicts](#compose-version-conflicts)
- [secp256k1 JNI Errors](#secp256k1-jni-errors)
- [Source Set Dependency Issues](#source-set-dependency-issues)
- [Proguard/R8 Issues](#proguardr8-issues)
- [Desktop Packaging Errors](#desktop-packaging-errors)
- [Kotlin Compilation Errors](#kotlin-compilation-errors)
- [Dependency Resolution Failures](#dependency-resolution-failures)
- [JVM/JDK Version Issues](#jvmjdk-version-issues)
---
## Compose Version Conflicts
### Error 1: Compose Runtime Mismatch
```
java.lang.IllegalStateException: Version mismatch: Compose runtime is 1.10.0 but compiler is 1.9.0
```
**Cause:** Compose Compiler plugin version doesn't match Compose Runtime
**Solution:**
```kotlin
// In gradle/libs.versions.toml
composeMultiplatform = "1.9.3" // Must align with Kotlin version
kotlin = "2.3.0"
// Check compatibility matrix:
// https://www.jetbrains.com/help/kotlin-multiplatform-dev/compose-compatibility-and-versioning.html
```
**Verification:**
```bash
./gradlew :commons:dependencies | grep compose
```
### Error 2: AndroidX Compose BOM Conflict
```
Duplicate class androidx.compose.ui.platform.AndroidCompositionLocalMap found in modules...
```
**Cause:** Both Compose Multiplatform and AndroidX Compose BOM providing same classes
**Solution:**
```kotlin
// In commons/build.gradle.kts (KMP module)
// Use Compose Multiplatform, NOT AndroidX BOM
dependencies {
implementation(compose.ui) // ✅ Compose Multiplatform
implementation(compose.material3)
// Don't use in KMP modules:
// implementation(libs.androidx.compose.bom) // ❌ Android-only
}
// In amethyst/build.gradle.kts (Android-only module)
// Can use AndroidX BOM
dependencies {
val composeBom = platform(libs.androidx.compose.bom)
implementation(composeBom)
implementation(libs.androidx.ui)
}
```
### Error 3: Material3 WindowSizeClass Not Found
```
Unresolved reference: WindowSizeClass
```
**Cause:** Using Android's WindowSizeClass in shared KMP code
**Solution:**
```kotlin
// Don't use in commonMain or jvmAndroid:
// import androidx.compose.material3.windowsizeclass.WindowSizeClass // ❌
// Use in androidMain only, or create expect/actual:
// commonMain
expect class WindowSizeClassAdapter
// androidMain
actual typealias WindowSizeClassAdapter = androidx.compose.material3.windowsizeclass.WindowSizeClass
// jvmMain (desktop)
actual class WindowSizeClassAdapter { /* Custom impl */ }
```
---
## secp256k1 JNI Errors
### Error 1: JNI Library Not Found (Desktop)
```
java.lang.UnsatisfiedLinkError: no secp256k1jni in java.library.path
```
**Cause:** Desktop using wrong secp256k1 variant (Android JNI instead of JVM JNI)
**Solution:**
```kotlin
// In quartz/build.gradle.kts
sourceSets {
jvmMain {
dependencies {
// ✅ Correct - JVM variant
implementation(libs.secp256k1.kmp.jni.jvm)
// ❌ Wrong - Android variant
// implementation(libs.secp256k1.kmp.jni.android)
}
}
}
```
**Verification:**
```bash
./gradlew :quartz:dependencies --configuration jvmRuntimeClasspath | grep secp256k1
# Should show: secp256k1-kmp-jni-jvm, NOT jni-android
```
### Error 2: Version Mismatch Between Variants
```
java.lang.NoSuchMethodError: fr.acinq.secp256k1.Secp256k1.sign
```
**Cause:** Common, Android, and JVM variants have different versions
**Solution:**
```toml
# In gradle/libs.versions.toml
# All three MUST use same version
secp256k1KmpJniAndroid = "0.22.0"
[libraries]
secp256k1-kmp-common = { ..., version.ref = "secp256k1KmpJniAndroid" }
secp256k1-kmp-jni-android = { ..., version.ref = "secp256k1KmpJniAndroid" }
secp256k1-kmp-jni-jvm = { ..., version.ref = "secp256k1KmpJniAndroid" }
```
### Error 3: Android JNI Not Loaded
```
java.lang.UnsatisfiedLinkError: dalvik.system.PathClassLoader couldn't find "libsecp256k1jni.so"
```
**Cause:** Proguard stripping JNI classes
**Solution:**
```proguard
# In quartz/proguard-rules.pro
-keep class fr.acinq.secp256k1.** { *; }
```
---
## Source Set Dependency Issues
### Error 1: jvmAndroid Defined After androidMain
```
Could not get unknown property 'jvmAndroid' for source set container
```
**Cause:** Source sets must be defined in dependency order
**Solution:**
```kotlin
// ✅ Correct order
sourceSets {
commonMain { }
// Define jvmAndroid BEFORE androidMain and jvmMain
val jvmAndroid = create("jvmAndroid") {
dependsOn(commonMain.get())
}
androidMain {
dependsOn(jvmAndroid) // Now jvmAndroid exists
}
jvmMain {
dependsOn(jvmAndroid)
}
}
```
### Error 2: Dependency in Wrong Source Set
```
Unresolved reference: ObjectMapper (Jackson)
```
**Cause:** JVM-only library in commonMain
**Solution:**
```kotlin
sourceSets {
commonMain {
// ❌ Jackson is JVM-only, can't use here
// implementation(libs.jackson.module.kotlin)
}
val jvmAndroid = create("jvmAndroid") {
dependsOn(commonMain.get())
// ✅ Jackson in jvmAndroid (shared JVM code)
api(libs.jackson.module.kotlin)
}
}
```
### Error 3: Platform-Specific Code in Shared Source Set
```
java.lang.NoClassDefFoundError: android.content.Context
```
**Cause:** Android-specific API in jvmAndroid or commonMain
**Solution:**
```kotlin
// Use expect/actual pattern
// commonMain
expect class PlatformContext
// androidMain
actual typealias PlatformContext = android.content.Context
// jvmMain
actual class PlatformContext {
// Custom desktop implementation
}
```
---
## Proguard/R8 Issues
### Error 1: Native Library Classes Stripped
```
java.lang.NoClassDefFoundError: com.goterl.lazysodium.Sodium
```
**Cause:** R8/Proguard removing JNA/LibSodium classes
**Solution:**
```proguard
# In quartz/proguard-rules.pro
-keep class com.goterl.lazysodium.** { *; }
-keep class com.sun.jna.** { *; }
-keep class fr.acinq.secp256k1.** { *; }
```
### Error 2: Reflection-Based Libraries Broken
```
com.fasterxml.jackson.databind.exc.InvalidDefinitionException: Cannot construct instance of ...
```
**Cause:** Jackson uses reflection, R8 strips class metadata
**Solution:**
```proguard
# Preserve reflection metadata
-keepattributes *Annotation*
-keepattributes Signature
-keepattributes InnerClasses
# Keep all Quartz event classes
-keep class com.vitorpamplona.quartz.** { *; }
```
### Error 3: Enum Values Missing
```
java.lang.IllegalArgumentException: No enum constant ...
```
**Cause:** R8 obfuscating enum names
**Solution:**
```proguard
# Keep all enums
-keep enum ** { *; }
-keepnames class ** { *; }
```
---
## Desktop Packaging Errors
### Error 1: Icon Not Found
```
FAILURE: Build failed with an exception.
* What went wrong: Cannot find icon file: src/jvmMain/resources/icon.icns
```
**Cause:** Icon file missing or wrong path
**Solution:**
```kotlin
// In desktopApp/build.gradle.kts
nativeDistributions {
macOS {
// Ensure file exists at this path
iconFile.set(project.file("src/jvmMain/resources/icon.icns"))
}
// Check file exists:
// ls -la desktopApp/src/jvmMain/resources/
}
```
**Icon Requirements:**
- macOS: `.icns` (512x512, 256x256, 128x128, 32x32)
- Windows: `.ico` (256x256, 128x128, 64x64, 32x32, 16x16)
- Linux: `.png` (512x512 recommended)
### Error 2: Main Class Not Found
```
Error: Could not find or load main class com.vitorpamplona.amethyst.desktop.MainKt
```
**Cause:** Wrong mainClass path or Main.kt doesn't have main()
**Solution:**
```kotlin
// In desktopApp/build.gradle.kts
compose.desktop {
application {
mainClass = "com.vitorpamplona.amethyst.desktop.MainKt"
// ^^^^
// Kotlin compiler adds "Kt" suffix
}
}
// In src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt
fun main() = application {
// ...
}
```
### Error 3: Native Library Missing in Package
```
java.lang.UnsatisfiedLinkError: no secp256k1jni in java.library.path
```
**Cause:** Native libraries not bundled in distribution
**Solution:**
```kotlin
// Native libs are automatically included via dependencies
// Verify secp256k1-kmp-jni-jvm is in dependencies:
dependencies {
implementation(libs.secp256k1.kmp.jni.jvm) // ✅ Includes native libs
}
// Test packaged app:
./gradlew :desktopApp:createDistributable
# Run from: desktopApp/build/compose/binaries/main/app/
```
---
## Kotlin Compilation Errors
### Error 1: Expect/Actual Mismatch
```
'actual' declaration has no corresponding expected declaration
```
**Cause:** Signature mismatch or missing expect
**Solution:**
```kotlin
// commonMain - expect declaration
expect class CryptoProvider {
fun sign(message: ByteArray, privateKey: ByteArray): ByteArray
}
// androidMain & jvmMain - actual must match EXACTLY
actual class CryptoProvider {
actual fun sign(message: ByteArray, privateKey: ByteArray): ByteArray {
// Implementation
}
}
// Common mistakes:
// - Different parameter names ❌
// - Different return types ❌
// - Missing 'actual' modifier ❌
```
### Error 2: Target JVM Version Mismatch
```
Compilation failed: module was compiled with an incompatible version of Kotlin
```
**Cause:** Different JVM targets across modules
**Solution:**
```kotlin
// Ensure ALL modules use same JVM target
// In quartz/build.gradle.kts
kotlin {
jvm {
compilerOptions {
jvmTarget.set(JvmTarget.JVM_21) // ✅ Java 21
}
}
}
// In android {} block
compileOptions {
sourceCompatibility = JavaVersion.VERSION_21
targetCompatibility = JavaVersion.VERSION_21
}
```
### Error 3: Compose Compiler Plugin Missing
```
This declaration needs opt-in. Please use @OptIn(ComposeApi::class) or @Composable
```
**Cause:** Compose compiler plugin not applied
**Solution:**
```kotlin
// In build.gradle.kts
plugins {
alias(libs.plugins.jetbrainsComposeCompiler) // ✅ Add this
alias(libs.plugins.composeMultiplatform)
}
```
---
## Dependency Resolution Failures
### Error 1: Repository Not Found
```
Could not find com.github.vitorpamplona.compose-richtext:richtext-ui:f92ef49c9d
```
**Cause:** Jitpack or custom Maven repository not configured
**Solution:**
```kotlin
// In settings.gradle
dependencyResolutionManagement {
repositories {
google()
mavenCentral()
maven { url = "https://jitpack.io" } // ✅ Add Jitpack
}
}
```
### Error 2: Gradle Version Too Old
```
Version catalogs are not supported in this version of Gradle
```
**Cause:** Gradle < 7.0
**Solution:**
```properties
# In gradle/wrapper/gradle-wrapper.properties
distributionUrl=https\://services.gradle.org/distributions/gradle-8.9-bin.zip
```
Then: `./gradlew wrapper --gradle-version=8.9`
### Error 3: Dependency Variant Not Found
```
No matching variant of fr.acinq.secp256k1:secp256k1-kmp-jni-android:0.22.0 was found
```
**Cause:** Wrong dependency configuration for target
**Solution:**
```kotlin
// In androidMain (Android library module)
dependencies {
// For AAR packaging
implementation("net.java.dev.jna:jna:5.18.1@aar") // ✅ Specify @aar
// secp256k1 works without @aar (auto-detects)
api(libs.secp256k1.kmp.jni.android)
}
```
---
## JVM/JDK Version Issues
### Error 1: Unsupported Class File Version
```
Unsupported class file major version 65
```
**Cause:** Compiled with Java 21, running with older Java
**Solution:**
```bash
# Check Java version
java -version # Should show 21
# Set JAVA_HOME if needed
export JAVA_HOME=/opt/homebrew/opt/openjdk@21/libexec/openjdk.jdk/Contents/Home
# Or in gradle.properties
org.gradle.java.home=/path/to/jdk-21
```
### Error 2: JVM Toolchain Not Found
```
No matching toolchain found for requested JvmVersion
```
**Cause:** Java 21 not installed or not detected
**Solution:**
```bash
# macOS (Homebrew)
brew install openjdk@21
# Ubuntu
sudo apt install openjdk-21-jdk
# Set JAVA_HOME
export JAVA_HOME=$(/usr/libexec/java_home -v 21) # macOS
export JAVA_HOME=/usr/lib/jvm/java-21-openjdk # Linux
# Verify
./gradlew -version
```
### Error 3: Gradle Daemon Using Wrong Java
```
Daemon will be stopped at the end of the build because JVM version has changed
```
**Cause:** Daemon started with different Java version
**Solution:**
```bash
# Stop all daemons
./gradlew --stop
# Start with correct JAVA_HOME
export JAVA_HOME=/path/to/jdk-21
./gradlew build
# Or set in gradle.properties permanently
org.gradle.java.home=/opt/homebrew/opt/openjdk@21/libexec/openjdk.jdk/Contents/Home
```
---
## General Troubleshooting Steps
### Step 1: Clean Build
```bash
./gradlew clean
./gradlew --stop # Stop daemon
./gradlew build
```
### Step 2: Check Dependencies
```bash
./gradlew :moduleName:dependencies
./gradlew dependencyInsight --dependency libraryName
```
### Step 3: Enable Debug Logging
```bash
./gradlew build --info # Info logging
./gradlew build --debug # Debug logging (verbose)
./gradlew build --stacktrace
```
### Step 4: Invalidate Caches
```bash
# Clear Gradle cache
rm -rf ~/.gradle/caches/
# Clear build outputs
./gradlew clean
# Clear Gradle wrapper cache
rm -rf ~/.gradle/wrapper/
```
### Step 5: Build Scan
```bash
./gradlew build --scan
# Opens interactive diagnostics in browser
```
## Quick Reference: Error Keywords → Solution
| Error Keyword | Likely Cause | Quick Fix |
|---------------|--------------|-----------|
| `UnsatisfiedLinkError` | Wrong JNI variant | Check secp256k1/JNA variants by platform |
| `IllegalStateException` (Compose) | Version mismatch | Align Compose Multiplatform + Kotlin versions |
| `NoClassDefFoundError` | Proguard stripping | Add `-keep` rule for class |
| `Unresolved reference` | Wrong source set | Move to appropriate source set (jvmAndroid) |
| `Duplicate class` | BOM conflict | Remove AndroidX BOM from KMP modules |
| `Version mismatch` | Plugin/runtime version mismatch | Update libs.versions.toml |
| `No matching variant` | Repository or packaging issue | Add repository or @aar suffix |
| `Could not find` (dependency) | Missing repository | Add maven/jitpack to repositories |
| `Unsupported class file` | Java version mismatch | Update JAVA_HOME to Java 21 |
---
## Getting Help
1. **Check Build Scan**: `./gradlew build --scan` for detailed diagnostics
2. **Gradle Forums**: https://discuss.gradle.org/
3. **Kotlin Slack**: #multiplatform channel
4. **Stack Overflow**: Tags `gradle`, `kotlin-multiplatform`, `compose-multiplatform`
@@ -0,0 +1,266 @@
# Module Dependency Graph
## Visual Hierarchy
```
┌─────────────────────────────────────────────────────────┐
│ Root Project │
│ (Amethyst) │
└─────────────────────────────────────────────────────────┘
┌────────────────┼────────────────┬────────────┐
│ │ │ │
▼ ▼ ▼ ▼
┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌──────────┐
│ :amethyst │ │ :desktopApp │ │ :benchmark │ │:ammolite │
│ (Android) │ │ (JVM) │ │ (Android) │ │ (Support)│
└─────────────┘ └─────────────┘ └─────────────┘ └──────────┘
│ │ │
│ │ │
└────────────────┼────────────────┘
┌─────────────┐
│ :commons │
│ (KMP UI) │
│ │
│ jvmAndroid │
│ / \ │
│ jvm android│
└─────────────┘
┌─────────────┐
│ :quartz │
│(KMP Library)│
│ │
│ commonMain │
│ │ │
│ jvmAndroid │
│ / | \ │
│jvm and ios │
└─────────────┘
```
## Module Details
### :quartz (KMP Nostr Library)
**Type:** Kotlin Multiplatform Library
**Targets:** JVM, Android, iOS (iosX64, iosArm64, iosSimulatorArm64)
**Dependencies:**
- External: secp256k1, jackson, okhttp, kotlinx.coroutines, kotlinx.collections.immutable
- Source sets: commonMain → jvmAndroid → {androidMain, jvmMain}, iosMain
**Role:** Core Nostr protocol implementation, shared across all platforms
### :commons (Shared UI Components)
**Type:** Kotlin Multiplatform Library
**Targets:** JVM, Android
**Dependencies:**
- Module: `:quartz`
- External: Compose Multiplatform, Material3, kotlinx.collections.immutable
- Source sets: commonMain → jvmAndroid → {androidMain, jvmMain}
**Role:** Shared Compose UI components for Desktop and Android
### :desktopApp (Desktop Application)
**Type:** JVM Application
**Targets:** JVM (Desktop)
**Dependencies:**
- Modules: `:commons`, `:quartz`
- External: Compose Desktop, kotlinx.coroutines.swing
**Role:** Desktop-specific navigation, layouts, and entry point
### :amethyst (Android Application)
**Type:** Android Application
**Targets:** Android
**Dependencies:**
- Modules: `:commons`, `:quartz`, `:ammolite`
- External: Android SDK, AndroidX, Firebase, Tor
**Role:** Android-specific navigation, layouts, and entry point
### :benchmark (Android Benchmark)
**Type:** Android Library
**Targets:** Android
**Dependencies:**
- Modules: `:commons`, `:quartz`
- External: AndroidX Benchmark
**Role:** Performance benchmarking for Android builds
### :ammolite (Support Module)
**Type:** Android Library
**Targets:** Android
**Dependencies:** Android-specific utilities
**Role:** Android support utilities for amethyst
## Dependency Flow Patterns
### Desktop Build Chain
```
:desktopApp → :commons (jvmMain) → :quartz (jvmMain)
jvmAndroid
commonMain
```
### Android Build Chain
```
:amethyst → :commons (androidMain) → :quartz (androidMain)
↓ ↓
:ammolite jvmAndroid
commonMain
```
## Source Set Dependencies
### :quartz Source Sets
```
commonMain (base)
├─ jvmAndroid (shared JVM code)
│ ├─ androidMain (Android platform)
│ └─ jvmMain (Desktop platform)
└─ iosMain (iOS platform)
├─ iosX64Main
├─ iosArm64Main
└─ iosSimulatorArm64Main
```
**Key Dependencies per Source Set:**
- **commonMain**: secp256k1-kmp, kotlinx.coroutines, collection, immutable collections
- **jvmAndroid**: jackson, okhttp, url-detector, rfc3986
- **androidMain**: secp256k1-kmp-jni-android, lazysodium-android, jna (aar)
- **jvmMain**: secp256k1-kmp-jni-jvm, lazysodium-java, jna (jar)
### :commons Source Sets
```
commonMain (base UI)
└─ jvmAndroid (shared JVM UI)
├─ androidMain (Android UI utilities)
└─ jvmMain (Desktop UI utilities)
```
**Key Dependencies per Source Set:**
- **commonMain**: Compose Multiplatform, Material3, :quartz
- **jvmAndroid**: url-detector
- **androidMain**: AndroidX Compose tooling
- **jvmMain**: Compose Desktop
## Critical Dependency Patterns
### 1. secp256k1 Variants
```kotlin
// commonMain - API only
api(libs.secp256k1.kmp.common)
// androidMain - JNI Android
api(libs.secp256k1.kmp.jni.android)
// jvmMain - JNI JVM
implementation(libs.secp256k1.kmp.jni.jvm)
```
**Why:** Different JNI bindings for Android vs Desktop JVM
### 2. JNA Variants (for LibSodium)
```kotlin
// androidMain
implementation("com.goterl:lazysodium-android:5.2.0@aar")
implementation("net.java.dev.jna:jna:5.18.1@aar")
// jvmMain
implementation(libs.lazysodium.java)
implementation(libs.jna) // JAR variant
```
**Why:** Android needs AAR packaging, JVM needs JAR
### 3. Compose Alignment
```kotlin
// commons/build.gradle.kts
implementation(compose.ui) // Compose Multiplatform BOM
implementation(compose.material3)
// Version catalog alignment
composeMultiplatform = "1.9.3"
composeBom = "2025.12.01" // AndroidX Compose
```
**Why:** Two Compose ecosystems (Multiplatform + AndroidX) must align
## Dependency Configuration Types
### API vs Implementation
**Use `api` when:**
- Dependency types appear in module's public API
- Used in expect/actual declarations visible to consumers
- Example: `secp256k1-kmp-common` in quartz (public types)
**Use `implementation` when:**
- Internal implementation detail
- Not exposed to module consumers
- Example: `okhttp` in quartz (internal network client)
### Example from quartz
```kotlin
// Public API - exposed to consumers
api(libs.secp256k1.kmp.common)
api(libs.jackson.module.kotlin) // Event serialization public
// Internal implementation
implementation(libs.okhttp)
implementation(libs.kotlinx.coroutines.core)
```
## Transitive Dependency Impact
### When :desktopApp depends on :commons
- Gets `:quartz` transitively (via :commons)
- Gets `secp256k1-kmp-jvm` transitively (via :quartz jvmMain)
- Does NOT get Android-specific dependencies (scoped to androidMain)
### When :amethyst depends on :commons
- Gets `:quartz` transitively (via :commons)
- Gets `secp256k1-kmp-jni-android` transitively (via :quartz androidMain)
- Does NOT get JVM/Desktop-specific dependencies (scoped to jvmMain)
## Verifying Dependencies
### Check Module Dependencies
```bash
./gradlew :desktopApp:dependencies
./gradlew :amethyst:dependencies
```
### Check Specific Library
```bash
./gradlew dependencyInsight --dependency secp256k1
./gradlew dependencyInsight --dependency compose-ui
```
### Visualize with Build Scan
```bash
./gradlew :desktopApp:dependencies --scan
# Opens interactive dependency graph in browser
```
## Common Dependency Issues
### Issue 1: Wrong secp256k1 Variant in Desktop
**Symptom:** `UnsatisfiedLinkError: no secp256k1jni in java.library.path`
**Cause:** Desktop using Android JNI variant
**Fix:** Ensure jvmMain uses `secp256k1-kmp-jni-jvm`
### Issue 2: Compose Version Mismatch
**Symptom:** `IllegalStateException: Version mismatch`
**Cause:** Compose Multiplatform plugin vs runtime version mismatch
**Fix:** Align `composeMultiplatform` version in libs.versions.toml with Kotlin plugin
### Issue 3: Duplicate JNA Classes
**Symptom:** `DuplicateClassException: com.sun.jna.Native`
**Cause:** Both JAR and AAR JNA variants in classpath
**Fix:** Use AAR (@aar) in androidMain, JAR in jvmMain (never in shared source sets)
@@ -0,0 +1,422 @@
# Version Catalog Guide
## Overview
AmethystMultiplatform uses Gradle's version catalog (`gradle/libs.versions.toml`) to centralize dependency management. This ensures version consistency across all modules and simplifies updates.
## Structure
### sections
```toml
[versions] # Version numbers (referenced by libraries and plugins)
[libraries] # Library dependencies
[plugins] # Gradle plugins
```
## Version References
### Defining Versions
```toml
[versions]
kotlin = "2.3.0"
composeMultiplatform = "1.9.3"
okhttp = "5.3.2"
```
### Special Patterns
#### Android SDK Versions
```toml
android-compileSdk = "36"
android-minSdk = "26"
android-targetSdk = "36"
```
**Access in build.gradle.kts:**
```kotlin
compileSdk = libs.versions.android.compileSdk.get().toInt()
minSdk = libs.versions.android.minSdk.get().toInt()
```
#### Version Suffixes (Git Commits)
```toml
androidKotlinGeohash = "b481c6a64e" # Jitpack commit hash
markdown = "f92ef49c9d"
```
**Why:** For GitHub dependencies via Jitpack that don't have semantic versions
## Library Declarations
### Basic Pattern
```toml
[libraries]
library-name = { group = "...", name = "...", version.ref = "..." }
```
### Examples
#### Version Reference
```toml
okhttp = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okhttp" }
```
#### Module Reference (for multi-artifact libs)
```toml
androidx-camera-core = { module = "androidx.camera:camera-core", version.ref = "androidxCamera" }
```
#### Without Group (shorthand)
```toml
androidx-ui = { group = "androidx.compose.ui", name = "ui" }
```
**Note:** Inherits version from BOM (compose-bom)
### BOMs (Bill of Materials)
#### AndroidX Compose BOM
```toml
[versions]
composeBom = "2025.12.01"
[libraries]
androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" }
androidx-ui = { group = "androidx.compose.ui", name = "ui" }
androidx-material3 = { group = "androidx.compose.material3", name = "material3" }
```
**Usage in build.gradle.kts:**
```kotlin
val composeBom = platform(libs.androidx.compose.bom)
implementation(composeBom)
implementation(libs.androidx.ui) // Version from BOM
implementation(libs.androidx.material3) // Version from BOM
```
**Benefits:**
- All AndroidX Compose artifacts use compatible versions
- Update single BOM version, not individual libraries
- Prevents version conflicts
### Platform-Specific Variants
#### secp256k1 (KMP crypto library)
```toml
secp256k1KmpJniAndroid = "0.22.0"
[libraries]
secp256k1-kmp-common = { group = "fr.acinq.secp256k1", name = "secp256k1-kmp", version.ref = "secp256k1KmpJniAndroid" }
secp256k1-kmp-jni-android = { group = "fr.acinq.secp256k1", name = "secp256k1-kmp-jni-android", version.ref = "secp256k1KmpJniAndroid" }
secp256k1-kmp-jni-jvm = { group = "fr.acinq.secp256k1", name = "secp256k1-kmp-jni-jvm", version.ref = "secp256k1KmpJniAndroid" }
```
**Critical:** All three variants MUST share the same version
#### JNA (for LibSodium)
```toml
jna = "5.18.1"
[libraries]
jna = { group = "net.java.dev.jna", name = "jna", version.ref = "jna" }
```
**Usage in build.gradle.kts:**
```kotlin
// androidMain - AAR packaging
implementation("net.java.dev.jna:jna:5.18.1@aar")
// jvmMain - JAR packaging
implementation(libs.jna)
```
**Why:** Android needs AAR, JVM needs JAR (different artifact types)
## Plugin Declarations
### Basic Pattern
```toml
[plugins]
plugin-id = { id = "...", version.ref = "..." }
```
### Examples
#### Kotlin Plugins
```toml
[versions]
kotlin = "2.3.0"
[plugins]
jetbrainsKotlinAndroid = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
jetbrainsKotlinJvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" }
kotlinMultiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" }
jetbrainsComposeCompiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
serialization = { id = 'org.jetbrains.kotlin.plugin.serialization', version.ref = 'kotlinxSerializationPlugin' }
```
**Critical:** All Kotlin plugins MUST use the same Kotlin version
#### Android Gradle Plugin
```toml
[versions]
agp = "8.13.2"
[plugins]
androidApplication = { id = "com.android.application", version.ref = "agp" }
androidLibrary = { id = "com.android.library", version.ref = "agp" }
androidKotlinMultiplatformLibrary = { id = "com.android.kotlin.multiplatform.library", version.ref = "agp" }
```
#### Compose Multiplatform
```toml
[versions]
composeMultiplatform = "1.9.3"
[plugins]
composeMultiplatform = { id = "org.jetbrains.compose", version.ref = "composeMultiplatform" }
```
### Plugin Application
```kotlin
// In build.gradle.kts
plugins {
alias(libs.plugins.kotlinMultiplatform)
alias(libs.plugins.androidLibrary)
alias(libs.plugins.composeMultiplatform)
alias(libs.plugins.jetbrainsComposeCompiler)
}
```
## Usage in Build Files
### Accessing Versions
```kotlin
// Direct version access
val kotlinVersion = libs.versions.kotlin.get()
val minSdk = libs.versions.android.minSdk.get().toInt()
```
### Accessing Libraries
```kotlin
dependencies {
implementation(libs.kotlinx.coroutines.core)
api(libs.secp256k1.kmp.common)
implementation(libs.okhttp)
}
```
### Accessing Plugins
```kotlin
plugins {
alias(libs.plugins.kotlinMultiplatform)
alias(libs.plugins.androidLibrary)
}
```
## Version Catalog Benefits
### 1. Centralized Version Management
Update once, applies everywhere:
```toml
# Change one line
kotlin = "2.3.0" "2.4.0"
# Affects all usages
- kotlinMultiplatform plugin
- jetbrainsKotlinAndroid plugin
- kotlin-stdlib
- All Kotlin-related dependencies
```
### 2. Type-Safe Accessors
```kotlin
// Compile-time checked
implementation(libs.okhttp) // ✅ IDE autocomplete
// vs string-based (error-prone)
implementation("com.squareup.okhttp3:okhttp:5.3.2") // ❌ No autocomplete
```
### 3. Dependency Consistency
```kotlin
// All modules reference same catalog
:quartz libs.okhttp
:commons libs.okhttp
:desktopApp libs.okhttp
// Same version everywhere
```
### 4. Gradle Sync Improvements
- Faster IDE sync (pre-parsed catalog)
- Better dependency resolution
- Clearer error messages
## Common Patterns
### GitHub Dependencies (Jitpack)
```toml
[versions]
markdown = "f92ef49c9d" # Git commit hash
[libraries]
markdown-ui = { group = "com.github.vitorpamplona.compose-richtext", name = "richtext-ui", version.ref = "markdown" }
```
**Repository config** (in settings.gradle):
```kotlin
repositories {
maven { url = "https://jitpack.io" }
}
```
### Multi-Artifact Libraries
```toml
[versions]
media3 = "1.9.0"
[libraries]
androidx-media3-exoplayer = { group = "androidx.media3", name = "media3-exoplayer", version.ref = "media3" }
androidx-media3-ui = { group = "androidx.media3", name = "media3-ui", version.ref = "media3" }
androidx-media3-session = { group = "androidx.media3", name = "media3-session", version.ref = "media3" }
```
**Why:** All media3 artifacts share same version for compatibility
### Test Dependencies
```toml
[libraries]
junit = { group = "junit", name = "junit", version.ref = "junit" }
androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "androidxJunit" }
mockk = { group = "io.mockk", name = "mockk", version.ref = "mockk" }
kotlinx-coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-test", version.ref = "kotlinx-coroutines-test"}
```
## Version Update Strategy
### Check for Updates
```bash
# Using Gradle Versions Plugin (if installed)
./gradlew dependencyUpdates
# Manual check
# Browse to Maven Central for specific library
```
### Update Process
1. **Update version in catalog**
```toml
okhttp = "5.3.2" → "5.4.0"
```
2. **Test locally**
```bash
./gradlew clean build
```
3. **Check for breaking changes**
- Review library changelog
- Run full test suite
4. **Commit with clear message**
```
chore: update okhttp 5.3.2 → 5.4.0
```
### Critical Version Alignments
#### Kotlin Ecosystem
```toml
kotlin = "2.3.0"
kotlinxCoroutinesCore = "1.10.2"
kotlinxSerialization = "1.9.0"
```
**Rule:** Kotlin version must be compatible with kotlinx libraries
#### Compose Ecosystem
```toml
composeMultiplatform = "1.9.3"
composeBom = "2025.12.01"
kotlin = "2.3.0"
```
**Rule:** Compose Multiplatform → Kotlin version (see compatibility matrix)
#### AGP & Gradle
```toml
agp = "8.13.2"
# Requires Gradle 8.9+
```
**Rule:** AGP version dictates minimum Gradle version
## Troubleshooting
### Issue 1: Unresolved Reference
**Error:** `Unresolved reference: libs`
**Cause:** Gradle version < 7.0 (version catalogs not supported)
**Fix:** Upgrade Gradle in `gradle/wrapper/gradle-wrapper.properties`
### Issue 2: Library Not Found
**Error:** `Could not find com.example:library:1.0.0`
**Cause:** Repository not configured or typo in catalog
**Fix:**
1. Check repository in settings.gradle
2. Verify group/name/version in libs.versions.toml
### Issue 3: Version Conflict
**Error:** `Conflict with dependency ... and ...`
**Cause:** Different versions of same library via transitive dependencies
**Fix:**
```kotlin
configurations.all {
resolutionStrategy {
force(libs.okhttp.get().toString())
}
}
```
## Best Practices
### 1. Naming Conventions
```toml
# Hyphen-separated, hierarchical
androidx-compose-ui
androidx-compose-material3
kotlinx-coroutines-core
# Platform suffixes
secp256k1-kmp-jni-android
secp256k1-kmp-jni-jvm
```
### 2. Group Related Dependencies
```toml
# Camera APIs together
androidx-camera-core
androidx-camera-camera2
androidx-camera-view
```
### 3. Document Special Cases
```toml
# JNA requires @aar for Android (see build.gradle.kts)
jna = { group = "net.java.dev.jna", name = "jna", version.ref = "jna" }
```
### 4. Keep BOMs Updated
```toml
# Update BOM, individual libs follow
composeBom = "2025.12.01" # Latest stable
```
### 5. Test Version Updates
```bash
# Before committing
./gradlew :quartz:test
./gradlew :commons:test
./gradlew :desktopApp:run
```
@@ -0,0 +1,45 @@
#!/bin/bash
# Analyze Gradle build performance and generate report
set -e
PROJECT_ROOT="${1:-.}"
cd "$PROJECT_ROOT"
echo "🔍 Analyzing Gradle build performance..."
echo "========================================"
echo ""
# Clean build for accurate timing
echo "Running clean build with --profile..."
./gradlew clean build --profile --scan
# Find the latest profile report
PROFILE_REPORT=$(find build/reports/profile -name "*.html" -type f -printf '%T@ %p\n' | sort -n | tail -1 | cut -f2- -d" ")
if [ -n "$PROFILE_REPORT" ]; then
echo ""
echo "✅ Profile report generated: $PROFILE_REPORT"
echo ""
echo "📊 Build Performance Summary:"
echo "----------------------------"
# Extract key metrics if available
if command -v jq &> /dev/null && [ -f "build/reports/profile/profile.json" ]; then
jq -r '.buildTime, .taskExecutionTime' build/reports/profile/profile.json
else
echo "Open the HTML report for detailed analysis:"
echo "file://$PWD/$PROFILE_REPORT"
fi
else
echo "⚠️ Profile report not found"
fi
echo ""
echo "💡 Build optimization tips:"
echo "- Enable Gradle daemon: org.gradle.daemon=true"
echo "- Parallel execution: org.gradle.parallel=true"
echo "- Configuration cache: org.gradle.configuration-cache=true"
echo "- Build cache: org.gradle.caching=true"
echo ""
echo "Add these to gradle.properties for faster builds"
@@ -0,0 +1,65 @@
#!/bin/bash
# Diagnose and suggest fixes for common dependency conflicts
set -e
PROJECT_ROOT="${1:-.}"
cd "$PROJECT_ROOT"
echo "🔍 Analyzing dependency conflicts..."
echo "===================================="
echo ""
# Run dependency report
echo "Generating dependency insight report..."
./gradlew dependencies --configuration runtimeClasspath > /tmp/gradle-dependencies.txt 2>&1 || true
# Check for common conflict patterns
echo ""
echo "🔎 Checking for common issues:"
echo "------------------------------"
# Check 1: Compose version conflicts
if grep -q "compose" /tmp/gradle-dependencies.txt; then
echo "✓ Compose dependencies found"
echo " Tip: Ensure Compose Multiplatform and AndroidX Compose versions align"
echo " Current project uses:"
echo " - Compose Multiplatform BOM"
echo " - AndroidX Compose BOM"
fi
# Check 2: secp256k1 variants
if grep -q "secp256k1" /tmp/gradle-dependencies.txt; then
echo "✓ secp256k1 dependencies found"
echo " Ensure correct variant:"
echo " - Android: secp256k1-kmp-jni-android"
echo " - JVM/Desktop: secp256k1-kmp-jni-jvm"
echo " - Common: secp256k1-kmp (transitive)"
fi
# Check 3: Kotlin version alignment
KOTLIN_VERSION=$(grep "kotlin =" gradle/libs.versions.toml | cut -d'"' -f2)
echo "✓ Kotlin version: $KOTLIN_VERSION"
echo " All Kotlin plugins should use the same version"
# Check 4: Multiple versions of same library
echo ""
echo "🔍 Checking for version conflicts..."
./gradlew dependencyInsight --configuration runtimeClasspath --dependency okhttp || true
echo ""
echo "💡 Common fixes:"
echo "---------------"
echo "1. Compose conflicts:"
echo " - Align compose-multiplatform plugin version with runtime"
echo " - Use BOM for AndroidX Compose to enforce consistency"
echo ""
echo "2. secp256k1 conflicts:"
echo " - Use 'api' instead of 'implementation' in source sets"
echo " - Ensure androidMain uses jni-android, jvmMain uses jni-jvm"
echo ""
echo "3. Kotlin version conflicts:"
echo " - Update all kotlin plugins to same version in libs.versions.toml"
echo " - Check for transitive Kotlin dependencies"
echo ""
echo "Run './gradlew dependencyInsight --dependency <name>' for specific conflicts"
+419
View File
@@ -0,0 +1,419 @@
---
name: kotlin-coroutines
description: Advanced Kotlin coroutines patterns for AmethystMultiplatform. Use when working with: (1) Structured concurrency (supervisorScope, coroutineScope), (2) Advanced Flow operators (flatMapLatest, combine, merge, shareIn, stateIn), (3) Channels and callbackFlow, (4) Dispatcher management and context switching, (5) Exception handling (CoroutineExceptionHandler, SupervisorJob), (6) Testing async code (runTest, Turbine), (7) Nostr relay connection pools and subscriptions, (8) Backpressure handling in event streams. Delegates to kotlin-expert for basic StateFlow/SharedFlow patterns. Complements nostr-expert for relay communication.
---
# Kotlin Coroutines - Advanced Async Patterns
Expert guidance for complex async operations in Amethyst: relay pools, event streams, structured concurrency, and testing.
## Mental Model
```
Async Architecture in Amethyst:
Relay Pool (supervisorScope)
├── Relay 1 (launch) → callbackFlow → Events
├── Relay 2 (launch) → callbackFlow → Events
└── Relay 3 (launch) → callbackFlow → Events
merge() → distinctBy(id) → shareIn
Multiple Collectors (ViewModels, Services)
```
**Key principles:**
- **supervisorScope** - Children fail independently
- **callbackFlow** - Bridge callbacks to Flow
- **shareIn/stateIn** - Hot flows from cold
- **Backpressure** - buffer(), conflate(), DROP_OLDEST
## When to Use This Skill
Use for **advanced** async patterns:
- Multi-relay subscriptions with supervisorScope
- Complex Flow operators (flatMapLatest, combine, merge)
- callbackFlow for Android callbacks (connectivity, location)
- Backpressure handling in high-frequency streams
- Exception handling with CoroutineExceptionHandler
- Testing coroutines with runTest and Turbine
**Delegate to kotlin-expert for:**
- Basic StateFlow/SharedFlow patterns
- Simple viewModelScope.launch
- MutableStateFlow → asStateFlow()
## Core Patterns
### Pattern: callbackFlow for Relay Subscriptions
```kotlin
// Real pattern from NostrClientStaticReqAsStateFlow.kt
fun INostrClient.reqAsFlow(
relay: NormalizedRelayUrl,
filters: List<Filter>,
): Flow<List<Event>> = callbackFlow {
val subId = RandomInstance.randomChars(10)
var hasBeenLive = false
val eventIds = mutableSetOf<HexKey>()
var currentEvents = listOf<Event>()
val listener = object : IRequestListener {
override fun onEvent(event: Event, ...) {
if (event.id !in eventIds) {
currentEvents = if (hasBeenLive) {
// After EOSE: prepend
listOf(event) + currentEvents
} else {
// Before EOSE: append
currentEvents + event
}
eventIds.add(event.id)
trySend(currentEvents)
}
}
override fun onEose(...) {
hasBeenLive = true
}
}
openReqSubscription(subId, mapOf(relay to filters), listener)
awaitClose { close(subId) }
}
```
**Key techniques:**
1. Deduplication with Set
2. EOSE handling (append → prepend strategy)
3. trySend (non-blocking from callback)
4. awaitClose for cleanup
### Pattern: Structured Concurrency for Relays
```kotlin
suspend fun connectToRelays(relays: List<Relay>) = supervisorScope {
relays.forEach { relay ->
launch {
try {
relay.connect()
relay.subscribe(filters).collect { event ->
eventChannel.send(event)
}
} catch (e: IOException) {
Log.e("Relay", "Connection failed: ${relay.url}", e)
// Other relays continue
}
}
}
}
```
**Why supervisorScope:**
- One relay failure doesn't cancel others
- All cancelled together when scope cancelled
- Proper cleanup guaranteed
### Pattern: Exception Handling for Services
```kotlin
// Real pattern from PushNotificationReceiverService.kt
class MyService : Service() {
val exceptionHandler = CoroutineExceptionHandler { _, throwable ->
Log.e("Service", "Caught: ${throwable.message}", throwable)
}
private val scope = CoroutineScope(
Dispatchers.IO + SupervisorJob() + exceptionHandler
)
override fun onDestroy() {
scope.cancel()
super.onDestroy()
}
}
```
**Pattern benefits:**
- SupervisorJob: children fail independently
- ExceptionHandler: log instead of crash
- Scoped lifecycle: cancel all on destroy
### Pattern: Network Connectivity as Flow
```kotlin
// Real pattern from ConnectivityFlow.kt
val status = callbackFlow {
val networkCallback = object : NetworkCallback() {
override fun onAvailable(network: Network) {
trySend(ConnectivityStatus.Active(...))
}
override fun onLost(network: Network) {
trySend(ConnectivityStatus.Off)
}
}
connectivityManager.registerCallback(networkCallback)
// Initial state
activeNetwork?.let { trySend(ConnectivityStatus.Active(...)) }
awaitClose {
connectivityManager.unregisterCallback(networkCallback)
}
}
.distinctUntilChanged()
.debounce(200) // Stabilize flapping
.flowOn(Dispatchers.IO)
```
**Key patterns:**
1. Emit initial state immediately
2. Register callback in flow body
3. Cleanup in awaitClose
4. Stabilize with debounce + distinctUntilChanged
### Pattern: Merge Events from Multiple Relays
```kotlin
fun observeFromRelays(
relays: List<NormalizedRelayUrl>,
filters: List<Filter>
): Flow<Event> =
relays.map { relay ->
client.reqAsFlow(relay, filters)
.flatMapConcat { it.asFlow() }
}.merge()
.distinctBy { it.id }
```
**Flow:**
- Each relay: `Flow<List<Event>>`
- flatMapConcat: flatten to `Flow<Event>`
- merge(): combine all relays
- distinctBy: deduplicate across relays
## Advanced Operators
For comprehensive coverage of Flow operators:
- **flatMapLatest, combine, zip, merge** → See [advanced-flow-operators.md](references/advanced-flow-operators.md)
- **shareIn, stateIn** → Conversion to hot flows
- **buffer, conflate** → Backpressure strategies
- **debounce, sample** → Rate limiting
### Quick Reference
| Operator | Use Case | Example |
|----------|----------|---------|
| **flatMapLatest** | Cancel previous, switch to new | Search (cancel old query) |
| **combine** | Latest from ALL flows | combine(account, settings, connectivity) |
| **merge** | Single stream from multiple | merge(relay1, relay2, relay3) |
| **shareIn** | Multiple collectors, single upstream | Share expensive computation |
| **stateIn** | StateFlow from Flow | ViewModel state |
| **buffer(DROP_OLDEST)** | High-frequency streams | Real-time event feed |
| **conflate** | Latest only | UI updates |
| **debounce** | Wait for quiet period | Search input |
## Nostr Relay Patterns
For complete relay-specific patterns:
→ See [relay-patterns.md](references/relay-patterns.md)
Covers:
- Multi-relay subscription management
- Connection lifecycle and reconnection
- Event deduplication strategies
- Backpressure for high-frequency events
- EOSE handling patterns
## Testing
For comprehensive testing patterns:
→ See [testing-coroutines.md](references/testing-coroutines.md)
**Quick testing pattern:**
```kotlin
@Test
fun `relay subscription receives events`() = runTest {
val client = FakeNostrClient()
client.reqAsFlow(relay, filters).test {
assertEquals(emptyList(), awaitItem())
client.sendEvent(event1)
assertEquals(listOf(event1), awaitItem())
cancelAndIgnoreRemainingEvents()
}
}
```
**Testing tools:**
- `runTest` - Virtual time, auto cleanup
- Turbine `.test {}` - Flow assertions
- `advanceTimeBy()` - Control time
- Fake implementations over mocks
## Common Scenarios
### Scenario: Implement New Relay Feature
**Steps:**
1. callbackFlow for subscription
2. Deduplication (Set of event IDs)
3. awaitClose for cleanup
4. Test with FakeNostrClient
**Example:** Add subscription for specific event kind
```kotlin
fun observeKind(kind: Int): Flow<Event> = callbackFlow {
val listener = object : IRequestListener {
override fun onEvent(event: Event, ...) {
if (event.kind == kind) {
trySend(event)
}
}
}
client.subscribe(listener)
awaitClose { client.unsubscribe(listener) }
}
```
### Scenario: Handle Network Connectivity Changes
**Steps:**
1. callbackFlow for connectivity
2. flatMapLatest to reconnect
3. debounce to stabilize
4. Exception handling for failures
**Example:** Reconnect relays on connectivity
```kotlin
connectivityFlow
.flatMapLatest { status ->
when (status) {
Active -> relayPool.observeEvents()
else -> emptyFlow()
}
}
.catch { e -> Log.e("Error", e) }
.collect { event -> handleEvent(event) }
```
### Scenario: Optimize Multi-Collector Performance
**Steps:**
1. Use shareIn for expensive upstream
2. Configure SharingStarted strategy
3. Set replay buffer size
4. Test with multiple collectors
**Example:** Share relay subscription
```kotlin
val events: SharedFlow<Event> = client
.reqAsFlow(relay, filters)
.flatMapConcat { it.asFlow() }
.shareIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5000),
replay = 0
)
```
## Anti-Patterns
**Using GlobalScope**
```kotlin
GlobalScope.launch { /* Leaks, no structured concurrency */ }
```
**Use scoped coroutines**
```kotlin
viewModelScope.launch { /* Cancelled with ViewModel */ }
```
---
**Forgetting awaitClose**
```kotlin
callbackFlow {
registerCallback()
// Missing cleanup!
}
```
**Always cleanup**
```kotlin
callbackFlow {
registerCallback()
awaitClose { unregisterCallback() }
}
```
---
**Blocking in Flow**
```kotlin
flow.map { Thread.sleep(1000); process(it) }
```
**Suspend, don't block**
```kotlin
flow.map { delay(1000); process(it) }.flowOn(Dispatchers.Default)
```
---
**Ignoring backpressure**
```kotlin
fastProducer.collect { slowConsumer(it) } // Blocks producer!
```
**Handle backpressure**
```kotlin
fastProducer
.buffer(64, BufferOverflow.DROP_OLDEST)
.collect { slowConsumer(it) }
```
## Delegation
**Use kotlin-expert for:**
- Basic StateFlow/SharedFlow patterns
- viewModelScope.launch usage
- Simple MutableStateFlow → asStateFlow()
**Use nostr-expert for:**
- Nostr protocol details (NIPs, event structure)
- Event creation and signing
- Cryptographic operations
**This skill provides:**
- Advanced async patterns
- Structured concurrency
- Complex Flow operators
- Testing strategies
- Relay-specific async patterns
## Resources
- **references/advanced-flow-operators.md** - All Flow operators with examples
- **references/relay-patterns.md** - Nostr relay async patterns from codebase
- **references/testing-coroutines.md** - Complete testing guide
## Quick Decision Tree
```
Need async operation?
├─ Simple ViewModel state update → kotlin-expert (StateFlow)
├─ Android callback → This skill (callbackFlow)
├─ Multiple concurrent operations → This skill (supervisorScope)
├─ Complex Flow transformation → This skill (references/advanced-flow-operators.md)
├─ Relay subscription → This skill (references/relay-patterns.md)
└─ Testing async code → This skill (references/testing-coroutines.md)
```
@@ -0,0 +1,309 @@
# Advanced Flow Operators
Comprehensive guide to Flow operators for complex async patterns in Amethyst.
## Transformation Operators
### flatMapLatest - Cancel Previous, Switch to New
**Use when:** Latest value matters, previous operations should cancel
```kotlin
// User types in search box → cancel previous search
searchQuery
.flatMapLatest { query ->
repository.search(query) // Cancels previous search
}
.collect { results -> updateUI(results) }
```
**Amethyst pattern:**
```kotlin
// Switch relays based on latest account
accountFlow
.flatMapLatest { account ->
relayPool.observeEvents(account.relays)
}
```
### flatMapConcat - Sequential Processing
**Use when:** Order matters, process one at a time
```kotlin
eventIds
.flatMapConcat { id ->
repository.fetchEvent(id)
}
.collect { event -> process(event) }
```
### flatMapMerge - Concurrent Processing
**Use when:** Process multiple simultaneously, order doesn't matter
```kotlin
relays
.flatMapMerge(concurrency = 10) { relay ->
relay.subscribe(filters)
}
.collect { event -> handleEvent(event) }
```
## Combination Operators
### combine - Latest from Multiple Flows
**Use when:** Need latest value from ALL flows
```kotlin
combine(
accountFlow,
settingsFlow,
connectivityFlow
) { account, settings, connectivity ->
AppState(account, settings, connectivity)
}.collect { state -> render(state) }
```
**Pattern:** Re-emits whenever ANY source emits
### zip - Pair Values in Order
**Use when:** Need corresponding values from flows
```kotlin
zip(requestFlow, responseFlow) { req, res ->
Pair(req, res)
}
```
**Pattern:** Waits for BOTH to emit before pairing
### merge - Combine Multiple Flows
**Use when:** Treat multiple flows as single stream
```kotlin
merge(
relay1.events,
relay2.events,
relay3.events
).collect { event -> handleEvent(event) }
```
## Backpressure & Buffering
### shareIn - Hot Flow from Cold
**Use when:** Multiple collectors should share single upstream
```kotlin
val sharedEvents = repository.observeEvents()
.shareIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5000),
replay = 0
)
// Multiple collectors share same upstream
sharedEvents.collect { /* collector 1 */ }
sharedEvents.collect { /* collector 2 */ }
```
**SharingStarted strategies:**
- `Eagerly` - Start immediately, never stop
- `Lazily` - Start on first subscriber, never stop
- `WhileSubscribed(stopTimeout)` - Stop after last unsubscribe + timeout
### stateIn - StateFlow from Cold Flow
**Use when:** Convert Flow to StateFlow (always has value)
```kotlin
val uiState: StateFlow<UiState> = repository.observeData()
.map { data -> UiState.Success(data) }
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5000),
initialValue = UiState.Loading
)
```
**Amethyst pattern:**
```kotlin
// Connectivity status as StateFlow
val connectivity: StateFlow<ConnectivityStatus> =
connectivityFlow.status
.stateIn(
scope = serviceScope,
started = SharingStarted.Eagerly,
initialValue = ConnectivityStatus.Off
)
```
### buffer - Control Backpressure
**Use when:** Producer faster than consumer
```kotlin
eventFlow
.buffer(capacity = 64, onBufferOverflow = BufferOverflow.DROP_OLDEST)
.collect { event -> slowProcessor(event) }
```
**Strategies:**
- `SUSPEND` - Slow down producer (default)
- `DROP_OLDEST` - Drop oldest in buffer
- `DROP_LATEST` - Drop newest emission
### conflate - Keep Only Latest
**Use when:** Only latest value matters, skip intermediate
```kotlin
locationFlow
.conflate() // Skip intermediate locations
.collect { location -> updateMap(location) }
```
## Debouncing & Throttling
### debounce - Wait for Quiet Period
**Use when:** Wait for user to stop typing
```kotlin
searchQuery
.debounce(300) // Wait 300ms after last emission
.flatMapLatest { query -> search(query) }
```
**Amethyst pattern:**
```kotlin
// ConnectivityFlow.kt:87
connectivityFlow
.distinctUntilChanged()
.debounce(200) // Wait 200ms for network to stabilize
.flowOn(Dispatchers.IO)
```
### sample - Periodic Sampling
**Use when:** Rate-limit high-frequency emissions
```kotlin
sensorData
.sample(1000) // Sample every 1 second
.collect { data -> process(data) }
```
## Error Handling
### catch - Handle Upstream Errors
**Use when:** Graceful degradation needed
```kotlin
repository.fetchData()
.catch { e ->
Log.e("Error", e)
emit(emptyList()) // Fallback value
}
.collect { data -> updateUI(data) }
```
**Pattern:** Only catches UPSTREAM errors, not in collect block
### retry/retryWhen - Automatic Retry
```kotlin
relayConnection
.retry(3) { cause ->
cause is IOException // Only retry on network errors
}
```
## Context Switching
### flowOn - Change Upstream Dispatcher
**Use when:** Offload work from current context
```kotlin
repository.fetchData()
.map { heavyProcessing(it) }
.flowOn(Dispatchers.Default) // Heavy work on Default
.collect { updateUI(it) } // Collect on Main
```
**Critical:** Only affects UPSTREAM operators
**Amethyst pattern:**
```kotlin
// ConnectivityFlow.kt:87
callbackFlow { /* ... */ }
.distinctUntilChanged()
.debounce(200)
.flowOn(Dispatchers.IO) // All upstream on IO
```
## Common Patterns
### Pattern: Multi-Relay Subscription
```kotlin
fun observeFromMultipleRelays(relays: List<Relay>, filters: List<Filter>): Flow<Event> =
relays.map { relay ->
relay.subscribe(filters)
}.merge()
.distinctBy { it.id }
```
### Pattern: Load + Cache + Observe
```kotlin
fun observeWithCache(id: String): Flow<Data> = flow {
// Emit cached value immediately
cache[id]?.let { emit(it) }
// Then observe updates
emitAll(repository.observe(id))
}.distinctUntilChanged()
```
### Pattern: Retry with Exponential Backoff
```kotlin
fun <T> Flow<T>.retryWithBackoff(
maxRetries: Int = 3,
initialDelay: Long = 1000
): Flow<T> = retryWhen { cause, attempt ->
if (attempt >= maxRetries || cause !is IOException) {
false
} else {
delay(initialDelay * (1L shl attempt.toInt()))
true
}
}
```
## Performance Tips
1. **Use shareIn for expensive operations**
- Compute once, share with multiple collectors
2. **Choose right backpressure strategy**
- UI updates: `conflate()` or `DROP_OLDEST`
- Events: `buffer()` with appropriate size
3. **flowOn placement matters**
- Place after expensive operators to offload them
4. **Avoid unnecessary emissions**
- Use `distinctUntilChanged()` when appropriate
- Consider `debounce()` for high-frequency sources
5. **StateFlow vs SharedFlow**
- StateFlow: Always has value, conflates
- SharedFlow: Optional replay, configurable buffering
@@ -0,0 +1,480 @@
# Nostr Relay Async Patterns
Proven coroutine patterns for Nostr relay connections, subscriptions, and event streaming in Amethyst.
## Core Pattern: callbackFlow for Relay Subscriptions
### Pattern: Subscription as Flow
**Real implementation from NostrClientStaticReqAsStateFlow.kt:**
```kotlin
fun INostrClient.reqAsFlow(
relay: NormalizedRelayUrl,
filters: List<Filter>,
): Flow<List<Event>> =
callbackFlow {
val subId = RandomInstance.randomChars(10)
var hasBeenLive = false
val eventIds = mutableSetOf<HexKey>()
var currentEvents = listOf<Event>()
val listener = object : IRequestListener {
override fun onEvent(
event: Event,
isLive: Boolean,
relay: NormalizedRelayUrl,
forFilters: List<Filter>?,
) {
if (event.id !in eventIds) {
if (hasBeenLive) {
// After EOSE: prepend new events
val list = ArrayList<Event>(1 + currentEvents.size)
list.add(event)
list.addAll(currentEvents)
currentEvents = list
} else {
// Before EOSE: append events
currentEvents = currentEvents + event
}
eventIds.add(event.id)
trySend(currentEvents)
}
}
override fun onEose(
relay: NormalizedRelayUrl,
forFilters: List<Filter>?,
) {
hasBeenLive = true
}
}
openReqSubscription(subId, mapOf(relay to filters), listener)
awaitClose {
close(subId)
}
}
```
**Key techniques:**
1. **callbackFlow** - Bridge callback API to Flow
2. **Deduplication** - `eventIds` set prevents duplicates
3. **EOSE handling** - Changes insertion strategy (append → prepend)
4. **awaitClose** - Cleanup when flow cancelled
5. **trySend** - Non-blocking emission from callback
## Multi-Relay Patterns
### Pattern: Merge Events from Multiple Relays
```kotlin
fun observeFromRelays(
relays: List<NormalizedRelayUrl>,
filters: List<Filter>
): Flow<Event> =
relays.map { relay ->
client.reqAsFlow(relay, filters)
.flatMapConcat { it.asFlow() }
}.merge()
.distinctBy { it.id }
```
**Explanation:**
- Each relay produces `Flow<List<Event>>`
- `flatMapConcat` flattens to `Flow<Event>`
- `merge()` combines all relay flows
- `distinctBy` deduplicates across relays
### Pattern: Concurrent Relay Operations with supervisorScope
```kotlin
suspend fun subscribeToRelays(
relays: List<Relay>,
filters: List<Filter>
) = supervisorScope {
relays.forEach { relay ->
launch {
relay.subscribe(filters).collect { event ->
eventChannel.send(event)
}
}
}
}
```
**Why supervisorScope:**
- If one relay fails, others continue
- All children cancelled when scope cancelled
- Structured concurrency maintained
## Backpressure Handling
### Pattern: Buffer with Drop Strategy
**For high-frequency event streams:**
```kotlin
relayFlow
.buffer(
capacity = 64,
onBufferOverflow = BufferOverflow.DROP_OLDEST
)
.collect { event -> processEvent(event) }
```
**Strategy selection:**
- `DROP_OLDEST` - For real-time feeds (lose old events OK)
- `DROP_LATEST` - For priority queues (lose new events OK)
- `SUSPEND` - For critical events (slow down producer)
### Pattern: Conflate for UI Updates
```kotlin
val uiEvents: Flow<UiEvent> = relayEvents
.map { event -> toUiEvent(event) }
.conflate() // Skip intermediate, show latest
.flowOn(Dispatchers.Default)
```
## Connection Management
### Pattern: Network Connectivity as Flow
**Real implementation from ConnectivityFlow.kt:**
```kotlin
@OptIn(FlowPreview::class)
val status = callbackFlow {
trySend(ConnectivityStatus.StartingService)
val connectivityManager = context.getConnectivityManager()
val networkCallback = object : ConnectivityManager.NetworkCallback() {
override fun onAvailable(network: Network) {
connectivityManager.getNetworkCapabilities(network)?.let {
trySend(ConnectivityStatus.Active(
network.networkHandle,
it.isMeteredOrMobileData()
))
}
}
override fun onCapabilitiesChanged(
network: Network,
networkCapabilities: NetworkCapabilities
) {
val isMobile = networkCapabilities.isMeteredOrMobileData()
trySend(ConnectivityStatus.Active(
network.networkHandle,
isMobile
))
}
override fun onLost(network: Network) {
trySend(ConnectivityStatus.Off)
}
}
connectivityManager.registerDefaultNetworkCallback(networkCallback)
// Send initial state
connectivityManager.activeNetwork?.let { network ->
connectivityManager.getNetworkCapabilities(network)?.let {
trySend(ConnectivityStatus.Active(
network.networkHandle,
it.isMeteredOrMobileData()
))
}
}
awaitClose {
connectivityManager.unregisterNetworkCallback(networkCallback)
trySend(ConnectivityStatus.Off)
}
}
.distinctUntilChanged()
.debounce(200) // Stabilize rapid changes
.flowOn(Dispatchers.IO)
```
**Key patterns:**
1. **Initial state** - Emit current connectivity immediately
2. **Callback registration** - Register listener in flow body
3. **Cleanup** - Unregister in `awaitClose`
4. **Stabilization** - `debounce(200)` prevents flapping
5. **Deduplication** - `distinctUntilChanged()` skips redundant updates
### Pattern: Reconnect on Connectivity Change
```kotlin
connectivityFlow
.flatMapLatest { status ->
when (status) {
is ConnectivityStatus.Active -> {
relayPool.connectAll()
relayPool.observeEvents()
}
else -> emptyFlow()
}
}
.collect { event -> handleEvent(event) }
```
## Exception Handling in Async Operations
### Pattern: CoroutineExceptionHandler + SupervisorJob
**Real implementation from PushNotificationReceiverService.kt:**
```kotlin
class PushNotificationReceiverService : FirebaseMessagingService() {
// Catch all uncaught exceptions
val exceptionHandler = CoroutineExceptionHandler { _, throwable ->
Log.e("AmethystCoroutine", "Caught exception: ${throwable.message}", throwable)
}
// Children fail independently, handler catches all
private val scope = CoroutineScope(
Dispatchers.IO + SupervisorJob() + exceptionHandler
)
override fun onMessageReceived(remoteMessage: RemoteMessage) {
scope.launch(Dispatchers.IO) {
parseMessage(remoteMessage.data)?.let { receiveIfNew(it) }
}
}
override fun onDestroy() {
scope.cancel()
super.onDestroy()
}
}
```
**Why this pattern:**
- **SupervisorJob** - One failure doesn't cancel others
- **ExceptionHandler** - Log exceptions, don't crash
- **Scoped lifecycle** - Cancel all on destroy
### Pattern: Retry with Backoff for Relay Connections
```kotlin
fun connectWithRetry(relay: Relay): Flow<ConnectionStatus> = flow {
var attempt = 0
val maxRetries = 5
val baseDelay = 1000L
while (attempt < maxRetries) {
try {
emit(ConnectionStatus.Connecting)
relay.connect()
emit(ConnectionStatus.Connected)
return@flow
} catch (e: Exception) {
attempt++
emit(ConnectionStatus.Error(e, attempt))
if (attempt < maxRetries) {
val delay = baseDelay * (1L shl attempt) // Exponential backoff
delay(delay)
}
}
}
emit(ConnectionStatus.Failed)
}
```
## Subscription Lifecycle
### Pattern: Auto-Cleanup Subscription
```kotlin
@Composable
fun ObserveRelayEvents(
filters: List<Filter>,
onEvent: (Event) -> Unit
) {
val scope = rememberCoroutineScope()
DisposableEffect(filters) {
val job = scope.launch {
relayClient.reqAsFlow(filters).collect { events ->
events.forEach { onEvent(it) }
}
}
onDispose {
job.cancel() // Cancels flow, triggers awaitClose
}
}
}
```
**Lifecycle:**
1. Composable enters → subscribe
2. filters change → cancel + re-subscribe
3. Composable leaves → cancel + cleanup
### Pattern: Multiple Concurrent Subscriptions
```kotlin
fun observeMultipleFeeds(
account: Account
): Flow<Event> = channelFlow {
supervisorScope {
// Home feed
launch {
client.reqAsFlow(filters = homeFeedFilters)
.collect { events -> events.forEach { send(it) } }
}
// Notifications
launch {
client.reqAsFlow(filters = notificationFilters)
.collect { events -> events.forEach { send(it) } }
}
// DMs
launch {
client.reqAsFlow(filters = dmFilters)
.collect { events -> events.forEach { send(it) } }
}
}
}
```
**Benefits:**
- All subscriptions run concurrently
- One failure doesn't affect others (supervisorScope)
- Single output channel for all events
## Performance Optimization
### Pattern: Shared Upstream for Multiple Collectors
```kotlin
class RelayViewModel(private val client: INostrClient) : ViewModel() {
val events: SharedFlow<Event> = client
.reqAsFlow(relay, filters)
.flatMapConcat { it.asFlow() }
.shareIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5000),
replay = 0
)
}
// Multiple collectors share single relay subscription
events.collect { /* UI 1 */ }
events.collect { /* UI 2 */ }
```
### Pattern: Event Deduplication Cache
```kotlin
class EventCache {
private val seen = mutableSetOf<HexKey>()
fun filterNew(events: List<Event>): List<Event> =
events.filter { event ->
if (event.id in seen) {
false
} else {
seen.add(event.id)
true
}
}
}
val deduplicatedEvents = relayEvents
.map { events -> cache.filterNew(events) }
.filter { it.isNotEmpty() }
```
## Testing Relay Flows
### Pattern: Test with Fake Relay
```kotlin
@Test
fun `subscription receives events`() = runTest {
val fakeRelay = FakeRelay()
val client = NostrClient(fakeRelay)
val events = mutableListOf<Event>()
val job = launch {
client.reqAsFlow(relay, filters).collect { list ->
events.addAll(list)
}
}
// Simulate relay responses
fakeRelay.sendEvent(testEvent1)
advanceTimeBy(100)
fakeRelay.sendEvent(testEvent2)
advanceTimeBy(100)
assertEquals(2, events.size)
job.cancel()
}
```
## Common Pitfalls
### ❌ Forgetting awaitClose
```kotlin
// BAD: Subscription never cleaned up
callbackFlow {
relay.subscribe(listener)
// Missing awaitClose!
}
```
```kotlin
// GOOD: Proper cleanup
callbackFlow {
relay.subscribe(listener)
awaitClose {
relay.unsubscribe(listener)
}
}
```
### ❌ Using GlobalScope
```kotlin
// BAD: Unstructured, leaks
GlobalScope.launch {
relay.connect()
}
```
```kotlin
// GOOD: Scoped to lifecycle
viewModelScope.launch {
relay.connect()
}
```
### ❌ Blocking in Flow Operators
```kotlin
// BAD: Blocks collector
flow.map { event ->
Thread.sleep(1000) // Blocks!
process(event)
}
```
```kotlin
// GOOD: Use flowOn to offload
flow
.map { event ->
delay(1000) // Suspends, doesn't block
process(event)
}
.flowOn(Dispatchers.Default)
```
@@ -0,0 +1,493 @@
# Testing Coroutines
Comprehensive guide for testing async code with runTest, Turbine, and best practices.
## runTest - Standard Testing
### Basic Pattern
```kotlin
@Test
fun `test suspend function`() = runTest {
val result = repository.fetchData()
assertEquals(expected, result)
}
```
**What runTest does:**
- Skips delays automatically
- Provides TestScope
- Advances virtual time
- Waits for all coroutines to complete
### Testing StateFlow
```kotlin
@Test
fun `stateflow updates correctly`() = runTest {
val viewModel = MyViewModel()
// Initial state
assertEquals(UiState.Loading, viewModel.state.value)
// Trigger action
viewModel.loadData()
advanceUntilIdle() // Run all pending coroutines
// Verify final state
assertEquals(UiState.Success(data), viewModel.state.value)
}
```
### Testing with Time Control
```kotlin
@Test
fun `debounce works correctly`() = runTest {
val viewModel = SearchViewModel()
viewModel.search("a")
advanceTimeBy(100) // 100ms passed
viewModel.search("ab")
advanceTimeBy(100)
viewModel.search("abc")
advanceTimeBy(300) // Debounce completes
// Only "abc" should have triggered search
assertEquals(listOf("abc"), viewModel.searchQueries)
}
```
**Time control functions:**
- `advanceTimeBy(millis)` - Move virtual time forward
- `advanceUntilIdle()` - Run all pending work
- `runCurrent()` - Run currently scheduled tasks only
## Turbine - Flow Testing Library
### Basic Collection Testing
```kotlin
@Test
fun `flow emits expected values`() = runTest {
repository.observeData().test {
assertEquals(Item1, awaitItem())
assertEquals(Item2, awaitItem())
assertEquals(Item3, awaitItem())
awaitComplete()
}
}
```
### Testing Flow Transformations
```kotlin
@Test
fun `map transforms correctly`() = runTest {
val source = flowOf(1, 2, 3)
source
.map { it * 2 }
.test {
assertEquals(2, awaitItem())
assertEquals(4, awaitItem())
assertEquals(6, awaitItem())
awaitComplete()
}
}
```
### Testing Relay Subscriptions
```kotlin
@Test
fun `relay subscription receives events`() = runTest {
val fakeClient = FakeNostrClient()
fakeClient.reqAsFlow(relay, filters).test {
// Initially empty
assertEquals(emptyList(), awaitItem())
// Send event
fakeClient.sendEvent(event1)
assertEquals(listOf(event1), awaitItem())
// Send another
fakeClient.sendEvent(event2)
assertEquals(listOf(event1, event2), awaitItem())
cancelAndIgnoreRemainingEvents()
}
}
```
### Testing Error Handling
```kotlin
@Test
fun `catch handles errors gracefully`() = runTest {
val errorFlow = flow {
emit(1)
throw IOException("Network error")
}.catch { emit(-1) } // Fallback value
errorFlow.test {
assertEquals(1, awaitItem())
assertEquals(-1, awaitItem())
awaitComplete()
}
}
```
### Testing StateFlow with Turbine
```kotlin
@Test
fun `stateflow emits updates`() = runTest {
val viewModel = MyViewModel()
viewModel.state.test {
// Skip initial value
assertEquals(UiState.Loading, awaitItem())
// Trigger update
viewModel.loadData()
assertEquals(UiState.Success(data), awaitItem())
cancelAndIgnoreRemainingEvents()
}
}
```
**Turbine assertions:**
- `awaitItem()` - Get next emission or fail
- `awaitComplete()` - Verify flow completed
- `awaitError()` - Verify flow threw exception
- `expectNoEvents()` - Assert no emissions in timeframe
- `cancelAndIgnoreRemainingEvents()` - Stop test
## Testing Patterns for Amethyst
### Pattern: Test Relay Connection Flow
```kotlin
@Test
fun `reconnects on connectivity change`() = runTest {
val connectivityFlow = MutableStateFlow(ConnectivityStatus.Off)
val relayPool = FakeRelayPool()
connectivityFlow
.flatMapLatest { status ->
when (status) {
is ConnectivityStatus.Active -> relayPool.connectAll()
else -> emptyFlow()
}
}
.test {
// Initially offline
expectNoEvents()
// Go online
connectivityFlow.value = ConnectivityStatus.Active(1L, false)
assertTrue(relayPool.connected)
cancelAndIgnoreRemainingEvents()
}
}
```
### Pattern: Test Event Deduplication
```kotlin
@Test
fun `deduplicates events across relays`() = runTest {
val relay1 = FakeRelay()
val relay2 = FakeRelay()
merge(relay1.events, relay2.events)
.distinctBy { it.id }
.test {
// Both relays send same event
relay1.send(event1)
relay2.send(event1)
// Only one emission
assertEquals(event1, awaitItem())
expectNoEvents()
cancelAndIgnoreRemainingEvents()
}
}
```
### Pattern: Test Backpressure Handling
```kotlin
@Test
fun `drops oldest events when buffer full`() = runTest {
val fastProducer = flow {
repeat(100) { emit(it) }
}
fastProducer
.buffer(capacity = 10, onBufferOverflow = BufferOverflow.DROP_OLDEST)
.test {
// Slow consumer
delay(100)
// Should have dropped oldest, kept newest
val items = mutableListOf<Int>()
repeat(10) {
items.add(awaitItem())
}
// Newest items present
assertTrue(90 in items)
assertTrue(99 in items)
awaitComplete()
}
}
```
### Pattern: Test Concurrent Subscriptions
```kotlin
@Test
fun `multiple subscriptions run concurrently`() = runTest {
val client = FakeNostrClient()
val feed1 = async { client.reqAsFlow(relay1, filters1).first() }
val feed2 = async { client.reqAsFlow(relay2, filters2).first() }
client.sendTo(relay1, event1)
client.sendTo(relay2, event2)
assertEquals(listOf(event1), feed1.await())
assertEquals(listOf(event2), feed2.await())
}
```
## Fakes and Mocks
### Fake NostrClient
```kotlin
class FakeNostrClient : INostrClient {
private val subscriptions = mutableMapOf<String, MutableSharedFlow<Event>>()
override fun reqAsFlow(
relay: NormalizedRelayUrl,
filters: List<Filter>
): Flow<List<Event>> = callbackFlow {
val subId = RandomInstance.randomChars(10)
val flow = MutableSharedFlow<Event>()
subscriptions[subId] = flow
val events = mutableListOf<Event>()
flow.collect { event ->
events.add(event)
send(events.toList())
}
awaitClose {
subscriptions.remove(subId)
}
}
fun sendEvent(event: Event) {
subscriptions.values.forEach { it.tryEmit(event) }
}
fun sendTo(relay: NormalizedRelayUrl, event: Event) {
subscriptions[relay.url]?.tryEmit(event)
}
}
```
### Fake Relay Pool
```kotlin
class FakeRelayPool {
var connected = false
private val _events = MutableSharedFlow<Event>()
val events: SharedFlow<Event> = _events.asSharedFlow()
fun connectAll(): Flow<Unit> = flow {
connected = true
emit(Unit)
}
fun disconnect() {
connected = false
}
suspend fun sendEvent(event: Event) {
_events.emit(event)
}
}
```
## Testing Exception Handling
### Test CoroutineExceptionHandler
```kotlin
@Test
fun `exception handler catches errors`() = runTest {
val errors = mutableListOf<Throwable>()
val handler = CoroutineExceptionHandler { _, throwable ->
errors.add(throwable)
}
val scope = CoroutineScope(
Dispatchers.Unconfined + SupervisorJob() + handler
)
scope.launch {
throw IOException("Test error")
}
advanceUntilIdle()
assertEquals(1, errors.size)
assertTrue(errors[0] is IOException)
}
```
### Test Retry Logic
```kotlin
@Test
fun `retries failed connections`() = runTest {
var attempts = 0
val maxRetries = 3
flow {
attempts++
if (attempts < maxRetries) {
throw IOException("Connection failed")
}
emit("Success")
}
.retry(maxRetries)
.test {
assertEquals("Success", awaitItem())
awaitComplete()
assertEquals(3, attempts)
}
}
```
## Common Testing Patterns
### Pattern: Verify No Emissions After Cancellation
```kotlin
@Test
fun `no emissions after cancellation`() = runTest {
val flow = flow {
emit(1)
delay(1000)
emit(2) // Should not emit
}
flow.test {
assertEquals(1, awaitItem())
cancel()
// Verify no more emissions
expectNoEvents()
}
}
```
### Pattern: Test Time-Based Operations
```kotlin
@Test
fun `periodic emission works`() = runTest {
flow {
repeat(3) {
emit(it)
delay(1000)
}
}.test {
assertEquals(0, awaitItem())
advanceTimeBy(1000)
assertEquals(1, awaitItem())
advanceTimeBy(1000)
assertEquals(2, awaitItem())
awaitComplete()
}
}
```
### Pattern: Test Hot Flow Conversion
```kotlin
@Test
fun `shareIn creates hot flow`() = runTest {
var emissions = 0
val source = flow {
repeat(3) {
emissions++
emit(it)
}
}
val shared = source.shareIn(
scope = this,
started = SharingStarted.Eagerly,
replay = 1
)
// First collector
shared.take(2).collect()
assertEquals(2, emissions) // Emitted 0, 1
// Second collector - shares upstream
shared.take(1).collect()
assertEquals(3, emissions) // Only emitted 2, not restarted
cancel()
}
```
## Best Practices
1. **Use runTest for all coroutine tests**
- Provides virtual time
- Automatic cleanup
2. **Use Turbine for Flow testing**
- Clearer assertions
- Better error messages
3. **Test both success and error paths**
- Normal flow
- Exception handling
- Edge cases
4. **Control virtual time explicitly**
- Don't rely on real delays
- Use `advanceTimeBy()` and `advanceUntilIdle()`
5. **Create fakes, not mocks**
- Simpler to maintain
- More realistic behavior
- Easier to debug
6. **Test cancellation behavior**
- Verify cleanup happens
- Check no emissions after cancel
7. **Test concurrent operations**
- Use `async` to spawn concurrent work
- Verify independence with SupervisorJob
+811
View File
@@ -0,0 +1,811 @@
---
name: kotlin-expert
description: Advanced Kotlin patterns for AmethystMultiplatform. Flow state management (StateFlow/SharedFlow), sealed hierarchies (classes vs interfaces), immutability (@Immutable, data classes), DSL builders (type-safe fluent APIs), inline functions (reified generics, performance). Use when working with: (1) State management patterns (StateFlow/SharedFlow/MutableStateFlow), (2) Sealed classes or sealed interfaces, (3) @Immutable annotations for Compose, (4) DSL builders with lambda receivers, (5) inline/reified functions, (6) Kotlin performance optimization. Complements kotlin-coroutines agent (async patterns) - this skill focuses on Amethyst-specific Kotlin idioms.
---
# Kotlin Expert
Advanced Kotlin patterns for AmethystMultiplatform. Covers Flow state management, sealed hierarchies, immutability, DSL builders, and inline functions with real codebase examples.
## Mental Model
**Kotlin in Amethyst:**
```
State Management (Hot Flows)
├── StateFlow<T> # Single value, always has value, replays to new subscribers
├── SharedFlow<T> # Event stream, configurable replay, multiple subscribers
└── MutableStateFlow<T> # Private mutable, public via .asStateFlow()
Type Safety (Sealed Hierarchies)
├── sealed class # State variants with data (AccountState.LoggedIn/LoggedOut)
└── sealed interface # Generic result types (SignerResult<T>)
Compose Performance (@Immutable)
├── @Immutable # 173+ event classes - prevents recomposition
└── data class # Structural equality, copy(), immutable by convention
DSL Patterns
├── Builder classes # Fluent APIs (TagArrayBuilder)
├── Lambda receivers # inline fun tagArray { ... }
└── Method chaining # return this
Performance
├── inline fun # Eliminate lambda overhead
├── reified type params # Runtime type info (OptimizedJsonMapper)
└── value class # Zero-cost wrappers (NOT USED yet in Amethyst)
```
**Delegation:**
- **kotlin-coroutines agent**: Deep async (structured concurrency, channels, operators)
- **kotlin-multiplatform skill**: expect/actual, source sets
- **This skill**: Amethyst Kotlin idioms, state patterns, type safety
---
## 1. Flow State Management
### StateFlow: State that Changes
**Mental model:** StateFlow is a "hot" observable state holder. Always has a value, new collectors immediately get current state.
**Amethyst pattern:**
```kotlin
// AccountManager.kt:48-50
class AccountManager {
private val _accountState = MutableStateFlow<AccountState>(AccountState.LoggedOut)
val accountState: StateFlow<AccountState> = _accountState.asStateFlow()
fun login(key: String) {
_accountState.value = AccountState.LoggedIn(...)
}
}
```
**Key principles:**
1. **Private mutable, public immutable**: `_accountState` (MutableStateFlow) private, `accountState` (StateFlow) public
2. **Always has value**: Initial value required (`LoggedOut`)
3. **Single value**: Replays ONE most recent value to new subscribers
4. **Hot**: Stays in memory, all collectors share same instance
**See:** AccountManager.kt:48-50, RelayConnectionManager.kt:49-52
### SharedFlow: Event Streams
**Mental model:** SharedFlow is a "hot" broadcast stream for events. Configurable replay buffer, doesn't require initial value.
**Amethyst pattern:**
```kotlin
// RelayConnectionManager.kt:52-53
val connectedRelays: StateFlow<Set<NormalizedRelayUrl>> = client.connectedRelaysFlow()
val availableRelays: StateFlow<Set<NormalizedRelayUrl>> = client.availableRelaysFlow()
```
**When to use StateFlow vs SharedFlow:**
| Scenario | Use StateFlow | Use SharedFlow |
|----------|---------------|----------------|
| **UI state** | ✅ Current screen data, login status | ❌ |
| **One-time events** | ❌ | ✅ Navigation, snackbars, toasts |
| **Always has value** | ✅ | ❌ Optional |
| **Replay count** | 1 (latest only) | Configurable (0, 1, n) |
| **Backpressure** | Conflates (drops old) | Configurable buffer |
**Best practice:**
```kotlin
// State: Use StateFlow
private val _uiState = MutableStateFlow(UiState.Loading)
val uiState: StateFlow<UiState> = _uiState.asStateFlow()
// Events: Use SharedFlow
private val _navigationEvents = MutableSharedFlow<NavEvent>(replay = 0)
val navigationEvents: SharedFlow<NavEvent> = _navigationEvents.asSharedFlow()
```
### Flow Anti-Patterns
**Exposing mutable state:**
```kotlin
val accountState: MutableStateFlow<AccountState> // BAD: Can be mutated externally
```
**Expose immutable:**
```kotlin
val accountState: StateFlow<AccountState> = _accountState.asStateFlow() // GOOD
```
---
**SharedFlow for state:**
```kotlin
val loginState = MutableSharedFlow<LoginState>() // BAD: State might get lost
```
**StateFlow for state:**
```kotlin
val loginState = MutableStateFlow(LoginState.LoggedOut) // GOOD: Always has value
```
**See:** `references/flow-patterns.md` for comprehensive examples.
---
## 2. Sealed Hierarchies
### Sealed Classes: State Variants
**Mental model:** Sealed classes represent a closed set of variants that share common data/behavior.
**Amethyst pattern:**
```kotlin
// AccountManager.kt:36-46
sealed class AccountState {
data object LoggedOut : AccountState()
data class LoggedIn(
val signer: NostrSigner,
val pubKeyHex: String,
val npub: String,
val nsec: String?,
val isReadOnly: Boolean
) : AccountState()
}
// Usage
when (state) {
is AccountState.LoggedOut -> showLogin()
is AccountState.LoggedIn -> showFeed(state.pubKeyHex)
} // Exhaustive - compiler enforces all cases
```
**Key principles:**
1. **Closed hierarchy**: All subclasses known at compile-time
2. **Exhaustive when**: Compiler ensures all cases handled
3. **Shared data**: Sealed class can hold common properties
4. **Single inheritance**: Subclass can't extend another class
**When to use:**
- Modeling UI states (Loading, Success, Error)
- Login states (LoggedOut, LoggedIn)
- Result types with different data per variant
### Sealed Interfaces: Generic Result Types
**Mental model:** Sealed interfaces for contracts with multiple implementations that need generics or multiple inheritance.
**Amethyst pattern:**
```kotlin
// SignerResult.kt:25-46
sealed interface SignerResult<T : IResult> {
sealed interface RequestAddressed<T : IResult> : SignerResult<T> {
class Successful<T : IResult>(val result: T) : RequestAddressed<T>
class Rejected<T : IResult> : RequestAddressed<T>
class TimedOut<T : IResult> : RequestAddressed<T>
class ReceivedButCouldNotPerform<T : IResult>(
val message: String?
) : RequestAddressed<T>
}
}
// Usage with generics
fun handleResult(result: SignerResult<SignResult>) {
when (result) {
is SignerResult.RequestAddressed.Successful -> processEvent(result.result.event)
is SignerResult.RequestAddressed.Rejected -> showRejected()
is SignerResult.RequestAddressed.TimedOut -> showTimeout()
}
}
```
**Key principles:**
1. **Multiple inheritance**: Subtype can implement other interfaces
2. **Variance**: Supports `out`/`in` modifiers for generics
3. **No constructor**: Can't hold state directly (subtypes can)
4. **Nested hierarchies**: Can create sub-sealed hierarchies
### Sealed Class vs Sealed Interface
| Feature | Sealed Class | Sealed Interface |
|---------|--------------|------------------|
| **Constructor** | ✅ Can hold common state | ❌ No constructor |
| **Inheritance** | ❌ Single parent only | ✅ Multiple interfaces |
| **Generics** | ❌ No variance | ✅ Covariance/contravariance |
| **Use case** | State variants | Result types, contracts |
**Decision tree:**
```
Need to hold common data in base?
YES → sealed class
NO → sealed interface
Need generics with variance (out/in)?
YES → sealed interface
NO → Either works
Subtypes need multiple inheritance?
YES → sealed interface
NO → Either works
```
**Amethyst examples:**
- `sealed class AccountState` - state variants with different data
- `sealed interface SignerResult<T>` - generic result types with variance
**See:** `references/sealed-class-catalog.md` for all sealed types in quartz.
---
## 3. Immutability & Compose Performance
### @Immutable Annotation
**Mental model:** @Immutable tells Compose "this value never changes after construction." Compose can skip recomposition if @Immutable object reference doesn't change.
**Amethyst pattern:**
```kotlin
// TextNoteEvent.kt:51-63
@Immutable
class TextNoteEvent(
id: HexKey,
pubKey: HexKey,
createdAt: Long,
tags: Array<Array<String>>,
content: String,
sig: HexKey
) : BaseThreadedEvent(id, pubKey, createdAt, KIND, tags, content, sig) {
// All properties immutable (val), no mutable state
}
```
**Key principles:**
1. **All properties immutable**: Only `val`, never `var`
2. **No mutable collections**: Use `ImmutableList`, `Array`, not `MutableList`
3. **Deep immutability**: Nested objects also immutable
4. **Compose optimization**: Skips recomposition if reference equals
**Why it matters:**
```kotlin
// Without @Immutable
@Composable
fun NoteCard(note: TextNoteEvent) { // Recomposes every time parent recomposes
Text(note.content)
}
// With @Immutable
@Composable
fun NoteCard(note: TextNoteEvent) { // Only recomposes if note reference changes
Text(note.content)
}
```
**173+ @Immutable classes** in quartz - all events immutable for Compose performance.
### Data Classes & Immutability
**Pattern:**
```kotlin
@Immutable
data class RelayStatus(
val url: NormalizedRelayUrl,
val connected: Boolean,
val error: String? = null
) {
// Implicit: equals(), hashCode(), copy(), toString()
}
// Usage
val oldStatus = RelayStatus(url, connected = false)
val newStatus = oldStatus.copy(connected = true) // Immutable update
```
**Key principles:**
1. **Structural equality**: `equals()` compares properties, not reference
2. **copy()**: Create modified copies without mutating
3. **All properties in constructor**: For proper `equals()`/`hashCode()`
4. **Prefer val**: Make properties immutable
### kotlinx.collections.immutable
**Pattern:**
```kotlin
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
// Instead of List (which could be mutable internally)
val relays: ImmutableList<String> = persistentListOf("wss://relay1.com", "wss://relay2.com")
// Add returns new instance
val updated = relays.add("wss://relay3.com") // relays unchanged, updated has 3 items
```
**When to use:**
- Compose state that needs collection
- Publicly exposed collections
- Shared state across threads
**See:** `references/immutability-patterns.md`
---
## 4. DSL Builders
### Type-Safe Fluent APIs
**Mental model:** DSL (Domain-Specific Language) builders use lambda receivers and method chaining to create readable, type-safe APIs.
**Amethyst pattern:**
```kotlin
// TagArrayBuilder.kt:23-90
class TagArrayBuilder<T : IEvent> {
private val tagList = mutableMapOf<String, MutableList<Tag>>()
fun add(tag: Array<String>): TagArrayBuilder<T> {
if (tag.isEmpty() || tag[0].isEmpty()) return this
tagList.getOrPut(tag[0], ::mutableListOf).add(tag)
return this // Method chaining
}
fun remove(tagName: String): TagArrayBuilder<T> {
tagList.remove(tagName)
return this // Method chaining
}
fun build() = tagList.flatMap { it.value }.toTypedArray()
}
// Inline function with lambda receiver (line 90)
inline fun <T : Event> tagArray(initializer: TagArrayBuilder<T>.() -> Unit = {}): TagArray =
TagArrayBuilder<T>().apply(initializer).build()
```
**Usage:**
```kotlin
val tags = tagArray<TextNoteEvent> {
add(arrayOf("e", eventId, relay, "reply"))
add(arrayOf("p", pubkey))
remove("a") // Remove address tags
}
```
**Key patterns:**
1. **Method chaining**: Return `this` from mutator methods
2. **Lambda receiver**: `TagArrayBuilder<T>.() -> Unit` - lambda has `this: TagArrayBuilder<T>`
3. **inline function**: Eliminates lambda overhead
4. **apply()**: Executes lambda with receiver, returns receiver
### DSL Pattern Template
```kotlin
class MyBuilder {
private val items = mutableListOf<Item>()
fun add(item: Item): MyBuilder {
items.add(item)
return this
}
fun build(): Result = Result(items.toList())
}
inline fun myDsl(init: MyBuilder.() -> Unit): Result =
MyBuilder().apply(init).build()
// Usage
val result = myDsl {
add(Item("foo"))
add(Item("bar"))
}
```
**Why inline?**
- Eliminates lambda object allocation
- Enables `reified` type parameters
- Better performance for frequently-called DSLs
**See:** `references/dsl-builder-examples.md` for more patterns.
---
## 5. Inline Functions & reified
### inline fun: Eliminate Overhead
**Mental model:** `inline` copies function body to call site. No lambda object created, direct code insertion.
**Pattern:**
```kotlin
// Without inline
fun <T> measureTime(block: () -> T): T {
val start = System.currentTimeMillis()
val result = block() // Lambda object allocated
println("Time: ${System.currentTimeMillis() - start}ms")
return result
}
// With inline
inline fun <T> measureTime(block: () -> T): T {
val start = System.currentTimeMillis()
val result = block() // No allocation, code inlined
println("Time: ${System.currentTimeMillis() - start}ms")
return result
}
```
**Benefits:**
1. **Zero overhead**: No lambda object allocation
2. **Non-local returns**: Can `return` from outer function inside lambda
3. **reified enabled**: Access to type parameter at runtime
### reified: Runtime Type Access
**Mental model:** `reified` makes generic type `T` available at runtime. Only works with `inline`.
**Amethyst pattern:**
```kotlin
// OptimizedJsonMapper.kt:48
expect object OptimizedJsonMapper {
inline fun <reified T : OptimizedSerializable> fromJsonTo(json: String): T
}
// Usage
val event: TextNoteEvent = OptimizedJsonMapper.fromJsonTo(jsonString)
// Compiler inlines and passes TextNoteEvent::class info
```
**Without reified:**
```kotlin
// Would need to pass class explicitly
fun <T> fromJson(json: String, clazz: KClass<T>): T {
return when (clazz) {
TextNoteEvent::class -> parseTextNote(json) as T
// ...
}
}
val event = fromJson(json, TextNoteEvent::class) // Verbose
```
**With reified:**
```kotlin
inline fun <reified T> fromJson(json: String): T {
return when (T::class) { // Can access T::class!
TextNoteEvent::class -> parseTextNote(json) as T
// ...
}
}
val event = fromJson<TextNoteEvent>(json) // Clean
```
### noinline & crossinline
**noinline**: Prevent specific lambda from being inlined
```kotlin
inline fun foo(
inlined: () -> Unit,
noinline notInlined: () -> Unit // Can be stored, passed around
) {
inlined()
someFunction(notInlined) // Can pass to non-inline function
}
```
**crossinline**: Lambda can't do non-local returns
```kotlin
inline fun foo(crossinline block: () -> Unit) {
launch {
block() // OK: crossinline allows lambda in different context
}
}
```
---
## 6. Value Classes (Opportunity)
**Mental model:** `value class` is a compile-time wrapper with zero runtime overhead. Single property, no boxing.
**Not currently used in Amethyst** - potential optimization.
**Pattern:**
```kotlin
@JvmInline
value class EventId(val hex: String)
@JvmInline
value class PubKey(val hex: String)
// Type safety without runtime cost
fun fetchEvent(eventId: EventId): Event {
// eventId.hex accessed without wrapper object
}
val id = EventId("abc123")
fetchEvent(id) // Type safe
// fetchEvent(PubKey("xyz")) // Compile error!
```
**When to use:**
- Type safety for primitives (IDs, hex strings, timestamps)
- High-frequency allocations (event processing)
- Clear domain types without overhead
**Restrictions:**
- Single property only
- Must be `val`
- Can't have `init` block with logic
- Inline at compile-time, may box in some cases
**Amethyst opportunity:**
```kotlin
// Current (String everywhere, no type safety)
fun fetchEvent(id: String): Event // Could pass wrong string
// With value class
@JvmInline value class EventId(val hex: String)
@JvmInline value class PubKeyHex(val hex: String)
@JvmInline value class Bech32(val encoded: String)
fun fetchEvent(id: EventId): Event // Type safe, zero cost
```
---
## Common Patterns
### Pattern: StateFlow State Management
```kotlin
class MyViewModel {
private val _state = MutableStateFlow(State.Initial)
val state: StateFlow<State> = _state.asStateFlow()
fun loadData() {
viewModelScope.launch {
_state.value = State.Loading
val result = repository.getData()
_state.value = when (result) {
is Success -> State.Success(result.data)
is Error -> State.Error(result.message)
}
}
}
}
sealed class State {
data object Initial : State()
data object Loading : State()
data class Success(val data: List<Item>) : State()
data class Error(val message: String) : State()
}
```
### Pattern: Sealed Result with Generics
```kotlin
sealed interface Result<out T> {
data class Success<T>(val value: T) : Result<T>
data class Error(val exception: Exception) : Result<Nothing>
data object Loading : Result<Nothing>
}
// Use with variance
fun <T> fetchData(): Result<T> = ...
val userResult: Result<User> = fetchData()
val itemResult: Result<List<Item>> = fetchData()
```
### Pattern: Immutable Event Builder
```kotlin
@Immutable
data class Event(
val id: String,
val kind: Int,
val content: String,
val tags: ImmutableList<Tag>
) {
companion object {
fun builder() = EventBuilder()
}
}
class EventBuilder {
private var id: String = ""
private var kind: Int = 1
private var content: String = ""
private val tags = mutableListOf<Tag>()
fun id(value: String) = apply { id = value }
fun kind(value: Int) = apply { kind = value }
fun content(value: String) = apply { content = value }
fun tag(tag: Tag) = apply { tags.add(tag) }
fun build() = Event(id, kind, content, tags.toImmutableList())
}
// Usage
val event = Event.builder()
.id("abc")
.kind(1)
.content("Hello")
.tag(Tag.P("pubkey"))
.build()
```
---
## Delegation Guide
**When to delegate:**
| Topic | Delegate To | This Skill Covers |
|-------|-------------|-------------------|
| Structured concurrency, channels | kotlin-coroutines agent | Flow state patterns only |
| expect/actual, source sets | kotlin-multiplatform skill | Platform-agnostic Kotlin |
| General Compose patterns | compose-expert skill | @Immutable for performance |
| Build configuration | gradle-expert skill | - |
**Ask kotlin-coroutines agent for:**
- Advanced Flow operators (flatMapLatest, combine, zip)
- Channel patterns
- Structured concurrency (supervisorScope, coroutineScope)
- Error handling in coroutines
**This skill teaches:**
- StateFlow/SharedFlow state management
- Sealed hierarchies
- @Immutable for Compose
- DSL builders
- Inline/reified patterns
---
## Anti-Patterns
**Mutable public state:**
```kotlin
val accountState: MutableStateFlow<AccountState> // BAD
```
**Immutable public interface:**
```kotlin
val accountState: StateFlow<AccountState> = _accountState.asStateFlow()
```
---
**Sealed class for generic results:**
```kotlin
sealed class Result<T> { // BAD: Can't use variance
data class Success<T>(val value: T) : Result<T>()
}
```
**Sealed interface for generics:**
```kotlin
sealed interface Result<out T> { // GOOD: Covariance
data class Success<T>(val value: T) : Result<T>
}
```
---
**Mutable properties in @Immutable class:**
```kotlin
@Immutable
data class Event(
var content: String // BAD: var breaks immutability
)
```
**All val:**
```kotlin
@Immutable
data class Event(
val content: String
)
```
---
**Passing class explicitly when reified available:**
```kotlin
inline fun <T> parse(json: String, clazz: KClass<T>): T // BAD
```
**Use reified:**
```kotlin
inline fun <reified T> parse(json: String): T // GOOD
```
---
## Quick Reference
### Flow Decision Tree
```
Need to expose state?
YES → StateFlow (always has value, single latest)
NO → Need events? → SharedFlow (optional replay, broadcast)
Need to mutate?
Internal only → MutableStateFlow (private)
Expose publicly → StateFlow via .asStateFlow()
```
### Sealed Decision Tree
```
Need common data in base type?
YES → sealed class
NO → sealed interface
Need generics with variance?
YES → sealed interface
NO → Either works
Need multiple inheritance?
YES → sealed interface
NO → Either works
```
### Inline Decision Tree
```
Passing lambda to function?
Called frequently? → inline (performance)
Need reified? → inline (required)
Need to store/pass lambda? → regular fun (can't inline)
```
---
## Resources
### Official Docs
- [StateFlow and SharedFlow | Android Developers](https://developer.android.com/kotlin/flow/stateflow-and-sharedflow)
- [Sealed Classes | Kotlin Docs](https://kotlinlang.org/docs/sealed-classes.html)
- [Inline Functions | Kotlin Docs](https://kotlinlang.org/docs/inline-functions.html)
### Bundled References
- `references/flow-patterns.md` - StateFlow/SharedFlow examples from AccountManager, RelayManager
- `references/sealed-class-catalog.md` - All sealed types in quartz
- `references/dsl-builder-examples.md` - TagArrayBuilder, other DSL patterns
- `references/immutability-patterns.md` - @Immutable usage, data classes, collections
### Codebase Examples
- AccountManager.kt:36-50 - sealed class AccountState, StateFlow pattern
- RelayConnectionManager.kt:44-52 - StateFlow state management
- SignerResult.kt:25-46 - sealed interface with generics
- TextNoteEvent.kt:51-63 - @Immutable event class
- TagArrayBuilder.kt:23-90 - DSL builder pattern, inline function
- OptimizedJsonMapper.kt:48 - inline fun with reified
---
**Version:** 1.0.0
**Last Updated:** 2025-12-30
**Codebase Reference:** AmethystMultiplatform commit 258c4e011
@@ -0,0 +1,602 @@
# DSL Builder Examples
Type-safe fluent APIs and DSL patterns from the codebase.
## Table of Contents
- [TagArrayBuilder Pattern](#tagarraybuilder-pattern)
- [Builder Variations](#builder-variations)
- [DSL Principles](#dsl-principles)
- [Creating Custom DSLs](#creating-custom-dsls)
---
## TagArrayBuilder Pattern
### Core Implementation
**File:** `quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/core/TagArrayBuilder.kt:23-91`
```kotlin
class TagArrayBuilder<T : IEvent> {
private val tagList = mutableMapOf<String, MutableList<Tag>>()
fun remove(tagName: String): TagArrayBuilder<T> {
tagList.remove(tagName)
return this // Method chaining
}
fun remove(tagName: String, tagValue: String): TagArrayBuilder<T> {
tagList[tagName]?.removeAll { it.valueOrNull() == tagValue }
if (tagList[tagName]?.isEmpty() == true) {
tagList.remove(tagName)
}
return this
}
fun removeIf(
predicate: (Tag, Tag) -> Boolean,
toCompare: Tag
): TagArrayBuilder<T> {
val tagName = toCompare.nameOrNull() ?: return this
tagList[tagName]?.removeAll { predicate(it, toCompare) }
if (tagList[tagName]?.isEmpty() == true) {
tagList.remove(tagName)
}
return this
}
fun add(tag: Array<String>): TagArrayBuilder<T> {
if (tag.isEmpty() || tag[0].isEmpty()) return this
tagList.getOrPut(tag[0], ::mutableListOf).add(tag)
return this
}
fun addFirst(tag: Array<String>): TagArrayBuilder<T> {
if (tag.isEmpty() || tag[0].isEmpty()) return this
tagList.getOrPut(tag[0], ::mutableListOf).add(0, tag)
return this
}
fun addUnique(tag: Array<String>): TagArrayBuilder<T> {
if (tag.isEmpty() || tag[0].isEmpty()) return this
tagList[tag[0]] = mutableListOf(tag) // Replace existing
return this
}
fun addAll(tag: List<Array<String>>): TagArrayBuilder<T> {
tag.forEach(::add)
return this
}
fun toTypedArray() = tagList.flatMap { it.value }.toTypedArray()
fun build() = toTypedArray()
}
// Inline DSL function with lambda receiver
inline fun <T : Event> tagArray(
initializer: TagArrayBuilder<T>.() -> Unit = {}
): TagArray = TagArrayBuilder<T>().apply(initializer).build()
```
### Usage Examples
**Basic usage:**
```kotlin
val tags = tagArray<TextNoteEvent> {
add(arrayOf("e", eventId, relay, "reply"))
add(arrayOf("p", pubkey))
add(arrayOf("t", "bitcoin"))
}
```
**Advanced patterns:**
```kotlin
// Remove and add
val tags = tagArray<TextNoteEvent> {
addAll(existingTags)
remove("a") // Remove all address tags
addUnique(arrayOf("client", "Amethyst")) // Replace client tag
}
// Conditional building
val tags = tagArray<TextNoteEvent> {
add(arrayOf("e", rootId, "", "root"))
if (replyToId != null) {
add(arrayOf("e", replyToId, "", "reply"))
}
mentionedPubkeys.forEach { pubkey ->
add(arrayOf("p", pubkey))
}
hashtags.forEach { tag ->
add(arrayOf("t", tag.lowercase()))
}
}
// Custom predicate removal
val tags = tagArray<TextNoteEvent> {
addAll(originalTags)
removeIf(
predicate = { tag, compare -> tag[1] == compare[1] },
toCompare = arrayOf("e", eventIdToRemove)
)
}
```
---
## Builder Variations
### PrivateTagArrayBuilder
**File:** `quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/PrivateTagArrayBuilder.kt`
```kotlin
class PrivateTagArrayBuilder {
private val builder = TagArrayBuilder<Event>()
fun add(tag: PrivateTag): PrivateTagArrayBuilder {
builder.add(tag.toArray())
return this
}
fun addAll(tags: List<PrivateTag>): PrivateTagArrayBuilder {
tags.forEach { add(it) }
return this
}
fun build(): Array<Array<String>> = builder.build()
}
// DSL function
inline fun privateTagArray(
initializer: PrivateTagArrayBuilder.() -> Unit
): Array<Array<String>> = PrivateTagArrayBuilder().apply(initializer).build()
```
**Usage:**
```kotlin
val privateTags = privateTagArray {
add(PrivateTag.Event(eventId, marker = "bookmark"))
add(PrivateTag.Profile(pubkey))
addAll(existingPrivateTags)
}
```
### TlvBuilder
**File:** `quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip19Bech32/tlv/TlvBuilder.kt`
```kotlin
class TlvBuilder {
private val entries = mutableListOf<TlvEntry>()
fun add(type: TlvType, value: ByteArray): TlvBuilder {
entries.add(TlvEntry(type, value))
return this
}
fun addRelay(relay: String): TlvBuilder {
add(TlvType.Relay, relay.encodeToByteArray())
return this
}
fun addAuthor(pubkey: ByteArray): TlvBuilder {
add(TlvType.Author, pubkey)
return this
}
fun addKind(kind: Int): TlvBuilder {
add(TlvType.Kind, kind.toByteArray())
return this
}
fun build(): ByteArray {
return entries.flatMap { it.encode() }.toByteArray()
}
}
fun tlv(init: TlvBuilder.() -> Unit): ByteArray =
TlvBuilder().apply(init).build()
```
**Usage:**
```kotlin
val tlvData = tlv {
addAuthor(pubkeyBytes)
addRelay("wss://relay.damus.io")
addKind(1)
}
```
### MapOfSetBuilder
**File:** `quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/MapOfSetBuilder.kt`
```kotlin
class MapOfSetBuilder<K, V> {
private val map = mutableMapOf<K, MutableSet<V>>()
fun add(key: K, value: V): MapOfSetBuilder<K, V> {
map.getOrPut(key) { mutableSetOf() }.add(value)
return this
}
fun addAll(key: K, values: Collection<V>): MapOfSetBuilder<K, V> {
map.getOrPut(key) { mutableSetOf() }.addAll(values)
return this
}
fun remove(key: K, value: V): MapOfSetBuilder<K, V> {
map[key]?.remove(value)
if (map[key]?.isEmpty() == true) {
map.remove(key)
}
return this
}
fun build(): Map<K, Set<V>> = map.mapValues { it.value.toSet() }
}
inline fun <K, V> mapOfSets(
init: MapOfSetBuilder<K, V>.() -> Unit
): Map<K, Set<V>> = MapOfSetBuilder<K, V>().apply(init).build()
```
**Usage:**
```kotlin
val relayMap = mapOfSets<String, EventId> {
add("wss://relay1.com", eventId1)
add("wss://relay1.com", eventId2)
add("wss://relay2.com", eventId3)
}
// Result: {"wss://relay1.com": [eventId1, eventId2], "wss://relay2.com": [eventId3]}
```
---
## DSL Principles
### 1. Lambda with Receiver
**Mental model:** Lambda receiver makes `this` refer to builder instance inside lambda.
```kotlin
// Without receiver
fun buildTags(config: (TagArrayBuilder<Event>) -> Unit) {
val builder = TagArrayBuilder<Event>()
config(builder) // Must pass builder explicitly
builder.build()
}
buildTags { builder ->
builder.add(...) // Verbose
}
// With receiver
inline fun buildTags(config: TagArrayBuilder<Event>.() -> Unit) {
TagArrayBuilder<Event>().apply(config).build()
}
buildTags {
add(...) // Clean - 'this' is builder
}
```
### 2. Method Chaining
**Pattern:** Return `this` from mutator methods.
```kotlin
class Builder {
private var value: String = ""
fun setValue(v: String): Builder {
value = v
return this // Enable chaining
}
fun append(s: String): Builder {
value += s
return this
}
fun build(): String = value
}
// Usage
val result = Builder()
.setValue("Hello")
.append(" ")
.append("World")
.build()
```
### 3. Inline for Performance
**Why inline:**
- Eliminates lambda allocation
- Allows `reified` type parameters
- Better for hot paths (frequently called)
```kotlin
// NOT inline - lambda object created each call
fun <T> myDsl(init: Builder<T>.() -> Unit): Result<T> {
return Builder<T>().apply(init).build()
}
// Inline - lambda code inlined at call site
inline fun <T> myDsl(init: Builder<T>.() -> Unit): Result<T> {
return Builder<T>().apply(init).build()
}
```
### 4. Type Safety
**Use generics for compile-time safety:**
```kotlin
// Type-safe builder
class EventBuilder<T : Event> {
fun addTag(tag: Tag<T>): EventBuilder<T> { // Only accepts tags for this event type
tags.add(tag)
return this
}
}
// Usage
val textNote = EventBuilder<TextNoteEvent>()
.addTag(TextNoteTag.Subject("Hello")) // OK
// .addTag(ChannelTag.Name("test")) // Compile error!
.build()
```
---
## Creating Custom DSLs
### Pattern: Simple Builder DSL
```kotlin
class QueryBuilder {
private val filters = mutableListOf<String>()
private var limit: Int? = null
private var offset: Int? = null
fun filter(field: String, value: String): QueryBuilder {
filters.add("$field:$value")
return this
}
fun limit(n: Int): QueryBuilder {
limit = n
return this
}
fun offset(n: Int): QueryBuilder {
offset = n
return this
}
fun build(): String {
val parts = mutableListOf<String>()
if (filters.isNotEmpty()) {
parts.add(filters.joinToString(" AND "))
}
if (limit != null) {
parts.add("LIMIT $limit")
}
if (offset != null) {
parts.add("OFFSET $offset")
}
return parts.joinToString(" ")
}
}
inline fun query(init: QueryBuilder.() -> Unit): String =
QueryBuilder().apply(init).build()
// Usage
val sql = query {
filter("status", "active")
filter("age", ">18")
limit(10)
offset(20)
}
// Result: "status:active AND age:>18 LIMIT 10 OFFSET 20"
```
### Pattern: Nested Builders
```kotlin
class FilterBuilder {
private val conditions = mutableListOf<String>()
fun equals(field: String, value: String) {
conditions.add("$field = '$value'")
}
fun greaterThan(field: String, value: Int) {
conditions.add("$field > $value")
}
fun build(): String = conditions.joinToString(" AND ")
}
class QueryBuilder {
private var filterClause: String = ""
private var selectClause: String = "*"
fun select(vararg fields: String): QueryBuilder {
selectClause = fields.joinToString(", ")
return this
}
fun where(init: FilterBuilder.() -> Unit): QueryBuilder {
filterClause = FilterBuilder().apply(init).build()
return this
}
fun build(): String {
return "SELECT $selectClause WHERE $filterClause"
}
}
inline fun query(init: QueryBuilder.() -> Unit): String =
QueryBuilder().apply(init).build()
// Usage
val sql = query {
select("id", "name", "age")
where {
equals("status", "active")
greaterThan("age", 18)
}
}
// Result: "SELECT id, name, age WHERE status = 'active' AND age > 18"
```
### Pattern: Type-Safe HTML DSL
```kotlin
abstract class Tag(val name: String) {
private val children = mutableListOf<Tag>()
private val attributes = mutableMapOf<String, String>()
fun <T : Tag> tag(tag: T, init: T.() -> Unit): T {
tag.init()
children.add(tag)
return tag
}
fun attr(name: String, value: String) {
attributes[name] = value
}
fun render(builder: StringBuilder, indent: String) {
builder.append("$indent<$name")
attributes.forEach { (k, v) -> builder.append(" $k=\"$v\"") }
if (children.isEmpty()) {
builder.append("/>\n")
} else {
builder.append(">\n")
children.forEach { it.render(builder, "$indent ") }
builder.append("$indent</$name>\n")
}
}
}
class HTML : Tag("html")
class Head : Tag("head")
class Body : Tag("body")
class Div : Tag("div")
class P : Tag("p")
class A : Tag("a")
fun HTML.head(init: Head.() -> Unit) = tag(Head(), init)
fun HTML.body(init: Body.() -> Unit) = tag(Body(), init)
fun Body.div(init: Div.() -> Unit) = tag(Div(), init)
fun Div.p(init: P.() -> Unit) = tag(P(), init)
fun Div.a(init: A.() -> Unit) = tag(A(), init)
fun html(init: HTML.() -> Unit): HTML = HTML().apply(init)
// Usage
val page = html {
head {
// ...
}
body {
div {
attr("class", "container")
p {
attr("id", "intro")
}
a {
attr("href", "https://example.com")
}
}
}
}
```
---
## Best Practices
### ✅ DO
1. **Return `this` for chaining:**
```kotlin
fun add(item: Item): Builder {
items.add(item)
return this
}
```
2. **Use `inline` for DSL functions:**
```kotlin
inline fun myDsl(init: Builder.() -> Unit) = Builder().apply(init).build()
```
3. **Provide sensible defaults:**
```kotlin
inline fun query(
init: QueryBuilder.() -> Unit = {} // Empty lambda as default
) = QueryBuilder().apply(init).build()
```
4. **Validate in `build()`:**
```kotlin
fun build(): Result {
require(fields.isNotEmpty()) { "Must specify at least one field" }
return Result(fields)
}
```
### ❌ DON'T
1. **Forget to return `this`:**
```kotlin
fun add(item: Item) { // BAD: Can't chain
items.add(item)
}
```
2. **Mutate after build:**
```kotlin
val builder = Builder()
builder.add("foo")
val result = builder.build()
builder.add("bar") // BAD: Confusing state
```
3. **Expose mutable state:**
```kotlin
class Builder {
val items = mutableListOf<Item>() // BAD: Can be mutated externally
}
```
4. **Make DSL functions non-inline unnecessarily:**
```kotlin
fun myDsl(init: Builder.() -> Unit) = ... // BAD: Lambda allocation overhead
```
---
## References
- TagArrayBuilder.kt:23-91
- PrivateTagArrayBuilder.kt
- TlvBuilder.kt
- [Type-Safe Builders | Kotlin Docs](https://kotlinlang.org/docs/type-safe-builders.html)
- [DSLs with Kotlin](https://kt.academy/article/dsl-intro)
@@ -0,0 +1,405 @@
# Flow Patterns in Amethyst
StateFlow and SharedFlow usage patterns from the codebase.
## Table of Contents
- [StateFlow for State Management](#stateflow-for-state-management)
- [Flow Composition](#flow-composition)
- [Common Patterns](#common-patterns)
- [Anti-Patterns](#anti-patterns)
---
## StateFlow for State Management
### AccountManager Pattern
**File:** `commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/account/AccountManager.kt:36-115`
```kotlin
sealed class AccountState {
data object LoggedOut : AccountState()
data class LoggedIn(
val signer: NostrSigner,
val pubKeyHex: String,
val npub: String,
val nsec: String?,
val isReadOnly: Boolean,
) : AccountState()
}
class AccountManager {
private val _accountState = MutableStateFlow<AccountState>(AccountState.LoggedOut)
val accountState: StateFlow<AccountState> = _accountState.asStateFlow()
fun generateNewAccount(): AccountState.LoggedIn {
val keyPair = KeyPair()
val signer = NostrSignerInternal(keyPair)
val state = AccountState.LoggedIn(
signer = signer,
pubKeyHex = keyPair.pubKey.toHexKey(),
npub = keyPair.pubKey.toNpub(),
nsec = keyPair.privKey?.toNsec(),
isReadOnly = false
)
_accountState.value = state // Update state
return state
}
fun loginWithKey(keyInput: String): Result<AccountState.LoggedIn> {
// ... validation ...
val state = AccountState.LoggedIn(...)
_accountState.value = state
return Result.success(state)
}
fun logout() {
_accountState.value = AccountState.LoggedOut
}
}
```
**Pattern highlights:**
- Private `MutableStateFlow` for internal mutations
- Public `StateFlow` via `.asStateFlow()` for read-only access
- Sealed class for type-safe state variants
- Initial value required (`AccountState.LoggedOut`)
### RelayConnectionManager Pattern
**File:** `commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/network/RelayConnectionManager.kt:44-80`
```kotlin
data class RelayStatus(
val url: NormalizedRelayUrl,
val connected: Boolean,
val error: String? = null,
val messageCount: Int = 0
)
open class RelayConnectionManager(
websocketBuilder: WebsocketBuilder
) : IRelayClientListener {
private val client = NostrClient(websocketBuilder)
// Map of relay URLs to their status
private val _relayStatuses = MutableStateFlow<Map<NormalizedRelayUrl, RelayStatus>>(emptyMap())
val relayStatuses: StateFlow<Map<NormalizedRelayUrl, RelayStatus>> = _relayStatuses.asStateFlow()
// Delegated StateFlows from client
val connectedRelays: StateFlow<Set<NormalizedRelayUrl>> = client.connectedRelaysFlow()
val availableRelays: StateFlow<Set<NormalizedRelayUrl>> = client.availableRelaysFlow()
fun addRelay(url: String): NormalizedRelayUrl? {
val normalized = RelayUrlNormalizer.normalizeOrNull(url) ?: return null
updateRelayStatus(normalized) { it.copy(connected = false, error = null) }
return normalized
}
fun removeRelay(url: NormalizedRelayUrl) {
_relayStatuses.value = _relayStatuses.value - url // Immutable update (remove from map)
}
private fun updateRelayStatus(
relay: NormalizedRelayUrl,
update: (RelayStatus) -> RelayStatus
) {
_relayStatuses.value = _relayStatuses.value.toMutableMap().apply {
val current = get(relay) ?: RelayStatus(relay, false)
put(relay, update(current))
}
}
// IRelayClientListener implementation
override fun onConnect(relay: NormalizedRelayUrl) {
updateRelayStatus(relay) { it.copy(connected = true, error = null) }
}
override fun onError(relay: NormalizedRelayUrl, error: String) {
updateRelayStatus(relay) { it.copy(connected = false, error = error) }
}
}
```
**Pattern highlights:**
- `Map` as state value for collection tracking
- Immutable map updates (copy with modifications)
- Helper function `updateRelayStatus` for consistent updates
- Delegation pattern (client exposes its own StateFlows)
---
## Flow Composition
### Multiple StateFlows in UI
**Pattern:**
```kotlin
@Composable
fun LoginScreen(accountManager: AccountManager) {
val accountState by accountManager.accountState.collectAsState()
when (accountState) {
is AccountState.LoggedOut -> {
LoginForm(onLogin = { key -> accountManager.loginWithKey(key) })
}
is AccountState.LoggedIn -> {
MainApp(account = accountState as AccountState.LoggedIn)
}
}
}
```
### Observing Multiple Flows
**Pattern:**
```kotlin
@Composable
fun RelayStatusCard(relayManager: RelayConnectionManager) {
val relayStatuses by relayManager.relayStatuses.collectAsState()
val connectedRelays by relayManager.connectedRelays.collectAsState()
Column {
Text("${connectedRelays.size} of ${relayStatuses.size} relays connected")
relayStatuses.forEach { (url, status) ->
RelayRow(
url = url,
connected = status.connected,
error = status.error
)
}
}
}
```
---
## Common Patterns
### Pattern: Immutable State Updates
```kotlin
// Map updates
_relayStatuses.value = _relayStatuses.value + (url to newStatus) // Add
_relayStatuses.value = _relayStatuses.value - url // Remove
_relayStatuses.value = _relayStatuses.value.mapValues { (key, value) ->
if (key == targetUrl) value.copy(connected = true) else value
}
// List updates
_items.value = _items.value + newItem // Append
_items.value = _items.value.filter { it.id != removedId } // Remove
_items.value = _items.value.map { if (it.id == id) it.copy(name = newName) else it } // Update
// Object updates
_user.value = _user.value.copy(name = newName)
```
### Pattern: Conditional State Transitions
```kotlin
fun attemptLogin(credentials: Credentials) {
if (_loginState.value is LoginState.LoggingIn) {
return // Already logging in, ignore
}
_loginState.value = LoginState.LoggingIn
viewModelScope.launch {
try {
val user = repository.login(credentials)
_loginState.value = LoginState.Success(user)
} catch (e: Exception) {
_loginState.value = LoginState.Error(e.message ?: "Login failed")
}
}
}
```
### Pattern: Derived State
```kotlin
class MyViewModel {
private val _items = MutableStateFlow<List<Item>>(emptyList())
val items: StateFlow<List<Item>> = _items.asStateFlow()
// Derived state (computed from items)
val itemCount: StateFlow<Int> = items.map { it.size }
.stateIn(viewModelScope, SharingStarted.Lazily, 0)
val hasItems: StateFlow<Boolean> = items.map { it.isNotEmpty() }
.stateIn(viewModelScope, SharingStarted.Lazily, false)
}
// Usage in Compose
@Composable
fun ItemList(viewModel: MyViewModel) {
val itemCount by viewModel.itemCount.collectAsState()
val hasItems by viewModel.hasItems.collectAsState()
if (hasItems) {
Text("$itemCount items")
} else {
Text("No items")
}
}
```
### Pattern: State with Loading/Error
```kotlin
sealed class UiState<out T> {
data object Loading : UiState<Nothing>()
data class Success<T>(val data: T) : UiState<T>()
data class Error(val message: String) : UiState<Nothing>()
}
class FeedViewModel {
private val _feedState = MutableStateFlow<UiState<List<Event>>>(UiState.Loading)
val feedState: StateFlow<UiState<List<Event>>> = _feedState.asStateFlow()
fun loadFeed() {
viewModelScope.launch {
_feedState.value = UiState.Loading
try {
val events = repository.getEvents()
_feedState.value = UiState.Success(events)
} catch (e: Exception) {
_feedState.value = UiState.Error(e.message ?: "Unknown error")
}
}
}
}
// UI
@Composable
fun FeedScreen(viewModel: FeedViewModel) {
val state by viewModel.feedState.collectAsState()
when (state) {
is UiState.Loading -> LoadingSpinner()
is UiState.Success -> EventList((state as UiState.Success).data)
is UiState.Error -> ErrorMessage((state as UiState.Error).message)
}
}
```
---
## Anti-Patterns
### ❌ Exposing Mutable State
```kotlin
// BAD: External code can mutate
class BadViewModel {
val state: MutableStateFlow<State> = MutableStateFlow(State.Initial)
}
// Caller can do:
viewModel.state.value = State.Hacked // Bypass internal logic!
```
### ✅ Expose Immutable
```kotlin
// GOOD: Only ViewModel can mutate
class GoodViewModel {
private val _state = MutableStateFlow(State.Initial)
val state: StateFlow<State> = _state.asStateFlow()
fun updateState(newState: State) {
// Controlled mutation with validation
_state.value = newState
}
}
```
---
### ❌ Not Using Immutable Updates
```kotlin
// BAD: Mutating collection doesn't trigger StateFlow update
val list = mutableListOf<Item>()
list.add(newItem)
_items.value = list // Same reference, no update emitted!
```
### ✅ Create New Instance
```kotlin
// GOOD: New list instance
_items.value = _items.value + newItem // New list created, update emitted
```
---
### ❌ StateFlow for Events
```kotlin
// BAD: Events get lost if no collector
class BadViewModel {
val navigationEvent: StateFlow<NavEvent?> = MutableStateFlow(null)
fun navigate(event: NavEvent) {
_navigationEvent.value = event // Lost if UI not observing!
}
}
```
### ✅ SharedFlow for Events
```kotlin
// GOOD: Events queued
class GoodViewModel {
private val _navigationEvent = MutableSharedFlow<NavEvent>(replay = 0)
val navigationEvent: SharedFlow<NavEvent> = _navigationEvent.asSharedFlow()
fun navigate(event: NavEvent) {
viewModelScope.launch {
_navigationEvent.emit(event) // Queued for collector
}
}
}
```
---
### ❌ Blocking Operations in State Update
```kotlin
// BAD: Blocking main thread
fun loadData() {
_state.value = fetchDataFromNetwork() // Blocks!
}
```
### ✅ Async Updates
```kotlin
// GOOD: Use coroutines
fun loadData() {
viewModelScope.launch {
_state.value = UiState.Loading
val data = withContext(Dispatchers.IO) {
fetchDataFromNetwork()
}
_state.value = UiState.Success(data)
}
}
```
---
## References
- AccountManager.kt:36-115
- RelayConnectionManager.kt:44-80
- [StateFlow and SharedFlow | Android Developers](https://developer.android.com/kotlin/flow/stateflow-and-sharedflow)
- [Hot vs Cold Flows](https://carrion.dev/en/posts/kotlin-flows-hot-cold/)
@@ -0,0 +1,641 @@
# Immutability Patterns
@Immutable annotation, data classes, and immutable collections for Compose performance.
## Table of Contents
- [Why Immutability Matters](#why-immutability-matters)
- [@Immutable Annotation](#immutable-annotation)
- [Data Classes](#data-classes)
- [Immutable Collections](#immutable-collections)
- [Common Patterns](#common-patterns)
- [Performance Impact](#performance-impact)
---
## Why Immutability Matters
### Compose Recomposition
**Mental model:** Compose tracks state changes by comparing references. If an `@Immutable` object reference doesn't change, Compose skips recomposition.
```kotlin
// Without @Immutable - Recomposes on every parent recomposition
data class User(val name: String, val age: Int)
@Composable
fun UserCard(user: User) { // Recomposes unnecessarily
Text(user.name)
}
// With @Immutable - Only recomposes when user reference changes
@Immutable
data class User(val name: String, val age: Int)
@Composable
fun UserCard(user: User) { // Smart recomposition
Text(user.name)
}
```
**Performance difference:**
- Without `@Immutable`: 1000 `UserCard` recompositions per screen update
- With `@Immutable`: 10 `UserCard` recompositions (only changed users)
### Thread Safety
Immutable objects are inherently thread-safe:
```kotlin
@Immutable
data class Event(
val id: String,
val content: String,
val createdAt: Long
)
// Safe to share across coroutines without synchronization
val sharedEvent: Event = fetchEvent()
launch { processEvent(sharedEvent) } // Safe
launch { saveEvent(sharedEvent) } // Safe
```
---
## @Immutable Annotation
### Basic Usage
**Pattern from Amethyst:**
```kotlin
// TextNoteEvent.kt:51-63
@Immutable
class TextNoteEvent(
id: HexKey,
pubKey: HexKey,
createdAt: Long,
tags: Array<Array<String>>,
content: String,
sig: HexKey
) : BaseThreadedEvent(id, pubKey, createdAt, KIND, tags, content, sig) {
// All properties are val (immutable)
// No var properties
// No mutable collections
}
```
**Requirements for @Immutable:**
1. All properties must be `val` (no `var`)
2. All property types must be immutable or primitives
3. No mutable collections (`MutableList`, `MutableMap`)
4. Arrays are allowed (treated as immutable by contract)
5. No public mutable state
### @Immutable vs @Stable
**@Immutable:** Value never changes after construction
```kotlin
@Immutable
data class User(val name: String, val age: Int)
// Once created, user.name and user.age never change
```
**@Stable:** Value can change, but changes are tracked
```kotlin
@Stable
class MutableCounter {
var count by mutableStateOf(0) // Changes tracked by Compose
}
```
**Amethyst uses @Immutable extensively:**
- 173+ event classes annotated with `@Immutable`
- All Nostr events immutable by design
- Critical for feed performance (thousands of events)
---
## Data Classes
### Immutable Data Classes
**Pattern:**
```kotlin
@Immutable
data class RelayStatus(
val url: NormalizedRelayUrl,
val connected: Boolean,
val error: String? = null,
val messageCount: Int = 0
) {
// Immutable properties only (val)
// Default values allowed
}
```
**Benefits:**
1. **Structural equality:** `equals()` compares values, not references
2. **copy():** Create modified copies without mutation
3. **toString():** Debugging-friendly output
4. **hashCode():** Consistent hashing for collections
5. **componentN():** Destructuring support
### copy() for Updates
**Mental model:** Instead of mutating, create modified copies.
```kotlin
val status = RelayStatus(
url = "wss://relay.damus.io",
connected = false,
error = null
)
// Immutable update
val updatedStatus = status.copy(connected = true)
// Original unchanged
assert(status.connected == false)
assert(updatedStatus.connected == true)
```
**StateFlow pattern:**
```kotlin
private val _relayStatuses = MutableStateFlow<Map<String, RelayStatus>>(emptyMap())
fun updateRelay(url: String, connected: Boolean) {
_relayStatuses.value = _relayStatuses.value.mapValues { (key, status) ->
if (key == url) {
status.copy(connected = connected) // Immutable update
} else {
status
}
}
}
```
### All Properties in Constructor
**Why important for data classes:**
```kotlin
// BAD: Properties outside constructor not included in equals/hashCode
data class User(val name: String) {
var age: Int = 0 // NOT in equals/hashCode/copy!
}
val user1 = User("Alice")
val user2 = User("Alice")
user1.age = 25
user2.age = 30
assert(user1 == user2) // TRUE! age not compared
assert(user1.copy() == user1) // TRUE! age not copied
// GOOD: All properties in constructor
@Immutable
data class User(
val name: String,
val age: Int // Included in equals/hashCode/copy
)
```
---
## Immutable Collections
### kotlinx.collections.immutable
**Installation:**
```kotlin
// build.gradle.kts
dependencies {
implementation("org.jetbrains.kotlinx:kotlinx-collections-immutable:0.3.7")
}
```
**Why use:**
- Structural sharing (efficient copies)
- Explicit immutability (compiler enforced)
- Safe for Compose state
### ImmutableList
```kotlin
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
// Create immutable list
val relays: ImmutableList<String> = persistentListOf(
"wss://relay1.com",
"wss://relay2.com"
)
// Add returns NEW list
val updated = relays.add("wss://relay3.com")
assert(relays.size == 2) // Original unchanged
assert(updated.size == 3) // New list has 3 items
// Convert from regular list
val mutableList = mutableListOf("a", "b", "c")
val immutable = mutableList.toImmutableList()
```
### ImmutableMap
```kotlin
import kotlinx.collections.immutable.ImmutableMap
import kotlinx.collections.immutable.persistentMapOf
import kotlinx.collections.immutable.toImmutableMap
// Create immutable map
val relayStatuses: ImmutableMap<String, RelayStatus> = persistentMapOf(
"wss://relay1.com" to RelayStatus(...),
"wss://relay2.com" to RelayStatus(...)
)
// Put returns NEW map
val updated = relayStatuses.put("wss://relay3.com", RelayStatus(...))
// Remove returns NEW map
val removed = relayStatuses.remove("wss://relay1.com")
```
### ImmutableSet
```kotlin
import kotlinx.collections.immutable.ImmutableSet
import kotlinx.collections.immutable.persistentSetOf
val connectedRelays: ImmutableSet<String> = persistentSetOf(
"wss://relay1.com",
"wss://relay2.com"
)
val updated = connectedRelays.add("wss://relay3.com")
```
### Structural Sharing
**Mental model:** Immutable collections reuse internal structure for efficiency.
```kotlin
val list1 = persistentListOf(1, 2, 3, 4, 5) // 5 items
val list2 = list1.add(6) // Shares structure with list1
// Internally:
// list1 and list2 share nodes for items 1-5
// list2 has one additional node for item 6
// O(1) time, O(1) space for add operation
```
---
## Common Patterns
### Pattern: Immutable State Updates
```kotlin
@Immutable
data class FeedState(
val events: ImmutableList<Event>,
val loading: Boolean,
val error: String?
)
class FeedViewModel {
private val _state = MutableStateFlow(
FeedState(
events = persistentListOf(),
loading = false,
error = null
)
)
val state: StateFlow<FeedState> = _state.asStateFlow()
fun loadEvents() {
_state.value = _state.value.copy(loading = true, error = null)
viewModelScope.launch {
try {
val events = repository.getEvents()
_state.value = _state.value.copy(
events = events.toImmutableList(),
loading = false
)
} catch (e: Exception) {
_state.value = _state.value.copy(
loading = false,
error = e.message
)
}
}
}
fun addEvent(event: Event) {
_state.value = _state.value.copy(
events = _state.value.events.add(event) // Immutable add
)
}
fun removeEvent(eventId: String) {
_state.value = _state.value.copy(
events = _state.value.events.filter { it.id != eventId }.toImmutableList()
)
}
}
```
### Pattern: Deep Immutability
```kotlin
// Nested immutable structures
@Immutable
data class User(
val name: String,
val profile: Profile // Also immutable
)
@Immutable
data class Profile(
val bio: String,
val avatar: String,
val relays: ImmutableList<String> // Immutable collection
)
// Safe deep copy
val user = User(
name = "Alice",
profile = Profile(
bio = "Nostr enthusiast",
avatar = "https://...",
relays = persistentListOf("wss://relay1.com")
)
)
val updatedUser = user.copy(
profile = user.profile.copy(
bio = "Bitcoin & Nostr enthusiast" // Deep update
)
)
```
### Pattern: Collection Builder to Immutable
```kotlin
// Build mutable, convert to immutable
fun processEvents(input: List<Event>): ImmutableList<Event> {
val processed = mutableListOf<Event>()
for (event in input) {
if (event.isValid()) {
processed.add(event.normalize())
}
}
return processed.toImmutableList() // Convert once at end
}
```
### Pattern: Immutable Map Updates
```kotlin
private val _relayStatuses = MutableStateFlow<ImmutableMap<String, RelayStatus>>(
persistentMapOf()
)
fun updateRelay(url: String, connected: Boolean) {
val currentStatuses = _relayStatuses.value
val currentStatus = currentStatuses[url] ?: RelayStatus(url, false)
_relayStatuses.value = currentStatuses.put(
url,
currentStatus.copy(connected = connected)
)
}
fun removeRelay(url: String) {
_relayStatuses.value = _relayStatuses.value.remove(url)
}
```
---
## Performance Impact
### Benchmarks (Approximate)
**Recomposition cost:**
```kotlin
// 1000 items in LazyColumn
// Without @Immutable: ~100ms per frame (skipped frames)
// With @Immutable: ~16ms per frame (smooth 60fps)
@Immutable
data class Item(val id: String, val name: String)
@Composable
fun ItemList(items: ImmutableList<Item>) {
LazyColumn {
items(items, key = { it.id }) { item ->
ItemRow(item) // Only recomposes when item changes
}
}
}
```
**Structural sharing efficiency:**
```kotlin
val list1 = persistentListOf(1..10000)
val list2 = list1.add(10001) // O(log n) time, shares structure
// Regular list (copy on modification):
val mutableList = (1..10000).toMutableList()
val copy = mutableList.toList() + 10001 // O(n) time, full copy
```
### When to Use Immutable Collections
**Use ImmutableList/Map/Set when:**
- Storing in Compose state (@Immutable class)
- Sharing across coroutines
- Frequent modifications (structural sharing efficient)
- Need compile-time immutability guarantee
**Use Array when:**
- Fixed size, no modifications
- Nostr protocol (tags are `Array<Array<String>>`)
- Performance-critical (array access is fastest)
**Use regular List/Map/Set when:**
- Local scope only
- Build once, read many times
- Converting to immutable at boundary
---
## Anti-Patterns
### ❌ Mutable Properties in @Immutable Class
```kotlin
@Immutable
data class BadEvent(
val id: String,
var content: String // BAD: var breaks immutability
)
```
### ✅ All val Properties
```kotlin
@Immutable
data class GoodEvent(
val id: String,
val content: String
)
```
---
### ❌ Mutable Collections in @Immutable Class
```kotlin
@Immutable
data class BadState(
val items: MutableList<Item> // BAD: Can mutate items
)
// Caller can mutate:
val state = BadState(mutableListOf())
state.items.add(newItem) // Breaks immutability!
```
### ✅ Immutable Collections
```kotlin
@Immutable
data class GoodState(
val items: ImmutableList<Item>
)
// Caller must create new state:
val updated = state.copy(items = state.items.add(newItem))
```
---
### ❌ Direct Mutation
```kotlin
val status = RelayStatus(url, connected = false)
status.connected = true // Compile error (val)
// But could happen with mutable nested objects:
@Immutable
data class Config(
val settings: Settings // If Settings is mutable...
)
class Settings {
var theme: String = "dark" // BAD
}
val config = Config(Settings())
config.settings.theme = "light" // Mutates "immutable" config!
```
### ✅ Deep Immutability
```kotlin
@Immutable
data class Config(
val settings: Settings
)
@Immutable
data class Settings(
val theme: String // val only
)
val config = Config(Settings("dark"))
val updated = config.copy(
settings = config.settings.copy(theme = "light")
)
```
---
### ❌ Exposing Mutable Internal State
```kotlin
@Immutable
class BadViewModel {
private val _items = mutableListOf<Item>()
val items: List<Item> = _items // BAD: Exposes mutable list
fun addItem(item: Item) {
_items.add(item)
}
}
// Caller can cast and mutate:
val vm = BadViewModel()
(vm.items as MutableList).clear() // Breaks encapsulation!
```
### ✅ Convert to Immutable at Boundary
```kotlin
@Immutable
class GoodViewModel {
private val _items = mutableListOf<Item>()
val items: ImmutableList<Item>
get() = _items.toImmutableList() // GOOD: Copy to immutable
fun addItem(item: Item) {
_items.add(item)
}
}
```
---
## Checklist for Immutability
**For @Immutable classes:**
- [ ] All properties are `val`, never `var`
- [ ] No mutable collections (`MutableList`, `MutableMap`, `MutableSet`)
- [ ] Nested objects are also `@Immutable` or primitives
- [ ] No public mutable state
- [ ] Use `copy()` for updates, never mutation
- [ ] Arrays used only when truly immutable by contract
**For StateFlow state:**
- [ ] State class is `@Immutable`
- [ ] Use immutable collections (ImmutableList, ImmutableMap)
- [ ] Create new instances for updates (`copy()`, `.add()`, `.put()`)
- [ ] Never mutate state in-place
**For Compose performance:**
- [ ] All `@Composable` parameters are `@Immutable` or `@Stable`
- [ ] Lists use `ImmutableList` and `key` parameter in `items()`
- [ ] Heavy objects (events, profiles) cached and reused
---
## References
- TextNoteEvent.kt:51-63 - @Immutable event example
- RelayConnectionManager.kt - Immutable map updates
- [Compose Performance | Android Developers](https://developer.android.com/jetpack/compose/performance/stability)
- [kotlinx.collections.immutable | GitHub](https://github.com/Kotlin/kotlinx.collections.immutable)
- [@Stable and @Immutable | Compose Docs](https://developer.android.com/jetpack/compose/performance/stability/fix)
@@ -0,0 +1,482 @@
# Sealed Class Catalog
Comprehensive list of sealed types in AmethystMultiplatform with usage patterns.
## Table of Contents
- [State Management](#state-management)
- [Result Types](#result-types)
- [Tag Variants](#tag-variants)
- [Sealed Class vs Sealed Interface](#sealed-class-vs-sealed-interface)
- [Patterns](#patterns)
---
## State Management
### AccountState (Sealed Class)
**File:** `commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/account/AccountManager.kt:36-46`
```kotlin
sealed class AccountState {
data object LoggedOut : AccountState()
data class LoggedIn(
val signer: NostrSigner,
val pubKeyHex: String,
val npub: String,
val nsec: String?,
val isReadOnly: Boolean
) : AccountState()
}
```
**Why sealed class:**
- Two distinct states with different data
- `LoggedIn` holds data, `LoggedOut` doesn't
- No need for generics or multiple inheritance
**Usage:**
```kotlin
fun handleAccountState(state: AccountState) {
when (state) {
is AccountState.LoggedOut -> showLogin()
is AccountState.LoggedIn -> {
showFeed(
pubkey = state.pubKeyHex,
canSign = !state.isReadOnly
)
}
} // Exhaustive - compiler enforces
}
```
### VerificationState (Sealed Class)
**File:** `quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip03Timestamp/VerificationState.kt`
```kotlin
sealed class VerificationState {
data object NotStarted : VerificationState()
data object Started : VerificationState()
data class Failed(val reason: String) : VerificationState()
data object Verified : VerificationState()
}
```
**Pattern:**
- State machine (NotStarted → Started → Failed/Verified)
- Only `Failed` carries data (reason)
- Rest are singletons (`data object`)
---
## Result Types
### SignerResult (Sealed Interface with Generics)
**File:** `quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/signer/SignerResult.kt:25-46`
```kotlin
sealed interface SignerResult<T : IResult> {
sealed interface RequestAddressed<T : IResult> : SignerResult<T> {
class Successful<T : IResult>(val result: T) : RequestAddressed<T>
class Rejected<T : IResult> : RequestAddressed<T>
class TimedOut<T : IResult> : RequestAddressed<T>
class ReceivedButCouldNotPerform<T : IResult>(
val message: String? = null
) : RequestAddressed<T>
class ReceivedButCouldNotParseEventFromResult<T : IResult>(
val eventJson: String
) : RequestAddressed<T>
class ReceivedButCouldNotVerifyResultingEvent<T : IResult>(
val invalidEvent: Event
) : RequestAddressed<T>
}
}
interface IResult
data class SignResult(val event: Event) : IResult
data class EncryptionResult(val ciphertext: String) : IResult
data class DecryptionResult(val plaintext: String) : IResult
```
**Why sealed interface:**
- Generic result type `<T : IResult>`
- Nested sealed hierarchy (RequestAddressed)
- Need covariance for flexible result types
**Usage:**
```kotlin
suspend fun signEvent(event: Event): SignerResult<SignResult> {
return when (val result = remoteSigner.sign(event)) {
is SignerResult.RequestAddressed.Successful -> result
is SignerResult.RequestAddressed.Rejected -> {
logger.warn("Signing rejected")
result
}
is SignerResult.RequestAddressed.TimedOut -> {
logger.error("Signing timed out")
result
}
is SignerResult.RequestAddressed.ReceivedButCouldNotPerform -> {
logger.error("Signer error: ${result.message}")
result
}
}
}
```
### CacheResults (Sealed Class with Generics)
**File:** `quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/signers/caches/CacheResults.kt`
```kotlin
sealed class CacheResults<T> {
data class Found<T>(val value: T) : CacheResults<T>()
class NotFound<T> : CacheResults<T>()
}
```
**Pattern:**
- Simple binary result (found/not found)
- `Found` carries data, `NotFound` doesn't
- Generic for reusability
---
## Tag Variants
### MuteTag (Sealed Class)
**File:** `quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/muteList/tags/MuteTag.kt`
```kotlin
sealed class MuteTag(
val nameOrNull: String?,
val valueOrNull: String?
) {
class Event(eventId: String) : MuteTag("e", eventId)
class Profile(pubkey: String) : MuteTag("p", pubkey)
class Word(word: String) : MuteTag("word", word)
class Thread(threadId: String) : MuteTag("thread", threadId)
companion object {
fun parse(tag: Array<String>): MuteTag? {
return when (tag.getOrNull(0)) {
"e" -> tag.getOrNull(1)?.let { Event(it) }
"p" -> tag.getOrNull(1)?.let { Profile(it) }
"word" -> tag.getOrNull(1)?.let { Word(it) }
"thread" -> tag.getOrNull(1)?.let { Thread(it) }
else -> null
}
}
}
fun toArray(): Array<String> {
return arrayOf(nameOrNull ?: "", valueOrNull ?: "")
}
}
```
**Pattern:**
- Common base class with shared properties
- Each variant represents different tag type
- Factory method `parse()` for parsing
- `toArray()` for serialization
### BookmarkIdTag (Sealed Class)
**File:** `quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/bookmarkList/tags/BookmarkIdTag.kt`
```kotlin
sealed class BookmarkIdTag {
abstract val id: String
abstract val marker: String?
data class Event(override val id: String, override val marker: String?) : BookmarkIdTag()
data class Profile(override val id: String, override val marker: String?) : BookmarkIdTag()
data class Address(override val id: String, override val marker: String?) : BookmarkIdTag()
companion object {
fun parse(tag: Array<String>): BookmarkIdTag? {
val marker = tag.getOrNull(3)
return when (tag.getOrNull(0)) {
"e" -> tag.getOrNull(1)?.let { Event(it, marker) }
"p" -> tag.getOrNull(1)?.let { Profile(it, marker) }
"a" -> tag.getOrNull(1)?.let { Address(it, marker) }
else -> null
}
}
}
}
```
**Pattern:**
- Abstract properties in sealed class
- Data classes implement abstract properties
- Parse factory returns sealed variant
---
## Exception Hierarchies
### SignerExceptions (Sealed Class)
**File:** `quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/signers/SignerExceptions.kt`
```kotlin
sealed class SignerExceptions(message: String) : Exception(message) {
class UnableToSign(message: String) : SignerExceptions(message)
class UnableToDecrypt(message: String) : SignerExceptions(message)
class UnableToEncrypt(message: String) : SignerExceptions(message)
class UnableToGetPublicKey(message: String) : SignerExceptions(message)
}
```
**Pattern:**
- Sealed exception hierarchy
- Extends `Exception` base class
- Type-safe error handling
**Usage:**
```kotlin
try {
signer.sign(event)
} catch (e: SignerExceptions) {
when (e) {
is SignerExceptions.UnableToSign -> logger.error("Signing failed: ${e.message}")
is SignerExceptions.UnableToDecrypt -> logger.error("Decryption failed: ${e.message}")
is SignerExceptions.UnableToEncrypt -> logger.error("Encryption failed: ${e.message}")
is SignerExceptions.UnableToGetPublicKey -> logger.error("No public key: ${e.message}")
}
}
```
---
## Sealed Class vs Sealed Interface
### When to Use Sealed Class
**Examples from codebase:**
1. **AccountState** - State variants with different data
2. **VerificationState** - State machine
3. **MuteTag** - Tag variants with common base properties
4. **SignerExceptions** - Exception hierarchy
**Characteristics:**
- Need common constructor parameters
- Single inheritance only
- State variants
- Exception hierarchies
### When to Use Sealed Interface
**Examples from codebase:**
1. **SignerResult<T>** - Generic result types needing variance
2. **RelayUrlNormalizer.Result** - Binary result with no shared state
**Characteristics:**
- Need generics with variance (`out`, `in`)
- No common state needed
- Multiple inheritance possible
- Contract/capability representation
---
## Patterns
### Pattern: State Machine
```kotlin
sealed class ConnectionState {
data object Disconnected : ConnectionState()
data object Connecting : ConnectionState()
data class Connected(val relay: String) : ConnectionState()
data class Failed(val error: String) : ConnectionState()
}
// Allowed transitions
fun transition(from: ConnectionState, event: Event): ConnectionState {
return when (from) {
is ConnectionState.Disconnected -> {
when (event) {
is Event.Connect -> ConnectionState.Connecting
else -> from
}
}
is ConnectionState.Connecting -> {
when (event) {
is Event.Success -> ConnectionState.Connected(event.relay)
is Event.Error -> ConnectionState.Failed(event.message)
is Event.Cancel -> ConnectionState.Disconnected
else -> from
}
}
is ConnectionState.Connected -> {
when (event) {
is Event.Disconnect -> ConnectionState.Disconnected
is Event.Error -> ConnectionState.Failed(event.message)
else -> from
}
}
is ConnectionState.Failed -> {
when (event) {
is Event.Retry -> ConnectionState.Connecting
is Event.Cancel -> ConnectionState.Disconnected
else -> from
}
}
}
}
```
### Pattern: Result Type
```kotlin
sealed interface Result<out T> {
data class Success<T>(val data: T) : Result<T>
data class Error(val exception: Exception) : Result<Nothing>
data object Loading : Result<Nothing>
}
// Extension functions
fun <T> Result<T>.getOrNull(): T? = when (this) {
is Result.Success -> data
else -> null
}
fun <T> Result<T>.getOrThrow(): T = when (this) {
is Result.Success -> data
is Result.Error -> throw exception
is Result.Loading -> error("Still loading")
}
fun <T, R> Result<T>.map(transform: (T) -> R): Result<R> = when (this) {
is Result.Success -> Result.Success(transform(data))
is Result.Error -> this
is Result.Loading -> Result.Loading
}
```
### Pattern: Tagged Union (Discriminated Union)
```kotlin
sealed class Command {
data class SendEvent(val event: Event) : Command()
data class Subscribe(val filters: List<Filter>) : Command()
data class Unsubscribe(val subId: String) : Command()
data object Close : Command()
fun toJson(): String = when (this) {
is SendEvent -> """["EVENT",${event.toJson()}]"""
is Subscribe -> """["REQ","sub",${filters.joinToString { it.toJson() }}]"""
is Unsubscribe -> """["CLOSE","$subId"]"""
is Close -> """["CLOSE"]"""
}
}
```
### Pattern: Nested Sealed Hierarchies
```kotlin
sealed interface UiState {
sealed interface Loading : UiState {
data object Initial : Loading
data class Refreshing(val currentData: List<Item>) : Loading
}
sealed interface Content : UiState {
data class Success(val data: List<Item>) : Content
data object Empty : Content
}
sealed interface Error : UiState {
data class Network(val message: String) : Error
data class Server(val code: Int, val message: String) : Error
}
}
// Usage
fun renderUi(state: UiState) {
when (state) {
is UiState.Loading.Initial -> showFullScreenLoader()
is UiState.Loading.Refreshing -> showRefreshIndicator(state.currentData)
is UiState.Content.Success -> showList(state.data)
is UiState.Content.Empty -> showEmptyState()
is UiState.Error.Network -> showNetworkError(state.message)
is UiState.Error.Server -> showServerError(state.code, state.message)
}
}
```
---
## All Sealed Types in Quartz
**Complete list of sealed types found in codebase:**
### Commons
- AccountState (class)
### Quartz
- BaseZapSplitSetup (class)
- MuteTag (class)
- BookmarkIdTag (class)
- SignerResult (interface)
- VerificationState (class)
- CacheResults (class)
- SignerExceptions (class)
- RelayUrlNormalizer.Result (interface)
**Total:** 8 sealed types (7 classes, 1 interface)
---
## Decision Tree
```
Need to represent variants of a concept?
YES → Use sealed type
NO → Regular class/interface
Variants have different data?
YES → sealed class or sealed interface
NO → enum (if simple constants)
Need generics with variance (out/in)?
YES → sealed interface
NO → sealed class (simpler)
Need common constructor/properties?
YES → sealed class
NO → sealed interface
Need multiple inheritance?
YES → sealed interface
NO → Either works
Representing state machine?
→ sealed class (state transitions)
Representing result/error types?
→ sealed interface (if generic, else class)
Representing tag/command variants?
→ sealed class (common structure)
```
---
## References
- [Sealed Classes | Kotlin Docs](https://kotlinlang.org/docs/sealed-classes.html)
- [Effective Kotlin: Sealed Classes](https://kt.academy/article/ek-sealed-classes)
- [Complete Guide: Sealed Classes & Interfaces 2025](https://proandroiddev.com/complete-technical-guide-sealed-classes-sealed-interfaces-enums-in-kotlin-28ffc39116df)
@@ -0,0 +1,402 @@
---
name: kotlin-multiplatform
description: |
Platform abstraction decision-making for Amethyst KMP project. Guides when to abstract vs keep platform-specific,
source set placement (commonMain, jvmAndroid, platform-specific), expect/actual patterns. Covers primary targets
(Android, JVM/Desktop, iOS) with web/wasm future considerations. Integrates with gradle-expert for dependency issues.
Triggers on: abstraction decisions ("should I share this?"), source set placement questions, expect/actual creation,
build.gradle.kts work, incorrect placement detection, KMP dependency suggestions.
---
# Kotlin Multiplatform: Platform Abstraction Decisions
Expert guidance for KMP architecture in Amethyst - deciding what to share vs keep platform-specific.
## When to Use This Skill
Making platform abstraction decisions:
- "Should I create expect/actual or keep Android-only?"
- "Can I share this ViewModel logic?"
- "Where does this crypto/JSON/network implementation belong?"
- "This uses Android Context - can it be abstracted?"
- "Is this code in the wrong module?"
- Preparing for iOS/web/wasm targets
- Detecting incorrect placements
## Abstraction Decision Tree
**Central question:** "Should this code be reused across platforms?"
Follow this decision path (< 1 minute):
```
Q: Is it used by 2+ platforms?
├─ NO → Keep platform-specific
│ Example: Android-only permission handling
└─ YES → Continue ↓
Q: Is it pure Kotlin (no platform APIs)?
├─ YES → commonMain
│ Example: Nostr event parsing, business rules
└─ NO → Continue ↓
Q: Does it vary by platform or by JVM vs non-JVM?
├─ By platform (Android ≠ iOS ≠ Desktop)
│ → expect/actual
│ Example: Secp256k1Instance (uses different security APIs)
├─ By JVM (Android = Desktop ≠ iOS/web)
│ → jvmAndroid
│ Example: Jackson JSON parsing (JVM library)
└─ Complex/UI-related
→ Keep platform-specific
Example: Navigation (Activity vs Window too different)
Final check:
Q: Maintenance cost of abstraction < duplication cost?
├─ YES → Proceed with abstraction
└─ NO → Duplicate (simpler)
```
### Real Examples from Codebase
**Crypto → expect/actual:**
```kotlin
// commonMain - expect declaration
expect object Secp256k1Instance {
fun signSchnorr(data: ByteArray, privKey: ByteArray): ByteArray
}
// androidMain - uses Android Keystore
// jvmMain - uses Desktop JVM crypto
// iosMain - uses iOS Security framework
```
**Why:** Each platform has different security APIs.
**JSON parsing → jvmAndroid:**
```kotlin
// quartz/build.gradle.kts
val jvmAndroid = create("jvmAndroid") {
api(libs.jackson.module.kotlin)
}
```
**Why:** Jackson is JVM-only, works on Android + Desktop, not iOS/web.
**Navigation → platform-specific:**
- Android: `MainActivity` (Activity + Compose Navigation)
- Desktop: `Window` + sidebar + MenuBar
**Why:** UI paradigms fundamentally different.
## Mental Model: Source Sets as Dependency Graph
Think of source sets as a dependency graph, not folders.
```
┌─────────────────────────────────────────────┐
│ commonMain = Contract (pure Kotlin) │
│ - Business logic, protocol, data models │
│ - No platform APIs │
└────────────┬────────────────────────────────┘
├──────────────────────┬────────────────────
│ │
▼ ▼
┌───────────────────┐ ┌──────────────────┐
│ jvmAndroid │ │ iosMain │
│ JVM libs shared │ │ iOS common │
│ - Jackson │ │ │
│ - OkHttp │ └────┬─────────────┘
└───┬───────────┬───┘ │
│ │ ├─→ iosX64Main
▼ ▼ ├─→ iosArm64Main
┌─────────┐ ┌──────────┐ └─→ iosSimulatorArm64Main
│android │ │jvmMain │
│Main │ │(Desktop) │
└─────────┘ └──────────┘
Future: jsMain, wasmMain
```
**Key insight:** jvmAndroid is NOT a platform - it's a shared JVM layer.
## The jvmAndroid Pattern
**Unique to Amethyst.** Shares JVM libraries between Android + Desktop.
### When to Use jvmAndroid
Use jvmAndroid when:
- ✅ JVM-specific libraries (Jackson, OkHttp, url-detector)
- ✅ Android implementation = Desktop implementation (same JVM)
- ✅ Library doesn't work on iOS/web
Do NOT use jvmAndroid for:
- ❌ Pure Kotlin code (use commonMain)
- ❌ Platform-specific APIs (use androidMain/jvmMain)
- ❌ Code that should work on all platforms
### Example from quartz/build.gradle.kts
```kotlin
// Must be defined BEFORE androidMain and jvmMain
val jvmAndroid = create("jvmAndroid") {
dependsOn(commonMain.get())
dependencies {
api(libs.jackson.module.kotlin) // JSON parsing - JVM only
api(libs.url.detector) // URL extraction - JVM only
implementation(libs.okhttp) // HTTP client - JVM only
}
}
// Both depend on jvmAndroid
jvmMain { dependsOn(jvmAndroid) }
androidMain { dependsOn(jvmAndroid) }
```
**Why Jackson in jvmAndroid, not commonMain?**
- Jackson is JVM-specific library
- Works on Android (runs on JVM)
- Works on Desktop (runs on JVM)
- Does NOT work on iOS (not JVM) or web (not JVM)
**Web/wasm consideration:** For future web support, consider migrating from Jackson → kotlinx.serialization (see Target-Specific Guidance).
## What to Abstract vs Keep Platform-Specific
Quick decision guidelines based on codebase patterns:
### Always Abstract
- **Crypto** (Secp256k1, encryption, signing)
- **Core protocol logic** (Nostr events, NIPs)
- **Why:** Needed everywhere, platform security APIs vary
### Often Abstract
- **I/O operations** (file reading, caching)
- **Logging** (platform logging systems differ)
- **Serialization** (if using kotlinx.serialization)
- **Why:** Commonly reused, platform implementations available
### Sometimes Abstract
- **Business logic:** YES - state machines, data processing
- **ViewModels:** YES - state + business logic shareable (StateFlow/SharedFlow)
- **Screen layouts:** NO - platform-native (Window vs Activity)
- **Why:** ViewModels contain platform-agnostic state; Screens render differently per platform
### Rarely Abstract
- **Complex UI components** (composables with heavy platform dependencies)
- **Why:** Platform paradigms can differ significantly
### Never Abstract
- **Navigation** (Activity vs Window fundamentally different)
- **Permissions** (Android vs iOS APIs incompatible)
- **Platform UX patterns**
- **Why:** Too platform-specific, abstraction creates leaky APIs
### Evidence from shared-ui-analysis.md
| Component | Shared? | Rationale |
|-----------|---------|-----------|
| PubKeyFormatter, ZapFormatter | ✅ YES | Pure Kotlin, no platform APIs |
| TimeAgoFormatter | ⚠️ ABSTRACTED | Needs StringProvider for localized strings |
| ViewModels (state + logic) | ✅ YES | StateFlow/SharedFlow platform-agnostic, Compose Multiplatform lifecycle compatible |
| Screen layouts (Scaffold, nav) | ❌ NO | Window vs Activity, sidebar vs bottom nav fundamentally different |
| Image loading (Coil) | ⚠️ ABSTRACTED | Coil 3.x supports KMP, needs expect/actual wrapper |
## expect/actual Mechanics
**When to use:** Code needed by 2+ platforms, varies by platform.
### Pattern Categories from Codebase
**Objects (singletons):**
```kotlin
// 24 expect declarations found, common pattern:
expect object Secp256k1Instance { ... }
expect object Log { ... }
expect object LibSodiumInstance { ... }
```
**Classes (instantiable):**
```kotlin
expect class AESCBC { ... }
expect class DigestInstance { ... }
```
**Functions (utilities):**
```kotlin
expect fun platform(): String
expect fun currentTimeSeconds(): Long
```
**See** [references/expect-actual-catalog.md](references/expect-actual-catalog.md) for complete catalog with rationale.
## Target-Specific Guidance
### Android, JVM (Desktop), iOS - Current Primary Targets
**Status:** Mature patterns, stable APIs
**Android (androidMain):**
- Uses Android framework (Activity, Context, etc.)
- secp256k1-kmp-jni-android for crypto
- AndroidX libraries
**Desktop JVM (jvmMain):**
- Uses Compose Desktop (Window, MenuBar, etc.)
- secp256k1-kmp-jni-jvm for crypto
- Pure JVM libraries
**iOS (iosMain):**
- Active development, framework configured
- Architecture targets: iosX64Main, iosArm64Main, iosSimulatorArm64Main
- Platform APIs via platform.posix, Security framework
### Web, wasm - Future Targets
**Status:** Not yet implemented, consider for future-proofing
**Constraints to know:**
- ❌ No platform.posix (file I/O different)
- ❌ No JVM libraries (Jackson, OkHttp won't work)
- ❌ Different async model (JS event loop vs threads)
**Future-proofing tips:**
1. Prefer pure Kotlin in commonMain
2. Use kotlinx.* libraries:
- kotlinx.serialization instead of Jackson
- ktor instead of OkHttp (ktor supports web)
- kotlinx.datetime instead of custom date handling
3. Avoid platform.posix for file operations
4. Test abstractions work without JVM assumptions
**Example migration path:**
```kotlin
// Current: jvmAndroid (JVM-only)
api(libs.jackson.module.kotlin)
// Future: commonMain (all platforms)
api(libs.kotlinx.serialization.json)
```
## Integration: When to Invoke Other Skills
### Invoke gradle-expert
Trigger gradle-expert skill when encountering:
- Dependency conflicts (e.g., secp256k1-android vs secp256k1-jvm version mismatch)
- Build errors related to source sets
- Version catalog issues (libs.versions.toml)
- "Duplicate class" errors
- Performance/build time issues
**Example trigger:**
```
Error: Duplicate class found: fr.acinq.secp256k1.Secp256k1
```
→ Invoke gradle-expert for dependency conflict resolution.
### Flags to Raise
**Platform code in commonMain:**
```kotlin
// ❌ INCORRECT - Android API in commonMain
expect fun getContext(): Context // Context is Android-only!
```
→ Flag: "Android API in commonMain won't compile on other platforms"
**Duplicated business logic:**
```kotlin
// ❌ INCORRECT - Same logic in both
// androidMain/.../CryptoUtils.kt
fun validateSignature(...) { ... }
// jvmMain/.../CryptoUtils.kt
fun validateSignature(...) { ... } // Duplicated!
```
→ Flag: "Business logic duplicated, should be in commonMain or expect/actual"
**Reinventing wheel - suggest KMP alternatives:**
- Custom date/time → kotlinx.datetime
- OkHttp → ktor (supports web)
- Jackson → kotlinx.serialization
- Custom UUID → kotlinx.uuid (when stable)
## Common Pitfalls
### 1. Over-Abstraction
**Problem:** Creating expect/actual for UI components
```kotlin
// ❌ BAD
expect fun NavigationComponent(...)
```
**Why:** Navigation paradigms too different (Activity vs Window)
**Fix:** Keep platform-specific, accept duplication
### 2. Under-Sharing
**Problem:** Duplicating business logic across platforms
```kotlin
// ❌ BAD - duplicated in androidMain and jvmMain
fun parseNostrEvent(json: String): Event { ... }
```
**Why:** Bug fixes need to be applied twice, tests duplicated
**Fix:** Move to commonMain (pure Kotlin) or create expect/actual
### 3. Leaky Abstractions
**Problem:** Platform code in commonMain
```kotlin
// commonMain - ❌ BAD
import android.content.Context // Won't compile on iOS!
```
**Fix:** Use expect/actual or dependency injection
### 4. Premature Abstraction
**Problem:** Creating expect/actual before second platform needs it
```kotlin
// ❌ BAD - only used on Android currently
expect fun showNotification(...)
```
**Why:** Wrong abstraction boundaries, wasted effort
**Fix:** Wait until iOS actually needs it, then abstract
### 5. Wrong Source Set
**Problem:** JVM libraries in commonMain
```kotlin
// commonMain - ❌ BAD
import com.fasterxml.jackson.databind.ObjectMapper
```
**Why:** Jackson won't compile on iOS/web
**Fix:** Move to jvmAndroid or migrate to kotlinx.serialization
## Quick Reference
| Code Type | Recommended Location | Reason |
|-----------|---------------------|--------|
| Pure Kotlin business logic | commonMain | Works everywhere |
| Nostr protocol, NIPs | commonMain | Core logic, no platform APIs |
| JVM libs (Jackson, OkHttp) | jvmAndroid | Android + Desktop only |
| Crypto (varies by platform) | expect in commonMain, actual in platforms | Different security APIs per platform |
| I/O, logging | expect in commonMain, actual in platforms | Platform implementations differ |
| State (business logic) | commonMain or commons/jvmAndroid | Reusable StateFlow patterns |
| **ViewModels** | **commons/commonMain/viewmodels/** | **StateFlow/SharedFlow + logic shareable, Compose MP lifecycle compatible** |
| UI formatters (pure) | commons/commonMain | Reusable, no dependencies |
| UI components (simple) | commons/commonMain | Cards, buttons, dialogs |
| **Screen layouts** | **Platform-specific** | **Window vs Activity, sidebar vs bottom nav** |
| Navigation | Platform-specific only | Activity vs Window too different |
| Permissions | Platform-specific only | APIs incompatible |
| Platform UX (menus, etc.) | Platform-specific only | Native feel required |
## See Also
- [references/abstraction-examples.md](references/abstraction-examples.md) - Good/bad abstraction examples with rationale
- [references/source-set-hierarchy.md](references/source-set-hierarchy.md) - Visual hierarchy with Amethyst examples
- [references/expect-actual-catalog.md](references/expect-actual-catalog.md) - All 24 expect/actual pairs with "why abstracted"
- [references/target-compatibility.md](references/target-compatibility.md) - Platform constraints and future-proofing
## Scripts
- `scripts/validate-kmp-structure.sh` - Detect incorrect placements, validate source sets
- `scripts/suggest-kmp-dependency.sh` - Suggest KMP library alternatives (ktor, kotlinx.serialization, etc.)
@@ -0,0 +1,311 @@
# Abstraction Examples from Amethyst Codebase
Real examples of abstraction decisions with rationale.
## Good Abstractions (Why They Work)
### 1. Secp256k1Instance - Crypto Signing
**Location:** expect in commonMain, actual in androidMain/jvmMain/iosMain
**Code:**
```kotlin
// quartz/src/commonMain/.../Secp256k1Instance.kt
expect object Secp256k1Instance {
fun signSchnorr(data: ByteArray, privKey: ByteArray): ByteArray
fun verifySchnorr(signature: ByteArray, hash: ByteArray, pubKey: ByteArray): Boolean
}
```
**Why abstracted:**
- Used by all platforms (Android, Desktop, iOS)
- Security APIs fundamentally different:
- Android: secp256k1-kmp-jni-android (Android Keystore integration)
- Desktop: secp256k1-kmp-jni-jvm (pure JVM crypto)
- iOS: Native Security framework
- Core protocol requirement (Nostr signatures)
**Decision rationale:** Always abstract crypto - varies by platform security APIs, critical for all platforms.
---
### 2. Log - Platform Logging
**Location:** expect object in commonMain
**Code:**
```kotlin
// quartz/src/commonMain/.../Log.kt
expect object Log {
fun d(tag: String, message: String)
fun w(tag: String, message: String, throwable: Throwable?)
fun e(tag: String, message: String, throwable: Throwable?)
}
```
**Why abstracted:**
- Used throughout quartz module (protocol library)
- Logging systems differ:
- Android: android.util.Log
- Desktop: println or logging framework
- iOS: NSLog or OSLog
- Simple interface, easy to implement
**Decision rationale:** Often abstract logging - platform systems differ, widely used, simple interface.
---
### 3. Platform Utils - Time & Platform Name
**Location:** expect functions in commonMain
**Code:**
```kotlin
// quartz/src/commonMain/.../Platform.kt
expect fun platform(): String
expect fun currentTimeSeconds(): Long
```
**Why abstracted:**
- Used by Nostr event creation (timestamps)
- Platform name for debugging
- Simple utilities, clear platform boundary
**Decision rationale:** Platform utilities are good abstraction candidates - simple, useful everywhere.
---
### 4. Jackson JSON (jvmAndroid Pattern)
**Location:** jvmAndroid source set
**Code:**
```kotlin
// quartz/build.gradle.kts
val jvmAndroid = create("jvmAndroid") {
api(libs.jackson.module.kotlin) // JVM-only library
}
```
**Why jvmAndroid (not commonMain):**
- Jackson is JVM-specific library
- Works on Android (JVM) + Desktop (JVM)
- Does NOT work on iOS (not JVM) or web (not JVM)
- Performance-critical JSON parsing
**Decision rationale:** Use jvmAndroid for JVM libraries shared between Android and Desktop.
**Future consideration:** For web support, migrate to kotlinx.serialization (works on all platforms).
---
## Bad/Over-Abstractions (Why They Failed)
### 1. Navigation Abstraction (Avoided)
**What COULD have been done:**
```kotlin
// ❌ Over-abstraction - DON'T DO THIS
expect interface Navigator {
fun navigate(route: String)
fun popBackStack()
}
```
**Why NOT abstracted:**
- Navigation paradigms fundamentally different:
- Android: Activity + Compose Navigation + back stack
- Desktop: Window + screen state + no back stack concept
- Complex APIs don't map well
- Creates leaky abstraction
**Actual approach:** Keep platform-specific
- Android: `INav` interface + Compose Navigation
- Desktop: Simple screen enum + state
**Decision rationale:** Never abstract navigation - platforms too different, abstraction would be leaky.
---
### 2. String Resources (Abstraction Planned)
**Current state:** Platform-specific (over-duplication)
**Problem:**
```kotlin
// Android uses R.string.*
Text(stringResource(R.string.post_not_found))
// Desktop uses hardcoded strings
Text("Post not found")
```
**Why NOT yet abstracted:** Waiting for second platform to fully implement UI, then will create StringProvider interface.
**Planned abstraction:**
```kotlin
// commonMain
interface StringProvider {
fun get(key: String): String
}
// androidMain
class AndroidStringProvider(context: Context): StringProvider { ... }
// jvmMain
class DesktopStringProvider: StringProvider { ... }
```
**Lesson:** Don't abstract prematurely - wait until second platform needs it, then create proper abstraction.
---
## Platform-Specific Code (Why NOT Abstracted)
### 1. MainActivity (Android Activity)
**Location:** amethyst/src/main/.../MainActivity.kt
**Code:**
```kotlin
class MainActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
enableEdgeToEdge()
setContent {
AmethystTheme {
AccountScreen(accountStateViewModel)
}
}
}
}
```
**Why platform-specific:**
- AppCompatActivity is Android framework
- Activity lifecycle unique to Android
- enableEdgeToEdge() is Android-specific API
- No equivalent on Desktop (uses Window)
**Decision rationale:** Android Activity is platform-specific by nature.
---
### 2. Desktop Window & MenuBar
**Location:** desktopApp/src/jvmMain/.../Main.kt
**Code:**
```kotlin
fun main() = application {
Window(
onCloseRequest = ::exitApplication,
title = "Amethyst"
) {
MenuBar {
Menu("File") {
Item("New Note", onClick = { ... }, shortcut = KeyShortcut(Key.N, ctrl = true))
Item("Quit", onClick = ::exitApplication)
}
}
NavigationRail { ... } // Sidebar navigation
}
}
```
**Why platform-specific:**
- Window, MenuBar, NavigationRail are Compose Desktop APIs
- Keyboard shortcuts (Ctrl+N) are desktop paradigm
- Sidebar navigation vs Android bottom nav
- No equivalent on Android
**Decision rationale:** Desktop UX patterns are platform-specific by nature.
---
### 3. AccountViewModel (Android ViewModel)
**Location:** amethyst/.../AccountStateViewModel.kt
**Partially abstracted:**
- Business logic → IAccountState interface (can be shared)
- UI state + lifecycle → AndroidX ViewModel (Android-only)
**Why not fully abstracted:**
- AndroidX ViewModel lifecycle tied to Android
- Desktop doesn't need ViewModel (simpler state management)
- SavedStateHandle is Android-specific
**Decision rationale:** Extract business logic to interface, keep UI state platform-specific.
---
## Migration Examples (Android → Shared)
### Example 1: PubKeyFormatter (Pure Kotlin)
**Before:**
```kotlin
// amethyst/ui/note/PubKeyFormatter.kt
fun String.toDisplayHexKey(): String {
return "${take(8)}:${takeLast(8)}"
}
```
**After:**
```kotlin
// commons/commonMain/formatters/PubKeyFormatter.kt
fun String.toDisplayHexKey(): String {
return "${take(8)}:${takeLast(8)}"
}
// Both apps use it
import com.vitorpamplona.amethyst.commons.formatters.toDisplayHexKey
```
**Why successful:**
- Pure Kotlin, no platform dependencies
- Widely reused
- Simple utility function
---
### Example 2: TimeAgoFormatter (Requires Abstraction)
**Problem:**
```kotlin
// Uses Android R.string.*
fun timeAgo(timestamp: Long): String {
return context.getString(R.string.x_minutes_ago, minutes)
}
```
**Solution:** Abstract string resources
```kotlin
// commonMain
fun timeAgo(timestamp: Long, stringProvider: StringProvider): String {
return stringProvider.get("x_minutes_ago", minutes)
}
// androidMain
stringProvider = AndroidStringProvider(context)
// jvmMain
stringProvider = DesktopStringProvider()
```
**Why successful:** Clear platform boundary (string resources), useful on both platforms.
---
## Decision Pattern Summary
| Pattern | Abstract? | Why |
|---------|-----------|-----|
| Pure Kotlin utilities | ✅ YES | No platform dependency, easy |
| Crypto APIs | ✅ YES (expect/actual) | Platform security APIs differ |
| JVM libraries | ⚠️ jvmAndroid | Works on Android+Desktop only |
| UI components (simple) | ✅ YES | Composables work cross-platform |
| UI components (complex) | ❌ NO | Platform dependencies |
| Navigation | ❌ NO | Paradigms too different |
| ViewModels | ⚠️ PARTIAL | Business logic yes, UI state no |
| String resources | ⚠️ PLANNED | Needs abstraction layer |
@@ -0,0 +1,163 @@
# Complete expect/actual Catalog
All 24 expect declarations in Amethyst quartz module with rationale.
| # | Name | Type | Purpose | Why Abstracted | Files |
|---|------|------|---------|----------------|-------|
| 1 | AESCBC | class | AES CBC encryption | Platform crypto APIs differ | quartz/.../ciphers/AESCBC.kt |
| 2 | AESGCM | class | AES GCM encryption | Platform crypto APIs differ | quartz/.../ciphers/AESGCM.kt |
| 3 | DigestInstance | class | Hash digests (SHA256) | Platform implementations | quartz/.../diggest/DigestInstance.kt |
| 4 | MacInstance | class | MAC (HMAC) operations | Platform crypto APIs | quartz/.../mac/MacInstance.kt |
| 5 | Sha256 | object | SHA256 hashing | Platform-specific optimizations | quartz/.../sha256/Sha256.kt |
| 6 | LargeCache | object | Large object caching | Platform storage APIs differ | quartz/.../cache/LargeCache.kt |
| 7 | UriParser | object | URI parsing | Platform URL APIs differ | quartz/.../UriParser.kt |
| 8 | UrlEncoder | object | URL encoding | Platform encoding differs | quartz/.../UrlEncoder.kt |
| 9 | Urls | object | URL utilities | Platform URL handling | quartz/.../Urls.kt |
| 10 | Platform | functions | platform(), currentTimeSeconds() | Platform name & time APIs | quartz/.../Platform.kt |
| 11 | Rfc3986 | object | RFC 3986 URL normalization | Used in jvmAndroid | quartz/.../Rfc3986.kt |
| 12 | Secp256k1Instance | object | Bitcoin crypto (secp256k1) | Different libs per platform | quartz/.../Secp256k1Instance.kt |
| 13 | SecureRandom | object | Cryptographically secure random | Platform random APIs differ | quartz/.../SecureRandom.kt |
| 14 | StringExt | functions | String utilities | Platform string handling | quartz/.../StringExt.kt |
| 15 | UnicodeNormalizer | object | Unicode normalization | Platform text APIs | quartz/.../UnicodeNormalizer.kt |
| 16 | GZip | object | GZip compression | Platform compression APIs | quartz/.../GZip.kt |
| 17 | LibSodiumInstance | object | NaCl/libsodium (NIP-44 encryption) | Different libs per platform | quartz/.../LibSodiumInstance.kt |
| 18 | Log | object | Logging | Platform logging systems | quartz/.../Log.kt |
| 19 | BigDecimal | class | Arbitrary precision decimal | Not in Kotlin common stdlib | quartz/.../BigDecimal.kt |
| 20 | BitSet | class | Bit set data structure | Not in Kotlin common stdlib | quartz/.../BitSet.kt |
| 21 | ServerInfoParser | object | Server info parsing (NIP-96) | Platform JSON parsing | quartz/.../nip96.../ServerInfoParser.kt |
| 22 | EventHasherSerializer | object | Event hashing | Platform-specific optimizations | quartz/.../nip01Core.../EventHasherSerializer.kt |
| 23 | OptimizedJsonMapper | object | JSON mapping | Platform JSON libraries | quartz/.../nip01Core.../OptimizedJsonMapper.kt |
| 24 | Address | data class | Address data structure | Platform-specific string handling | quartz/.../nip01Core.../Address.kt |
## Pattern Analysis
### Objects (Singletons) - 19 total
Most common pattern for platform-specific singletons:
- Crypto: Secp256k1Instance, LibSodiumInstance, Sha256
- I/O: UriParser, UrlEncoder, GZip
- Utils: Log, Platform, SecureRandom
### Classes (Instantiable) - 4 total
For objects that need to maintain state:
- AESCBC, AESGCM (cipher state)
- DigestInstance, MacInstance (hash/MAC state)
- BigDecimal, BitSet (data structures)
### Functions - 2 total
Simple utilities:
- platform(), currentTimeSeconds()
## Why Abstracted Categories
### Crypto (8 items)
**Always abstract:** Security APIs fundamentally different across platforms
- Android: Android Keystore, secp256k1-android
- Desktop: JVM crypto, secp256k1-jvm
- iOS: Security framework, native crypto
### I/O & Platform Utils (7 items)
**Often abstract:** File systems, URLs, compression differ
- Platform storage APIs
- URL handling varies
- Compression libraries differ
### Data Structures (2 items)
**Abstract when missing:** Not available in Kotlin common stdlib
- BigDecimal, BitSet not in common
### JSON/Parsing (3 items)
**Platform-specific optimization:** Uses platform JSON libraries
- Android/Desktop: Jackson (via jvmAndroid)
- iOS: Native parsers
### Logging (1 item)
**Always abstract:** Platform logging systems differ
- Android: android.util.Log
- Desktop: println or logging framework
- iOS: NSLog or OSLog
## Actual Implementation Examples
### Simple Object Pattern
```kotlin
// commonMain
expect object Log {
fun d(tag: String, message: String)
}
// androidMain
actual object Log {
actual fun d(tag: String, message: String) {
android.util.Log.d(tag, message)
}
}
// jvmMain
actual object Log {
actual fun d(tag: String, message: String) {
println("[$tag] $message")
}
}
```
### Complex Object with Dependencies
```kotlin
// commonMain
expect object Secp256k1Instance {
fun signSchnorr(data: ByteArray, privKey: ByteArray): ByteArray
}
// androidMain - uses JNI bindings
actual object Secp256k1Instance {
actual fun signSchnorr(data: ByteArray, privKey: ByteArray): ByteArray {
return fr.acinq.secp256k1.Secp256k1.signSchnorr(data, privKey, null)
}
}
// jvmMain - different JNI library
actual object Secp256k1Instance {
actual fun signSchnorr(data: ByteArray, privKey: ByteArray): ByteArray {
return fr.acinq.secp256k1.Secp256k1.signSchnorr(data, privKey, null)
}
}
// iosMain - native iOS implementation
actual object Secp256k1Instance {
actual fun signSchnorr(data: ByteArray, privKey: ByteArray): ByteArray {
// Uses iOS Security framework or native lib
}
}
```
### Class Pattern
```kotlin
// commonMain
expect class BigDecimal {
constructor(value: String)
fun add(other: BigDecimal): BigDecimal
override fun toString(): String
}
// jvmAndroid (works on Android + Desktop)
actual typealias BigDecimal = java.math.BigDecimal
// iosMain
actual class BigDecimal {
private val value: NSDecimalNumber
actual constructor(value: String) {
this.value = NSDecimalNumber(value)
}
// ... implementation
}
```
## Decision Patterns
Ask for each declaration:
1. **Used by 2+ platforms?** → YES (otherwise platform-specific)
2. **Pure Kotlin possible?** → NO (otherwise commonMain)
3. **Varies by platform?** → YES (expect/actual)
4. **JVM-only library?** → NO (otherwise jvmAndroid)
@@ -0,0 +1,332 @@
# Source Set Hierarchy in Amethyst
Visual guide to source set organization with concrete examples from the codebase.
## Hierarchy Diagram
```
┌─────────────────────────────────────────────────────────────┐
│ commonMain │
│ Pure Kotlin, no platform APIs │
│ Examples: │
│ - Nostr event parsing (TextNoteEvent, MetadataEvent) │
│ - Business logic (data validation, crypto algorithms) │
│ - Data models (@Immutable data classes) │
│ Dependencies: kotlin-stdlib, kotlinx-coroutines │
└──────────────────────┬──────────────────────────────────────┘
┌────────────┴────────────┬───────────────┐
│ │ │
▼ ▼ ▼
┌──────────────────┐ ┌───────────────────┐ ┌──────────────┐
│ jvmAndroid │ │ iosMain │ │ Future: │
│ JVM libraries │ │ iOS common │ │ jsMain │
│ Examples: │ │ Examples: │ │ wasmMain │
│ - Jackson JSON │ │ - Platform API │ └──────────────┘
│ - OkHttp HTTP │ │ - Actuals for │
│ - url-detector │ │ crypto/I/O │
│ Dependencies: │ │ Dependencies: │
│ - Jackson │ │ - Platform libs │
│ - OkHttp │ └───────┬───────────┘
└────┬─────────┬───┘ │
│ │ ├─→ iosX64Main (simulator Intel)
│ │ ├─→ iosArm64Main (device ARM64)
│ │ └─→ iosSimulatorArm64Main (Apple Silicon)
▼ ▼
┌──────────┐ ┌───────────┐
│android │ │ jvmMain │
│Main │ │ (Desktop) │
│Examples: │ │ Examples: │
│- Activity│ │- Window │
│- ViewModel│ │- MenuBar │
│- Android │ │- Desktop │
│ APIs │ │ Compose │
│Deps: │ │ Deps: │
│- secp256k│ │- secp256k │
│ 1-android│ │ 1-jvm │
│- androidx│ │- Compose │
│ │ │ Desktop │
└──────────┘ └───────────┘
```
## Dependency Flow
```
Code in commonMain
↓ can use
Nothing (only Kotlin stdlib)
Code in jvmAndroid
↓ can use
commonMain + JVM libraries (Jackson, OkHttp)
Code in androidMain
↓ can use
commonMain + jvmAndroid + Android framework
Code in jvmMain
↓ can use
commonMain + jvmAndroid + JVM + Compose Desktop
Code in iosMain
↓ can use
commonMain + iOS platform APIs
```
## Real Examples from Amethyst
### commonMain - Pure Kotlin
**File:** `quartz/src/commonMain/.../TextNoteEvent.kt`
```kotlin
@Immutable
class TextNoteEvent(
id: HexKey,
pubKey: HexKey,
createdAt: Long,
tags: Array<Array<String>>,
content: String,
sig: HexKey,
) : BaseThreadedEvent(...) {
// Pure Kotlin - works everywhere
override fun indexableContent() = "Subject: " + subject() + "\n" + content
}
```
**Why commonMain:**
- Pure Kotlin code
- No platform APIs
- Data class with business logic
- Needed by all platforms
---
### jvmAndroid - JVM Libraries
**File:** `quartz/build.gradle.kts`
```kotlin
val jvmAndroid = create("jvmAndroid") {
dependsOn(commonMain.get())
dependencies {
// Normalizes URLs
api(libs.rfc3986.normalizer)
// Performant Parser of JSONs into Events
api(libs.jackson.module.kotlin)
// Parses URLs from Text
api(libs.url.detector)
// Websockets API
implementation(libs.okhttp)
implementation(libs.okhttpCoroutines)
}
}
jvmMain { dependsOn(jvmAndroid) } // Desktop gets Jackson, OkHttp
androidMain { dependsOn(jvmAndroid) } // Android gets Jackson, OkHttp
```
**Why jvmAndroid:**
- Jackson, OkHttp are JVM-only libraries
- Works on Android (JVM) and Desktop (JVM)
- Does NOT work on iOS (not JVM) or web (not JVM)
**Usage in code:**
```kotlin
// Can use Jackson in jvmAndroid source set
val mapper = ObjectMapper()
val event = mapper.readValue(json, Event::class.java)
```
---
### androidMain - Android Platform
**File:** `amethyst/src/main/.../MainActivity.kt`
```kotlin
class MainActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
enableEdgeToEdge() // Android API
super.onCreate(savedInstanceState)
setContent { // Compose for Android
AmethystTheme {
val accountStateViewModel: AccountStateViewModel = viewModel()
AccountScreen(accountStateViewModel)
}
}
}
}
```
**Why androidMain:**
- AppCompatActivity is Android framework
- Activity lifecycle Android-specific
- AndroidX libraries (viewModel())
**Dependencies:**
```kotlin
androidMain {
dependsOn(jvmAndroid) // Gets Jackson, OkHttp
dependencies {
implementation(libs.androidx.core.ktx)
api(libs.secp256k1.kmp.jni.android) // Android crypto
}
}
```
---
### jvmMain - Desktop Platform
**File:** `desktopApp/src/jvmMain/.../Main.kt`
```kotlin
fun main() = application {
val windowState = rememberWindowState(
width = 1200.dp,
height = 800.dp
)
Window( // Compose Desktop API
onCloseRequest = ::exitApplication,
state = windowState,
title = "Amethyst"
) {
MenuBar { // Desktop-specific
Menu("File") {
Item("New Note", shortcut = KeyShortcut(Key.N, ctrl = true))
}
}
NavigationRail { ... } // Sidebar
}
}
```
**Why jvmMain:**
- Window, MenuBar, NavigationRail are Compose Desktop
- Keyboard shortcuts desktop paradigm
- Different UX from Android (sidebar vs bottom nav)
**Dependencies:**
```kotlin
jvmMain {
dependsOn(jvmAndroid) // Gets Jackson, OkHttp
dependencies {
implementation(libs.secp256k1.kmp.jni.jvm) // Desktop crypto
implementation(compose.desktop.currentOs)
}
}
```
---
### iosMain - iOS Platform
**File:** `quartz/build.gradle.kts`
```kotlin
iosMain {
dependsOn(commonMain.get())
dependencies {
// iOS platform dependencies
}
}
val iosX64Main by getting { dependsOn(iosMain.get()) }
val iosArm64Main by getting { dependsOn(iosMain.get()) }
val iosSimulatorArm64Main by getting { dependsOn(iosMain.get()) }
```
**Why iosMain:**
- iOS platform APIs
- Native crypto (Security framework)
- Different from Android/Desktop
**Architecture targets:**
- iosX64Main: Intel simulator
- iosArm64Main: Device (iPhone, iPad)
- iosSimulatorArm64Main: Apple Silicon simulator
---
## Build Order Matters
**CRITICAL:** jvmAndroid must be defined BEFORE androidMain and jvmMain:
```kotlin
// ✅ CORRECT ORDER
val jvmAndroid = create("jvmAndroid") { ... }
jvmMain { dependsOn(jvmAndroid) }
androidMain { dependsOn(jvmAndroid) }
// ❌ WRONG - Build error
androidMain { dependsOn(jvmAndroid) } // jvmAndroid not defined yet!
val jvmAndroid = create("jvmAndroid") { ... }
```
See comment in quartz/build.gradle.kts:131:
```kotlin
// Must be defined before androidMain and jvmMain
val jvmAndroid = create("jvmAndroid") { ... }
```
## Choosing the Right Source Set
Decision flowchart:
```
Q: Where should this code go?
├─ Pure Kotlin? (no platform APIs)
│ └─ commonMain
├─ JVM library? (Jackson, OkHttp)
│ └─ jvmAndroid
├─ Android API? (Activity, Context)
│ └─ androidMain
├─ Desktop API? (Window, MenuBar)
│ └─ jvmMain
└─ iOS API? (platform.posix, Security)
└─ iosMain
```
## Future: Web/wasm Source Sets
**Not yet implemented**, but structure would be:
```
commonMain
├─→ jsMain (JavaScript/Web)
│ └─ JS-specific: DOM APIs, fetch
└─→ wasmMain (WebAssembly)
└─ wasm-specific: limited APIs
```
**Constraints:**
- Cannot use jvmAndroid (Jackson, OkHttp)
- Cannot use platform.posix
- Must use pure Kotlin or web-compatible libs (ktor, kotlinx.serialization)
## Summary Table
| Source Set | Extends | Can Use | Example Code |
|------------|---------|---------|--------------|
| commonMain | - | Kotlin stdlib only | TextNoteEvent, business logic |
| jvmAndroid | commonMain | JVM libs (Jackson, OkHttp) | JSON parsing, HTTP |
| androidMain | jvmAndroid | Android framework | Activity, ViewModel |
| jvmMain | jvmAndroid | JVM + Compose Desktop | Window, MenuBar |
| iosMain | commonMain | iOS platform | Security framework |
| iosX64Main | iosMain | Simulator (Intel) | Architecture-specific |
| iosArm64Main | iosMain | Device (ARM64) | Architecture-specific |
| jsMain | commonMain | JS/DOM | Web (future) |
| wasmMain | commonMain | wasm APIs | WebAssembly (future) |
@@ -0,0 +1,345 @@
# Target Compatibility Guide
Current targets (Android, JVM/Desktop, iOS) and future targets (web, wasm) with constraints.
## Current Primary Targets
### Android (androidMain)
**Status:** ✅ Mature, production-ready
**Runtime:** JVM (Dalvik/ART)
**Available:**
- Android framework (Activity, Context, Intent, etc.)
- AndroidX libraries (ViewModel, Navigation, etc.)
- JVM libraries via jvmAndroid (Jackson, OkHttp)
- Platform-specific crypto: secp256k1-kmp-jni-android
**Constraints:**
- Mobile UX paradigms (bottom navigation, vertical scroll)
- Touch-first interaction
- Limited screen space
- Battery/performance constraints
**Example code:**
```kotlin
// androidMain
class MainActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
// Android-specific lifecycle
}
}
```
---
### JVM / Desktop (jvmMain)
**Status:** ✅ Active development, functional
**Runtime:** JVM
**Available:**
- Pure JVM libraries
- JVM libraries via jvmAndroid (Jackson, OkHttp)
- Compose Desktop (Window, MenuBar, etc.)
- Platform-specific crypto: secp256k1-kmp-jni-jvm
**Constraints:**
- Desktop UX paradigms (sidebar, menus, keyboard shortcuts)
- Keyboard + mouse interaction
- Larger screen space
- Different navigation patterns (no back stack)
**Example code:**
```kotlin
// jvmMain
fun main() = application {
Window(
onCloseRequest = ::exitApplication,
title = "Amethyst"
) {
MenuBar { ... } // Desktop-specific
NavigationRail { ... } // Sidebar
}
}
```
---
### iOS (iosMain + architecture targets)
**Status:** ⚠️ In development, framework configured
**Runtime:** Native iOS
**Source sets:**
- iosMain (common iOS code)
- iosX64Main (Intel simulator)
- iosArm64Main (device - iPhone/iPad)
- iosSimulatorArm64Main (Apple Silicon simulator)
**Available:**
- iOS platform APIs (platform.posix, Foundation, etc.)
- Native crypto (Security framework)
- SwiftUI integration (via KMP framework)
**NOT available:**
- JVM libraries (Jackson, OkHttp)
- jvmAndroid source set
- JVM-specific APIs
**Constraints:**
- Mobile UX (similar to Android)
- Swift/Objective-C interop
- XCFramework distribution
- CocoaPods integration
**Example code:**
```kotlin
// iosMain
actual object Secp256k1Instance {
actual fun signSchnorr(...): ByteArray {
// Use iOS Security framework
}
}
```
**XCFramework setup:**
```kotlin
// quartz/build.gradle.kts
kotlin {
listOf(iosX64(), iosArm64(), iosSimulatorArm64())
.forEach { target ->
target.binaries.framework {
baseName = "quartz-kmpKit"
isStatic = true
}
}
}
```
---
## Future Targets
### Web / JavaScript (jsMain)
**Status:** ❌ Not implemented, consider for future
**Runtime:** JavaScript (browser or Node.js)
**Available:**
- Kotlin/JS stdlib
- JS/DOM APIs
- kotlinx.* libraries (serialization, coroutines, datetime)
- ktor-client (HTTP)
**NOT available:**
- ❌ JVM libraries (Jackson, OkHttp)
- ❌ jvmAndroid source set
- ❌ platform.posix (no file system access like native)
- ❌ Blocking APIs (different async model - JS event loop)
**Constraints:**
- Single-threaded event loop
- No blocking calls
- Different async patterns (Promises, async/await)
- Browser security (CORS, no file system)
**Migration path from current code:**
| Current (jvmAndroid) | Web-compatible alternative |
|---------------------|---------------------------|
| Jackson JSON | kotlinx.serialization |
| OkHttp HTTP | ktor-client |
| java.math.BigDecimal | Kotlin BigDecimal (coming) |
| Blocking I/O | Suspending functions |
**Example migration:**
```kotlin
// Current: jvmAndroid
val mapper = ObjectMapper()
val event = mapper.readValue(json, Event::class.java)
// Future: commonMain (works on web)
val json = Json { ignoreUnknownKeys = true }
val event = json.decodeFromString<Event>(jsonString)
```
---
### WebAssembly (wasmMain)
**Status:** ❌ Not implemented, experimental Kotlin/Wasm
**Runtime:** WebAssembly
**Available:**
- Kotlin/Wasm stdlib
- Limited kotlinx.* libraries
- wasm-specific APIs
**NOT available:**
- ❌ JVM libraries
- ❌ Full platform.posix
- ❌ Many kotlinx libraries (limited wasm support)
**Constraints:**
- Even more limited than JS
- Experimental Kotlin support
- Limited library ecosystem
**Recommendation:** Focus on web (jsMain) first, wasm later.
---
## Cross-Target Compatibility Matrix
| Feature | Android | JVM/Desktop | iOS | Web (JS) | wasm |
|---------|---------|-------------|-----|----------|------|
| Pure Kotlin | ✅ | ✅ | ✅ | ✅ | ✅ |
| kotlinx.coroutines | ✅ | ✅ | ✅ | ✅ | ⚠️ |
| kotlinx.serialization | ✅ | ✅ | ✅ | ✅ | ⚠️ |
| kotlinx.datetime | ✅ | ✅ | ✅ | ✅ | ⚠️ |
| ktor-client | ✅ | ✅ | ✅ | ✅ | ❌ |
| Jackson JSON | ✅ (jvmAndroid) | ✅ (jvmAndroid) | ❌ | ❌ | ❌ |
| OkHttp | ✅ (jvmAndroid) | ✅ (jvmAndroid) | ❌ | ❌ | ❌ |
| platform.posix | ❌ | ❌ | ✅ | ❌ | ⚠️ |
| Compose Multiplatform | ✅ | ✅ | ⚠️ (experimental) | ⚠️ (experimental) | ❌ |
Legend:
- ✅ Full support
- ⚠️ Limited/experimental
- ❌ Not available
---
## Future-Proofing Recommendations
### For Web Compatibility
**DO:**
- ✅ Use kotlinx.serialization instead of Jackson
- ✅ Use ktor-client instead of OkHttp
- ✅ Use kotlinx.datetime instead of java.time
- ✅ Use suspending functions (non-blocking)
- ✅ Keep business logic in commonMain
**DON'T:**
- ❌ Put JVM libraries in commonMain
- ❌ Use platform.posix for critical features
- ❌ Use blocking I/O
- ❌ Depend on threading (use coroutines)
**Example:**
```kotlin
// ❌ NOT web-compatible
// jvmAndroid
fun parseJson(json: String): Event {
val mapper = ObjectMapper() // Jackson - JVM only
return mapper.readValue(json, Event::class.java)
}
// ✅ Web-compatible
// commonMain
@Serializable
data class Event(...)
fun parseJson(json: String): Event {
return Json.decodeFromString<Event>(json) // Works everywhere
}
```
### Current Migration Priorities
**High priority:** (Needed for web)
1. Migrate Jackson → kotlinx.serialization
2. Migrate OkHttp → ktor-client
3. Move business logic to commonMain
**Medium priority:** (Nice to have)
1. Abstract date/time handling → kotlinx.datetime
2. Remove platform.posix usage where possible
3. Use suspending functions over blocking
**Low priority:** (Future optimization)
1. wasm-specific optimizations
2. Platform-specific performance tuning
---
## Platform-Specific Patterns
### Android vs iOS Differences
| Aspect | Android | iOS |
|--------|---------|-----|
| **Activity/ViewController** | Activity | UIViewController |
| **Navigation** | Compose Navigation | UINavigationController |
| **Lifecycle** | onCreate, onResume, etc. | viewDidLoad, viewWillAppear |
| **Permissions** | Runtime permissions | Info.plist + runtime |
| **Crypto** | secp256k1-android | Security framework |
| **Storage** | Room, SharedPreferences | Core Data, UserDefaults |
### Desktop vs Mobile Differences
| Aspect | Desktop | Mobile |
|--------|---------|--------|
| **Navigation** | Sidebar | Bottom nav |
| **Input** | Keyboard + mouse | Touch |
| **Screen** | Large, landscape | Small, portrait |
| **Windows** | Multi-window | Single app |
| **Shortcuts** | Keyboard shortcuts (Ctrl+N) | None |
| **Menus** | MenuBar | Bottom sheets |
---
## Testing Strategy
### Per-Target Testing
**Android:**
- Unit tests: androidTest
- Instrumented: androidInstrumentedTest
- Device/emulator testing
**Desktop:**
- Unit tests: jvmTest
- Manual desktop app testing
**iOS:**
- Unit tests: iosTest (iosX64Test, iosArm64Test, etc.)
- Simulator/device testing
**Web (future):**
- Unit tests: jsTest
- Browser testing (Selenium, Playwright)
### Shared Testing
**commonTest:**
- Business logic tests
- Pure Kotlin code
- Works on all platforms
```kotlin
// commonTest
class EventParsingTest {
@Test
fun parseTextNoteEvent() {
// Tests run on all platforms
}
}
```
---
## Summary
**Current Focus:** Android, JVM/Desktop, iOS (active development)
**Future Considerations:** Web (requires migration from Jackson/OkHttp)
**Key Decision:** Prefer kotlinx.* libraries over JVM-specific libs for future web compatibility.
@@ -0,0 +1,166 @@
#!/bin/bash
# Suggests KMP library alternatives for JVM-specific dependencies
set -e
PROJECT_ROOT="${1:-.}"
cd "$PROJECT_ROOT"
echo "=== KMP Dependency Suggestions ==="
echo
# Colors
YELLOW='\033[1;33m'
GREEN='\033[0;32m'
BLUE='\033[0;34m'
NC='\033[0m'
SUGGESTIONS_FOUND=0
# Check for Jackson (suggest kotlinx.serialization)
echo "📦 Checking for Jackson JSON..."
if grep -r "jackson" */build.gradle.kts 2>/dev/null | grep -q "implementation\|api"; then
echo -e "${YELLOW}⚠ Found Jackson dependency${NC}"
echo " Current: Jackson (JVM-only)"
echo -e " ${GREEN}Suggest: kotlinx.serialization${NC} (works on all platforms)"
echo
echo " Migration:"
echo " // Remove:"
echo " api(libs.jackson.module.kotlin)"
echo
echo " // Add to commonMain:"
echo " implementation(libs.kotlinx.serialization.json)"
echo
echo " // Code change:"
echo " // Before (Jackson):"
echo " val mapper = ObjectMapper()"
echo " val event = mapper.readValue(json, Event::class.java)"
echo
echo " // After (kotlinx.serialization):"
echo " @Serializable"
echo " data class Event(...)"
echo " val event = Json.decodeFromString<Event>(json)"
echo
SUGGESTIONS_FOUND=$((SUGGESTIONS_FOUND + 1))
else
echo -e "${GREEN}✓ Not using Jackson (or already using kotlinx.serialization)${NC}"
fi
# Check for OkHttp (suggest ktor)
echo
echo "📦 Checking for OkHttp..."
if grep -r "okhttp" */build.gradle.kts 2>/dev/null | grep -q "implementation\|api"; then
echo -e "${YELLOW}⚠ Found OkHttp dependency${NC}"
echo " Current: OkHttp (JVM-only)"
echo -e " ${GREEN}Suggest: ktor-client${NC} (works on all platforms)"
echo
echo " Migration:"
echo " // Remove:"
echo " implementation(libs.okhttp)"
echo
echo " // Add to commonMain:"
echo " implementation(libs.ktor.client.core)"
echo " // Platform-specific engines:"
echo " // androidMain: implementation(libs.ktor.client.android)"
echo " // jvmMain: implementation(libs.ktor.client.cio)"
echo " // iosMain: implementation(libs.ktor.client.darwin)"
echo
echo " // Code change:"
echo " // Before (OkHttp):"
echo " val client = OkHttpClient()"
echo " val request = Request.Builder().url(url).build()"
echo " val response = client.newCall(request).execute()"
echo
echo " // After (ktor):"
echo " val client = HttpClient()"
echo " val response: String = client.get(url)"
echo
SUGGESTIONS_FOUND=$((SUGGESTIONS_FOUND + 1))
else
echo -e "${GREEN}✓ Not using OkHttp (or already using ktor)${NC}"
fi
# Check for java.time (suggest kotlinx.datetime)
echo
echo "📦 Checking for java.time usage..."
if find */src -name "*.kt" 2>/dev/null | xargs grep -l "import java.time\." >/dev/null 2>&1; then
echo -e "${YELLOW}⚠ Found java.time imports${NC}"
echo " Current: java.time (JVM-only)"
echo -e " ${GREEN}Suggest: kotlinx.datetime${NC} (works on all platforms)"
echo
echo " Migration:"
echo " // Add to commonMain:"
echo " implementation(libs.kotlinx.datetime)"
echo
echo " // Code change:"
echo " // Before (java.time):"
echo " import java.time.Instant"
echo " val now = Instant.now()"
echo
echo " // After (kotlinx.datetime):"
echo " import kotlinx.datetime.Clock"
echo " val now = Clock.System.now()"
echo
SUGGESTIONS_FOUND=$((SUGGESTIONS_FOUND + 1))
else
echo -e "${GREEN}✓ Not using java.time (or already using kotlinx.datetime)${NC}"
fi
# Check for java.math.BigDecimal
echo
echo "📦 Checking for java.math.BigDecimal usage..."
if find */src -name "*.kt" 2>/dev/null | xargs grep -l "import java.math.BigDecimal" >/dev/null 2>&1; then
echo -e "${YELLOW}⚠ Found java.math.BigDecimal imports${NC}"
echo " Current: java.math.BigDecimal (JVM-only)"
echo -e " ${BLUE}Note:${NC} KMP BigDecimal not yet in stable kotlinx"
echo
echo " Options:"
echo " 1. Use expect/actual (current approach in quartz)"
echo " 2. Wait for kotlinx.decimal (proposal stage)"
echo " 3. Use third-party KMP library (e.g., bignum)"
echo
SUGGESTIONS_FOUND=$((SUGGESTIONS_FOUND + 1))
else
echo -e "${GREEN}✓ Not using java.math.BigDecimal directly${NC}"
fi
# Check for platform.posix usage
echo
echo "📦 Checking for platform.posix usage..."
if find */src/commonMain -name "*.kt" 2>/dev/null | xargs grep -l "import platform.posix\." >/dev/null 2>&1; then
echo -e "${YELLOW}⚠ Found platform.posix in commonMain${NC}"
echo " Current: platform.posix (native platforms only, not web)"
echo -e " ${GREEN}Suggest:${NC} Abstract file I/O with expect/actual"
echo
echo " For web compatibility:"
echo " - iOS/Native: platform.posix"
echo " - Web: Use kotlinx-io or ktor file APIs"
echo " - Create expect/actual for file operations"
echo
SUGGESTIONS_FOUND=$((SUGGESTIONS_FOUND + 1))
else
echo -e "${GREEN}✓ Not using platform.posix in commonMain${NC}"
fi
# Summary
echo
echo "=== Summary ==="
if [ "$SUGGESTIONS_FOUND" -eq 0 ]; then
echo -e "${GREEN}✓ No JVM-specific dependencies found!${NC}"
echo " Your code is ready for web/wasm targets."
else
echo -e "${YELLOW}Found $SUGGESTIONS_FOUND suggestion(s) for KMP alternatives${NC}"
echo
echo "Priority recommendations:"
echo " 1. ${GREEN}High:${NC} Jackson → kotlinx.serialization (enables web support)"
echo " 2. ${GREEN}High:${NC} OkHttp → ktor-client (enables web support)"
echo " 3. ${GREEN}Medium:${NC} java.time → kotlinx.datetime"
echo " 4. ${GREEN}Low:${NC} Consider web compatibility for platform.posix usage"
echo
echo "Resources:"
echo " - kotlinx.serialization: https://github.com/Kotlin/kotlinx.serialization"
echo " - ktor: https://ktor.io/docs/client.html"
echo " - kotlinx.datetime: https://github.com/Kotlin/kotlinx-datetime"
fi
exit 0
@@ -0,0 +1,126 @@
#!/bin/bash
# Validates KMP source set structure and detects common issues
set -e
PROJECT_ROOT="${1:-.}"
cd "$PROJECT_ROOT"
echo "=== Validating KMP Structure ==="
echo
# Colors for output
RED='\033[0;31m'
YELLOW='\033[1;33m'
GREEN='\033[0;32m'
NC='\033[0m' # No Color
ISSUES_FOUND=0
# Check 1: jvmAndroid defined before androidMain/jvmMain
echo "📋 Checking source set definition order..."
if [ -f "quartz/build.gradle.kts" ]; then
jvmandroid_line=$(grep -n "val jvmAndroid = create" quartz/build.gradle.kts | cut -d: -f1)
android_line=$(grep -n "androidMain {" quartz/build.gradle.kts | cut -d: -f1)
jvm_line=$(grep -n "jvmMain {" quartz/build.gradle.kts | cut -d: -f1)
if [ -n "$jvmandroid_line" ] && [ -n "$android_line" ] && [ -n "$jvm_line" ]; then
if [ "$jvmandroid_line" -lt "$android_line" ] && [ "$jvmandroid_line" -lt "$jvm_line" ]; then
echo -e "${GREEN}${NC} jvmAndroid defined before androidMain and jvmMain"
else
echo -e "${RED}${NC} jvmAndroid must be defined BEFORE androidMain and jvmMain"
ISSUES_FOUND=$((ISSUES_FOUND + 1))
fi
fi
fi
# Check 2: Platform code in commonMain (Android imports)
echo
echo "📋 Checking for platform code in commonMain..."
android_imports_in_common=$(find */src/commonMain -name "*.kt" 2>/dev/null | xargs grep -l "^import android\." || true)
if [ -n "$android_imports_in_common" ]; then
echo -e "${RED}${NC} Found Android imports in commonMain:"
echo "$android_imports_in_common" | sed 's/^/ /'
echo " Fix: Move to androidMain or create expect/actual"
ISSUES_FOUND=$((ISSUES_FOUND + 1))
else
echo -e "${GREEN}${NC} No Android imports in commonMain"
fi
# Check 3: JVM libraries in commonMain (Jackson, OkHttp)
echo
echo "📋 Checking for JVM libraries in commonMain..."
jvm_imports_in_common=$(find */src/commonMain -name "*.kt" 2>/dev/null | xargs grep -l "^import com.fasterxml.jackson\|^import okhttp3\." || true)
if [ -n "$jvm_imports_in_common" ]; then
echo -e "${RED}${NC} Found JVM library imports in commonMain:"
echo "$jvm_imports_in_common" | sed 's/^/ /'
echo " Fix: Move to jvmAndroid or migrate to kotlinx.serialization/ktor"
ISSUES_FOUND=$((ISSUES_FOUND + 1))
else
echo -e "${GREEN}${NC} No JVM library imports in commonMain"
fi
# Check 4: Unmatched expect/actual declarations
echo
echo "📋 Checking expect/actual pairs..."
expect_files=$(find */src/commonMain -name "*.kt" 2>/dev/null | xargs grep -l "^expect " || true)
if [ -n "$expect_files" ]; then
for file in $expect_files; do
# Extract declarations
expects=$(grep "^expect \(class\|object\|fun\|interface\)" "$file" | sed 's/expect //' | awk '{print $2}' | sed 's/[({].*$//')
# Check for actuals in platform source sets
for expect_name in $expects; do
actual_count=0
for platform in androidMain jvmMain iosMain; do
platform_dir=$(dirname "$file" | sed "s/commonMain/$platform/")
platform_file="${platform_dir}/$(basename "$file")"
if [ -f "$platform_file" ] && grep -q "actual.*$expect_name" "$platform_file"; then
actual_count=$((actual_count + 1))
fi
done
if [ "$actual_count" -eq 0 ]; then
echo -e "${YELLOW}${NC} No actual implementations found for: $expect_name in $file"
echo " Check: androidMain, jvmMain, iosMain"
ISSUES_FOUND=$((ISSUES_FOUND + 1))
fi
done
done
else
echo -e "${GREEN}${NC} No expect declarations to validate"
fi
# Check 5: Duplicated business logic across platforms
echo
echo "📋 Checking for potential code duplication..."
# This is a heuristic check - look for similar function names in different platform source sets
common_functions=$(find */src/commonMain -name "*.kt" 2>/dev/null | xargs grep -h "^fun " | awk '{print $2}' | sed 's/[({<].*$//' | sort -u || true)
if [ -n "$common_functions" ]; then
for func in $common_functions; do
android_count=$(find */src/androidMain -name "*.kt" 2>/dev/null | xargs grep -l "^fun $func" | wc -l)
jvm_count=$(find */src/jvmMain -name "*.kt" 2>/dev/null | xargs grep -l "^fun $func" | wc -l)
if [ "$android_count" -gt 0 ] && [ "$jvm_count" -gt 0 ]; then
echo -e "${YELLOW}${NC} Function '$func' found in both androidMain and jvmMain"
echo " Consider: Move to commonMain or jvmAndroid if truly shared"
fi
done
fi
# Summary
echo
echo "=== Summary ==="
if [ "$ISSUES_FOUND" -eq 0 ]; then
echo -e "${GREEN}✓ All checks passed!${NC}"
exit 0
else
echo -e "${RED}✗ Found $ISSUES_FOUND issue(s)${NC}"
echo
echo "Common fixes:"
echo " 1. Platform code in commonMain → Move to androidMain or create expect/actual"
echo " 2. JVM libraries in commonMain → Move to jvmAndroid or migrate to kotlinx.*"
echo " 3. Missing actual implementations → Implement in all target platforms"
echo " 4. Duplicated logic → Move to commonMain or jvmAndroid"
exit 1
fi
+551
View File
@@ -0,0 +1,551 @@
---
name: nostr-expert
description: Nostr protocol implementation patterns in Quartz (AmethystMultiplatform's KMP Nostr library). Use when working with: (1) Nostr events (creating, parsing, signing), (2) Event kinds and tags, (3) NIP implementations (57 NIPs in quartz/), (4) Event builders and TagArrayBuilder DSL, (5) Nostr cryptography (secp256k1, NIP-44 encryption), (6) Relay communication patterns, (7) Bech32 encoding (npub, nsec, note, nevent). Complements nostr-protocol agent (NIP specs) - this skill provides Quartz codebase patterns and implementation details.
---
# Nostr Protocol Expert (Quartz Implementation)
Practical patterns for working with Nostr in Quartz, AmethystMultiplatform's KMP Nostr library.
## When to Use This Skill
- Implementing Nostr event types (TextNote, Reaction, Zap, etc.)
- Creating/parsing events with TagArrayBuilder DSL
- Working with event kinds and tags
- Finding NIP implementations in quartz/ codebase
- Nostr cryptography (secp256k1 signing, NIP-44 encryption)
- Bech32 encoding/decoding (npub, nsec, note formats)
- Event validation and verification
**For NIP specifications** → Use `nostr-protocol` agent
**For Quartz implementation** → Use this skill
## Quartz Architecture
Quartz organizes code by NIP number:
```
quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/
├── nip01Core/ # Core protocol (Event, Kind, Tags)
├── nip04Dm/ # Legacy DMs (deprecated)
├── nip10Notes/ # Text notes with threading
├── nip17Dm/ # Private DMs (gift wrap)
├── nip19Bech32/ # Bech32 encoding
├── nip44Encryption/ # Modern encryption (ChaCha20)
├── nip57Zaps/ # Lightning zaps
├── ... (57 NIPs total)
└── experimental/ # Draft NIPs
```
**Pattern**: `nip##<Name>/` directories contain event classes, tags, and utilities for that NIP.
**Find implementations**: Use `scripts/nip-lookup.sh <nip-number>` or see `references/nip-catalog.md`.
## Event Anatomy
### Core Structure
```kotlin
@Immutable
open class Event(
val id: HexKey, // SHA-256 hash of serialized event
val pubKey: HexKey, // Author's public key (32 bytes hex)
val createdAt: Long, // Unix timestamp
val kind: Kind, // Event kind (Int typealias)
val tags: TagArray, // Array of tag arrays
val content: String, // Event content
val sig: HexKey, // Schnorr signature (64 bytes hex)
) : IEvent
```
**Key insight**: `Event` is the base class. Specific event types (TextNoteEvent, ReactionEvent) extend it and add parsing/helper methods.
### Kind Classification
```kotlin
typealias Kind = Int
fun Kind.isEphemeral() = this in 20000..29999 // Not stored
fun Kind.isReplaceable() = this == 0 || this == 3 || this in 10000..19999
fun Kind.isAddressable() = this in 30000..39999 // Replaceable + has d-tag
fun Kind.isRegular() = this in 1000..9999 // Stored, not replaced
```
**Pattern**: Kind determines event lifecycle and replaceability.
## Creating Events
### EventTemplate Pattern
```kotlin
fun eventTemplate(
kind: Kind,
content: String,
tags: TagArray = emptyArray()
): EventTemplate
```
**Usage**:
```kotlin
val template = eventTemplate(
kind = 1, // Text note
content = "Hello Nostr!",
tags = tagArray {
add(arrayOf("subject", "Greeting"))
}
)
// Sign with a signer
val signedEvent = signer.sign(template)
```
**Why templates?** Separates event data from signing. Templates can be signed by different signers (local keys, remote signers, hardware wallets).
### TagArrayBuilder DSL
```kotlin
fun <T : Event> tagArray(
initializer: TagArrayBuilder<T>.() -> Unit
): TagArray
```
**Methods**:
- `add(tag)` - Append tag
- `addFirst(tag)` - Prepend tag (for ordering)
- `addUnique(tag)` - Replace all tags with this name
- `remove(tagName)` - Remove by name
- `addAll(tags)` - Bulk add
**Example**:
```kotlin
val tags = tagArray<TextNoteEvent> {
add(arrayOf("e", replyToEventId, "", "reply"))
add(arrayOf("p", authorPubkey))
addUnique(arrayOf("subject", "Re: Hello"))
add(arrayOf("content-warning", "spoilers"))
}
```
**Pattern**: Fluent DSL for building tag arrays with validation and deduplication.
## Common Event Types
### TextNoteEvent (kind 1)
```kotlin
class TextNoteEvent : BaseThreadedEvent
```
**Creating**:
```kotlin
val note = eventTemplate(
kind = 1,
content = "Hello world!",
tags = tagArray {
add(arrayOf("subject", "First post"))
}
)
```
**Parsing**:
```kotlin
val event: TextNoteEvent = ...
val subject = event.subject() // Extension from nip14Subject
val mentions = event.mentions() // List of p-tags
val quotedEvents = event.quotes() // List of q-tags
```
### ReactionEvent (kind 7)
```kotlin
fun createReaction(
targetEvent: Event,
emoji: String = "+"
): EventTemplate {
return eventTemplate(
kind = 7,
content = emoji,
tags = tagArray {
add(arrayOf("e", targetEvent.id))
add(arrayOf("p", targetEvent.pubKey))
}
)
}
```
### MetadataEvent (kind 0)
```kotlin
data class UserMetadata(
val name: String?,
val displayName: String?,
val picture: String?,
val banner: String?,
val about: String?,
// ... more fields
)
fun createMetadata(metadata: UserMetadata): EventTemplate {
return eventTemplate(
kind = 0,
content = metadata.toJson() // Serialize to JSON
)
}
```
### Addressable Events (kinds 30000-40000)
```kotlin
fun createArticle(
slug: String,
title: String,
content: String
): EventTemplate {
return eventTemplate(
kind = 30023,
content = content,
tags = tagArray {
addUnique(arrayOf("d", slug)) // Unique identifier
add(arrayOf("title", title))
add(arrayOf("published_at", "${TimeUtils.now()}"))
}
)
}
```
**Key**: `d-tag` makes it addressable. Events with same kind + pubkey + d-tag replace each other.
## Tag Patterns
Tags are `Array<String>` with pattern `[name, value, ...optionalParams]`.
### Core Tags
**e-tag** (event reference):
```kotlin
add(arrayOf("e", eventId, relayHint, marker))
// marker: "reply", "root", "mention"
```
**p-tag** (pubkey reference):
```kotlin
add(arrayOf("p", pubkey, relayHint))
```
**a-tag** (addressable event):
```kotlin
add(arrayOf("a", "$kind:$pubkey:$dtag", relayHint))
```
**d-tag** (identifier for addressable events):
```kotlin
addUnique(arrayOf("d", "unique-slug"))
```
### Tag Extensions
```kotlin
// Find tags
event.tags.tagValue("subject") // First subject tag value
event.tags.allTags("p") // All p-tags
event.tags.tagValues("e") // All e-tag values
// Parse structured tags
event.tags.mapNotNull(ETag::parse) // Parse as ETag objects
```
For comprehensive tag patterns, see `references/tag-patterns.md`.
## Threading (NIP-10)
```kotlin
fun createReply(
original: TextNoteEvent,
content: String
): EventTemplate {
return eventTemplate(
kind = 1,
content = content,
tags = tagArray {
// Reply marker
add(arrayOf("e", original.id, "", "reply"))
// Root marker (original's root, or original itself)
original.rootEvent()?.let {
add(arrayOf("e", it.id, "", "root"))
} ?: add(arrayOf("e", original.id, "", "root"))
// Tag author
add(arrayOf("p", original.pubKey))
// Tag all mentioned users
original.mentions().forEach {
add(arrayOf("p", it))
}
}
)
}
```
**Pattern**: `reply` and `root` markers establish thread hierarchy.
## Cryptography
### Signing (secp256k1)
```kotlin
interface ISigner {
suspend fun sign(template: EventTemplate): Event
}
// Local key signing
class LocalSigner(private val privateKey: ByteArray) : ISigner {
override suspend fun sign(template: EventTemplate): Event {
val id = template.generateId()
val sig = Secp256k1.sign(id, privateKey)
return Event(id, pubKey, createdAt, kind, tags, content, sig)
}
}
```
**Pattern**: Signers abstract key management. Can be local, remote (NIP-46), or hardware.
### Encryption (NIP-44)
```kotlin
// Modern encryption (ChaCha20-Poly1305)
object Nip44v2 {
fun encrypt(plaintext: String, privateKey: ByteArray, pubKey: HexKey): String
fun decrypt(ciphertext: String, privateKey: ByteArray, pubKey: HexKey): String
}
// Usage
val encrypted = Nip44v2.encrypt(
plaintext = "Secret message",
privateKey = myPrivateKey,
pubKey = recipientPubKey
)
val decrypted = Nip44v2.decrypt(
ciphertext = encrypted,
privateKey = myPrivateKey,
pubKey = senderPubKey
)
```
**Pattern**: Elliptic curve Diffie-Hellman + ChaCha20-Poly1305 AEAD.
### NIP-04 (Deprecated)
```kotlin
// Legacy encryption (NIP-04, deprecated for NIP-44)
object Nip04 {
fun encrypt(msg: String, privateKey: ByteArray, pubKey: HexKey): String
fun decrypt(msg: String, privateKey: ByteArray, pubKey: HexKey): String
}
```
**Note**: Use NIP-44 (Nip44v2) for new implementations. NIP-04 has security issues.
## Bech32 Encoding (NIP-19)
```kotlin
object Nip19 {
// Encode
fun npubEncode(pubkey: HexKey): String // npub1...
fun nsecEncode(privateKey: ByteArray): String // nsec1...
fun noteEncode(eventId: HexKey): String // note1...
fun neventEncode(eventId: HexKey, relays: List<String> = emptyList()): String
fun nprofileEncode(pubkey: HexKey, relays: List<String> = emptyList()): String
fun naddrEncode(kind: Int, pubkey: HexKey, dTag: String, relays: List<String> = emptyList()): String
// Decode
fun decode(bech32: String): Nip19Result
}
sealed class Nip19Result {
data class NPub(val hex: HexKey) : Nip19Result()
data class NSec(val hex: HexKey) : Nip19Result()
data class Note(val hex: HexKey) : Nip19Result()
data class NEvent(val hex: HexKey, val relays: List<String>) : Nip19Result()
data class NProfile(val hex: HexKey, val relays: List<String>) : Nip19Result()
data class NAddr(val kind: Int, val pubkey: HexKey, val dTag: String, val relays: List<String>) : Nip19Result()
}
```
**Usage**:
```kotlin
// Encode
val npub = Nip19.npubEncode(pubkeyHex)
// Output: "npub1..."
// Decode
when (val result = Nip19.decode(npub)) {
is Nip19Result.NPub -> println("Pubkey: ${result.hex}")
is Nip19Result.NEvent -> println("Event: ${result.hex}, relays: ${result.relays}")
else -> println("Other type")
}
```
## Event Validation
```kotlin
fun Event.verify(): Boolean {
// 1. Verify ID matches content hash
val computedId = generateId()
if (id != computedId) return false
// 2. Verify signature
return Secp256k1.verify(id, sig, pubKey)
}
fun Event.generateId(): HexKey {
val serialized = serializeForId() // JSON array format
return sha256(serialized)
}
```
**Pattern**: Always verify events from untrusted sources (relays).
## Common Workflows
### Publishing an Event
```kotlin
suspend fun publishNote(content: String, signer: ISigner, relays: List<String>) {
// 1. Create template
val template = eventTemplate(kind = 1, content = content)
// 2. Sign
val event = signer.sign(template)
// 3. Verify (optional but recommended)
require(event.verify()) { "Signature verification failed" }
// 4. Publish to relays
relays.forEach { relay ->
relayClient.send(relay, event)
}
}
```
### Querying Events
```kotlin
// Subscription filter
data class Filter(
val ids: List<HexKey>? = null,
val authors: List<HexKey>? = null,
val kinds: List<Kind>? = null,
val since: Long? = null,
val until: Long? = null,
val limit: Int? = null,
val tags: Map<String, List<String>>? = null // e.g., {"#e": [eventId], "#p": [pubkey]}
)
// Usage
val filter = Filter(
authors = listOf(userPubkey),
kinds = listOf(1), // Text notes only
limit = 50
)
relayClient.subscribe(relay, filter) { event ->
// Handle incoming events
}
```
### Creating a Zap (NIP-57)
```kotlin
fun createZapRequest(
targetEvent: Event,
amountSats: Long,
comment: String = ""
): EventTemplate {
return eventTemplate(
kind = 9734, // Zap request
content = comment,
tags = tagArray {
add(arrayOf("e", targetEvent.id))
add(arrayOf("p", targetEvent.pubKey))
add(arrayOf("amount", "${amountSats * 1000}")) // millisats
add(arrayOf("relays", "wss://relay1.com", "wss://relay2.com"))
}
)
}
```
### Gift-Wrapped DMs (NIP-17)
```kotlin
fun createGiftWrappedDM(
recipientPubkey: HexKey,
message: String,
signer: ISigner
): Event {
// 1. Create sealed gossip (kind 14)
val sealedGossip = createSealedGossip(message, recipientPubkey, signer)
// 2. Wrap in gift wrap (kind 1059)
return createGiftWrap(sealedGossip, recipientPubkey, signer)
}
```
**Pattern**: Double encryption + random ephemeral keys for metadata protection.
## Finding NIPs
Use the bundled script:
```bash
# Find by NIP number
scripts/nip-lookup.sh 44
# Search by term
scripts/nip-lookup.sh encryption
scripts/nip-lookup.sh "gift wrap"
```
Or see `references/nip-catalog.md` for complete catalog.
## Bundled Resources
- **references/nip-catalog.md** - All 57 NIPs with package locations and key files
- **references/event-hierarchy.md** - Event class hierarchy, kind classifications, common types
- **references/tag-patterns.md** - Tag structure, TagArrayBuilder DSL, common tag types, parsing patterns
- **scripts/nip-lookup.sh** - Find NIP implementations by number or search term
## Quick Reference
| Task | Pattern | Location |
|------|---------|----------|
| Create event | `eventTemplate(kind, content, tags)` | nip01Core/signers/ |
| Build tags | `tagArray { add(...) }` | nip01Core/core/ |
| Sign event | `signer.sign(template)` | nip01Core/signers/ |
| Verify signature | `event.verify()` | nip01Core/core/ |
| Encrypt (NIP-44) | `Nip44v2.encrypt(...)` | nip44Encryption/ |
| Bech32 encode | `Nip19.npubEncode(...)` | nip19Bech32/ |
| Find NIP | `scripts/nip-lookup.sh <number>` | - |
## Common Event Kinds
| Kind | Type | NIP | Package |
|------|------|-----|---------|
| 0 | Metadata | 01 | nip01Core/ |
| 1 | Text note | 01, 10 | nip10Notes/ |
| 3 | Contact list | 02 | nip02FollowList/ |
| 5 | Deletion | 09 | nip09Deletions/ |
| 7 | Reaction | 25 | nip25Reactions/ |
| 1059 | Gift wrap | 59 | nip59Giftwrap/ |
| 9734 | Zap request | 57 | nip57Zaps/ |
| 9735 | Zap receipt | 57 | nip57Zaps/ |
| 10002 | Relay list | 65 | nip65RelayList/ |
| 30023 | Long-form content | 23 | nip23LongContent/ |
## Related Skills
- **nostr-protocol** - NIP specifications and protocol details
- **kotlin-expert** - Kotlin patterns (@Immutable, sealed classes, DSLs)
- **kotlin-coroutines** - Async patterns for relay communication
- **kotlin-multiplatform** - KMP patterns, expect/actual in Quartz
@@ -0,0 +1,293 @@
# Event Hierarchy & Structure
## Core Hierarchy
```
IEvent (empty interface)
└── Event (@Immutable base class)
├── BaseAddressableEvent (replaceable + addressable, has d-tag)
│ ├── BaseReplaceableEvent (kinds 10000-20000, FIXED_D_TAG = "")
│ └── [Specific addressable events - 30000-40000]
└── [Specific event implementations - all other kinds]
```
## Event Base Class
**Location**: `/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/core/Event.kt`
```kotlin
@Immutable
open class Event(
val id: HexKey, // SHA-256 hash of serialized event
val pubKey: HexKey, // Author's public key (32 bytes hex)
val createdAt: Long, // Unix timestamp
val kind: Kind, // Event kind (Int typealias)
val tags: TagArray, // Array of tag arrays
val content: String, // Event content
val sig: HexKey, // schnorr signature (64 bytes hex)
) : IEvent, OptimizedSerializable
```
## Kind Classification
```kotlin
typealias Kind = Int
fun Kind.isEphemeral() = this in 20000..29999
fun Kind.isReplaceable() = this == 0 || this == 3 || this in 10000..19999
fun Kind.isAddressable() = this in 30000..39999
fun Kind.isRegular() = this in 1000..9999
```
## Common Event Types
### Text Note (kind 1)
```kotlin
class TextNoteEvent(...) : BaseThreadedEvent(...),
EventHintProvider, AddressHintProvider, PubKeyHintProvider, SearchableEvent
// Threading support via markers: reply, root, mention
fun replyTo(): List<Note> // Direct reply targets
fun root(): Note? // Root of thread
```
### Metadata (kind 0)
```kotlin
class MetadataEvent(...) : BaseAddressableEvent(...)
// Replaceable: newest version overwrites old
// d-tag automatically set to "" for kind 0
fun name(): String?
fun displayName(): String?
fun picture(): String?
fun about(): String?
fun lnAddress(): String?
```
### Reaction (kind 7)
```kotlin
class ReactionEvent(...) : Event(...)
companion object {
const val LIKE = "+"
const val DISLIKE = "-"
fun like(reactedTo: EventHintBundle<Event>, ...)
fun dislike(reactedTo: EventHintBundle<Event>, ...)
}
```
### Zap Request/Receipt (kinds 9734, 9735)
```kotlin
class LnZapRequestEvent(...) : Event(...)
// Created by client, sent to Lightning Address
class LnZapEvent(...) : Event(...)
// Receipt from LSP, contains bolt11 + embedded zap request
val zapRequest: LnZapRequestEvent? by lazy { containedPost() }
val amount: BigDecimal? by lazy { /* parse from bolt11 */ }
```
### Long-Form Content (kind 30023)
```kotlin
class LongTextNoteEvent(...) : BaseAddressableEvent(...)
// Blog posts, articles
// Addressable via kind:pubkey:d-tag
```
### Lists (kinds 10000-30004)
```kotlin
sealed class PeopleListEvent : BaseAddressableEvent {
object MuteList : PeopleListEvent(10000)
object PinList : PeopleListEvent(10001)
object BookmarkList : PeopleListEvent(10003)
// ... 18 list types total
}
```
## Event Interfaces
### Hint Providers
Events can implement interfaces to optimize relay queries:
```kotlin
interface EventHintProvider {
fun taggedEventIds(): Set<HexKey>
fun taggedEventRelays(): Map<HexKey, Set<NormalizedRelayUrl>>
}
interface PubKeyHintProvider {
fun taggedPubKeys(): Set<HexKey>
fun taggedPubKeyRelays(): Map<HexKey, Set<NormalizedRelayUrl>>
}
interface AddressHintProvider {
fun taggedAddresses(): Set<Address>
fun taggedAddressRelays(): Map<Address, Set<NormalizedRelayUrl>>
}
interface SearchableEvent {
fun subject(): String?
fun isContentEncoded(): Boolean
}
```
## Event Building Pattern
### DSL Builder
```kotlin
TextNoteEvent.build(
note = "Hello Nostr",
replyingTo = eventBundle,
createdAt = TimeUtils.now()
) {
pTag(pubKey, relayHint) // Tag person
eTag(eventId, relayHint, "reply") // Tag event with marker
hashtag("nostr") // Add hashtag
alt("A short note") // Alt text
}
```
### Event Template (Low-level)
```kotlin
suspend fun eventTemplate(
kind: Kind,
content: String,
createdAt: Long,
initializer: TagArrayBuilder.() -> Unit
): EventTemplate {
val tags = TagArrayBuilder().apply(initializer).build()
return EventTemplate(kind, tags, content, createdAt)
}
// Sign with signer
val template = eventTemplate(1, "Hello", now()) { pTag(pubkey) }
val signedEvent = signer.sign(template)
```
## Addressable vs Regular Events
| Feature | Regular Event | Addressable Event |
|---------|---------------|-------------------|
| **Identifier** | Event ID (SHA-256 hash) | Address (kind:pubkey:d-tag) |
| **Replaceability** | Immutable | Newest replaces old |
| **d-tag** | Optional | Required |
| **Lookup** | By event ID | By address |
| **Example** | Text note (kind 1) | Metadata (kind 0), Long-form (kind 30023) |
```kotlin
// Regular event address
note = LocalCache.getNoteIfExists(eventId)
// Addressable event address
address = Address(kind = 30023, pubkey = authorHex, dTag = "my-article")
note = LocalCache.getAddressableNoteIfExists(address)
```
## Event Validation
```kotlin
// Verify event ID matches computed hash
fun Event.verifyId(): Boolean =
EventHasher.hashIdCheck(id, pubKey, createdAt, kind, tags, content)
// Verify signature
fun Event.verifySignature(): Boolean =
Nip01.verify(Hex.decode(sig), Hex.decode(id), Hex.decode(pubKey))
// Complete verification
fun Event.checkSignature() {
if (!verifyId()) throw Exception("ID mismatch")
if (!verifySignature()) throw Exception("Bad signature!")
}
```
## Event Serialization
```kotlin
// To JSON (for transmission/signing)
fun Event.toJson(): String = OptimizedJsonMapper.toJson(this)
// From JSON
fun Event.fromJson(json: String): Event = OptimizedJsonMapper.fromJson(json)
// Event ID generation (SHA-256 of canonical JSON)
fun EventHasher.hashId(
pubKey: HexKey,
createdAt: Long,
kind: Kind,
tags: TagArray,
content: String
): HexKey {
val serialized = """[0,"$pubKey",$createdAt,$kind,${tags.toJson()},"$content"]"""
return sha256(serialized.encodeToByteArray()).toHexKey()
}
```
## Event Lifecycle in LocalCache
```
Event received from relay
LocalCache.consume(event, relay, wasVerified)
getOrCreateNote(event.id) or getOrCreateAddressableNote(address)
justVerify(event) → checkSignature()
note.loadEvent(event, author, replyTo)
Update indices (replies, reactions, boosts)
refreshNewNoteObservers(note) → emit to SharedFlow
UI updates
```
## Common Event Patterns
### Reply Threading
```kotlin
// Root event (top of thread)
val rootEvent = TextNoteEvent.build("Thread root") { }
// Reply to root
val reply1 = TextNoteEvent.build("First reply", replyingTo = rootEvent) {
// Automatically adds:
// ["e", <root_id>, <relay>, "root"]
// ["e", <root_id>, <relay>, "reply"]
}
// Reply to reply (nested)
val reply2 = TextNoteEvent.build("Nested reply", replyingTo = reply1) {
// Automatically adds:
// ["e", <root_id>, <relay>, "root"]
// ["e", <reply1_id>, <relay>, "reply"]
}
```
### Replaceable Events
```kotlin
// Metadata update (kind 0) - newest wins
val metadata1 = MetadataEvent.createNew(name = "Alice", picture = "url1")
Thread.sleep(1000)
val metadata2 = MetadataEvent.createNew(name = "Alice Updated", picture = "url2")
// LocalCache keeps only metadata2 (higher createdAt)
```
### Event Deletion
```kotlin
// Delete events
val deletion = DeletionEvent.create(
deleteEvents = listOf(eventId1, eventId2),
reason = "Spam",
signer = signer
)
// LocalCache marks events as deleted, but doesn't remove (for verification)
```
## 63+ Event Classes
Full list at `/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip*/` - one class per event type across 60+ NIP implementations.
@@ -0,0 +1,179 @@
# NIP Catalog: 60 Standard + 8 Experimental NIPs in Quartz
## Standard NIPs by Category
### Core/Basic Protocol
| NIP | Directory | Key Files | Description |
|-----|-----------|-----------|-------------|
| 01 | `nip01Core/` | Event.kt, Kind.kt, Tag.kt | Core protocol, event structure, kinds, tags |
| 02 | `nip02FollowList/` | ContactListEvent.kt | Follow/contact lists (kind 3) |
| 03 | `nip03Timestamp/` | OpenTimestampsAttestation.kt | Timestamps |
| 04 | `nip04Dm/` | EncryptedDmEvent.kt | Legacy encrypted DMs (deprecated for NIP-17) |
| 05 | `nip05DnsIdentifiers/` | Nip05Verifier.kt | DNS-based verification |
| 06 | `nip06KeyDerivation/` | Mnemonic-related | BIP-39 key derivation |
| 09 | `nip09Deletions/` | DeletionEvent.kt | Event deletion requests (kind 5) |
| 11 | `nip11RelayInfo/` | RelayInformation.kt | Relay metadata |
| 13 | `nip13Pow/` | ProofOfWork.kt | Proof of work |
| 14 | `nip14Subject/` | Subject tags | Subject tags for text notes |
| 17 | `nip17Dm/` | GiftWrapEvent.kt, SealedGossipEvent.kt | Private DMs (replacem
ent for NIP-04) |
| 21 | `nip21UriScheme/` | URI scheme (`nostr:`) | URI scheme parsing |
| 42 | `nip42RelayAuth/` | RelayAuthEvent.kt | Relay authentication (kind 22242) |
| 44 | `nip44Encryption/` | Nip44.kt, Nip44v2.kt | Modern encryption (ChaCha20) |
| 49 | `nip49PrivKeyEnc/` | NIP-49Ncryptsec.kt | Private key encryption format |
### Content Types
| NIP | Directory | Key Files | Description |
|-----|-----------|-----------|-------------|
| 10 | `nip10Notes/` | TextNoteEvent.kt | Text notes with threading (kind 1) |
| 18 | `nip18Reposts/` | RepostEvent.kt, GenericRepostEvent.kt | Reposts (kind 6, 16) |
| 22 | `nip22Comments/` | CommentEvent.kt | Comments (kind 1111) |
| 23 | `nip23LongContent/` | LongTextNoteEvent.kt | Long-form content (kind 30023) |
| 25 | `nip25Reactions/` | ReactionEvent.kt | Reactions (kind 7) |
| 31 | `nip31Alts/` | Alt tags | Alt description tags |
| 36 | `nip36SensitiveContent/` | Content warnings | Content warning tags |
| 37 | `nip37Drafts/` | DraftEvent.kt | Drafts (kind 31234) |
| 50 | `nip50Search/` | Search filters | Full-text search |
### Encoding & Standards
| NIP | Directory | Key Files | Description |
|-----|-----------|-----------|-------------|
| 19 | `nip19Bech32/` | Nip19.kt | Bech32 encoding (npub, nsec, note, nevent, nprofile, naddr) |
| 40 | `nip40Expiration/` | Expiration tags | Event expiration |
| 48 | `nip48ProxyTags/` | Proxy tags | Proxy tags for delegation |
| 62 | `nip62RequestToVanish/` | RequestToVanishEvent.kt | Request to vanish (kind 12) |
| 98 | `nip98HttpAuth/` | HTTP authorization | HTTP auth header |
### Lists & Management
| NIP | Directory | Key Files | Description |
|-----|-----------|-----------|-------------|
| 51 | `nip51Lists/` | 18 list types | Named lists (mute, bookmarks, pins, communities, etc.) (kinds 10000-30004) |
| 65 | `nip65RelayList/` | AdvertisedRelayListEvent.kt | Relay lists (kind 10002) |
### Social & Identity
| NIP | Directory | Key Files | Description |
|-----|-----------|-----------|-------------|
| 39 | `nip39ExtIdentities/` | External identities | External identity claims |
| 46 | `nip46RemoteSigner/` | NostrConnectEvent.kt | Remote signer protocol (bunker) |
| 47 | `nip47WalletConnect/` | Nostr Wallet Connect | Wallet connection protocol |
| 56 | `nip56Reports/` | ReportEvent.kt | Reports (kind 1984) |
| 57 | `nip57Zaps/` | LnZapEvent.kt, LnZapRequestEvent.kt | Lightning zaps (kinds 9734, 9735) |
| 58 | `nip58Badges/` | Badge events | Badge definitions & awards (kinds 30009, 8) |
| 59 | `nip59Giftwrap/` | GiftWrapEvent.kt | Gift-wrapped events for privacy |
| 75 | `nip75ZapGoals/` | ZapGoalEvent.kt | Zap goals (kind 9041) |
### Specialized Content
| NIP | Directory | Key Files | Description |
|-----|-----------|-----------|-------------|
| 28 | `nip28PublicChat/` | ChannelCreateEvent.kt, ChannelMessageEvent.kt | Public chat channels (kinds 40-44) |
| 30 | `nip30CustomEmoji/` | EmojiUrl.kt | Custom emoji |
| 34 | `nip34Git/` | Git patch/issue events | Git repository tracking (kinds 30617, 30618, 1617, 1621, 1622, 1630, 1633) |
| 35 | `nip35Torrents/` | Torrent events | Torrent tracking |
| 52 | `nip52Calendar/` | Calendar events | Calendar time-based/date-based (kinds 31922-31925) |
| 53 | `nip53LiveActivities/` | LiveActivitiesEvent.kt | Live events/streaming (kind 30311) |
| 54 | `nip54Wiki/` | WikiNoteEvent.kt | Wiki pages (kind 30818) |
| 68 | `nip68Picture/` | Picture metadata | Picture metadata |
| 71 | `nip71Video/` | 7 video event types | Video events (kinds 34235, 35235, 1234, 1235) |
| 72 | `nip72ModCommunities/` | Community events | Moderated communities (kinds 34550, 34551, 9041) |
| 84 | `nip84Highlights/` | HighlightEvent.kt | Highlights (kind 9802) |
| 89 | `nip89AppHandlers/` | AppDefinitionEvent.kt | App recommendations (kinds 31990, 31989) |
| 90 | `nip90Dvms/` | DVM job events | Data Vending Machines (DVMs) (kinds 5000-7000) |
| 92 | `nip92IMeta/` | IMeta tags | Image metadata tags |
| 94 | `nip94FileMetadata/` | FileHeaderEvent.kt, FileStorageEvent.kt | File metadata (kind 1063) |
| 96 | `nip96FileStorage/` | HTTP file storage | HTTP-based file storage |
| 99 | `nip99Classifieds/` | ClassifiedsEvent.kt | Classifieds/marketplace (kind 30402) |
| A0 | `nipA0VoiceMessages/` | Voice messages | Voice message events |
| B7 | `nipB7Blossom/` | Blossom server URLs | Blossom file storage |
### Web/Storage/Other
| NIP | Directory | Key Files | Description |
|-----|-----------|-----------|-------------|
| 38 | `nip38UserStatus/` | StatusEvent.kt | User status (kind 30315) |
| 60 | `nip60Payment/` | Wallet events | Wallet info (kind 13194) |
| 61 | `nip61PaymentRequest/` | Nut zaps | Cashu payment requests |
| 64 | `nip64Chess/` | Chess moves | Chess move events |
| 66 | `nip66Monitoring/` | Relay monitor events | Relay monitoring |
| 67 | `nip67Invoices/` | Invoice tags | Lightning invoice tags |
| 69 | `nip69Offers/` | BOLT-12 offers | BOLT-12 offer tags |
| 70 | `nip70ProtectedEvts/` | Protected events | Protected event types |
| 73 | `nip73ExternalIds/` | External content IDs | External content identifiers |
| 78 | `nip78AppData/` | AppDataEvent.kt | Application data (kind 30078) |
| 79 | `nip79Labels/` | Label events | Labeling (kinds 1985, 1986) |
| 80-88 | Various | Various protocols | Relationship, preferences, polls, surveys, social graphs, etc. |
| 91 | `nip91Feed/` | Feed display events | Feed definitions |
| 93 | `nip93Gallery/` | Gallery events | Gallery collections |
| 95 | `nip95Storage/` | Storage event tags | Storage events |
| 97 | `nip97Nests/` | Audio rooms | Audio room events |
## Experimental NIPs (18 packages)
Located at `/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/`:
| Package | Description |
|---------|-------------|
| `audio/` | Audio content, track events |
| `bounties/` | Bounty/funding events |
| `decoupling/` | Decoupling setup |
| `edits/` | Event edit tracking |
| `ephemChat/` | Ephemeral encrypted chat |
| `forks/` | Fork tracking |
| `inlineMetadata/` | Inline metadata |
| `interactiveStories/` | Interactive story events |
| `limits/` | Limit enforcement |
| `medical/` | Medical data |
| `nip95/` | File storage support |
| `nipA3/` | A3 protocol extension |
| `nns/` | Nostr Name System |
| `profileGallery/` | Profile gallery lists |
| `publicMessages/` | Public message lists |
| `relationshipStatus/` | Relationship status events |
| `trustedAssertions/` | Trust/assertion events |
| `zapPolls/` | Zap-based polling |
## Quick Lookup by Kind
| Kind | Event Type | NIP |
|------|------------|-----|
| 0 | Metadata | 01 |
| 1 | Text Note | 01, 10 |
| 3 | Follow List | 02 |
| 4 | Encrypted DM (legacy) | 04 |
| 5 | Deletion | 09 |
| 6 | Repost | 18 |
| 7 | Reaction | 25 |
| 8 | Badge Award | 58 |
| 16 | Generic Repost | 18 |
| 40-44 | Channel Events | 28 |
| 1063 | File Metadata | 94 |
| 1111 | Comment | 22 |
| 1617, 1621, 1622, 1630, 1633 | Git | 34 |
| 1984 | Report | 56 |
| 1985, 1986 | Label | 79 |
| 9734 | Zap Request | 57 |
| 9735 | Zap Receipt | 57 |
| 9802 | Highlight | 84 |
| 10000-20000 | Replaceable Lists | 51 |
| 10002 | Relay List | 65 |
| 13194 | Wallet Info | 60 |
| 22242 | Relay Auth | 42 |
| 23194, 23195 | NWC Payment | 47 |
| 30000-40000 | Addressable Events | Various |
| 30009 | Badge Definition | 58 |
| 30023 | Long-Form Content | 23 |
| 30078 | App Data | 78 |
| 30311 | Live Event | 53 |
| 30315 | User Status | 38 |
| 30402 | Classifieds | 99 |
| 30818 | Wiki | 54 |
| 31234 | Draft | 37 |
| 31922-31925 | Calendar | 52 |
| 31989, 31990 | App Handlers | 89 |
| 34235, 34550-34551 | Video/Communities | 71, 72 |
| 5000-7000 | DVM Jobs | 90 |
## File Location Pattern
All NIPs located at: `/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip<NN><Name>/`
Example: NIP-57 → `/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip57Zaps/`
@@ -0,0 +1,336 @@
# Tag Patterns in Quartz
Tags are the primary way events reference other events, users, and metadata in Nostr.
## Tag Structure
```kotlin
typealias Tag = Array<String> // ["tag_name", "value", "optional_param", ...]
typealias TagArray = Array<Tag>
```
**Pattern**: `[name, value, ...optionalParams]`
## TagArrayBuilder DSL
```kotlin
fun tagArray(initializer: TagArrayBuilder<T>.() -> Unit): TagArray
```
**Methods**:
- `add(tag)` - Append tag
- `addFirst(tag)` - Prepend tag
- `addUnique(tag)` - Replace all tags with this name
- `remove(tagName)` - Remove all tags with name
- `removeIf(predicate, toCompare)` - Conditional removal
**Example**:
```kotlin
val tags = tagArray<TextNoteEvent> {
add(arrayOf("e", eventId, relayHint, "reply"))
add(arrayOf("p", pubkey))
addUnique(arrayOf("subject", "Hello"))
}
```
## Core Tag Types (NIP-01)
### e-tag (Event Reference)
```kotlin
// ["e", <event-id>, <relay-hint>, <marker>]
arrayOf("e", eventId, "wss://relay.damus.io", "reply")
```
**Markers** (NIP-10):
- `root` - Root of thread
- `reply` - Direct reply target
- `mention` - Mentioned event (not reply)
**Extensions**:
```kotlin
// nip01Core/tags/
fun TagArrayBuilder.eTag(eventId: HexKey, relay: String? = null, marker: String? = null)
```
### p-tag (Pubkey Reference)
```kotlin
// ["p", <pubkey>, <relay-hint>]
arrayOf("p", pubkey, "wss://relay.damus.io")
```
**Usage**: Tag users, indicate recipients
**Extensions**:
```kotlin
fun TagArrayBuilder.pTag(pubkey: HexKey, relay: String? = null)
```
### a-tag (Addressable Event Reference)
```kotlin
// ["a", <kind>:<pubkey>:<d-tag>, <relay-hint>]
arrayOf("a", "30023:${authorPubkey}:${dtag}", "wss://relay.damus.io")
```
**Usage**: Reference replaceable/addressable events (kinds 10000-20000, 30000-40000)
**Extensions**:
```kotlin
fun TagArrayBuilder.aTag(kind: Int, pubkey: HexKey, dTag: String, relay: String? = null)
```
### d-tag (Identifier)
```kotlin
// ["d", <identifier>]
arrayOf("d", "my-article-slug")
```
**Usage**: Unique identifier for addressable events
## Common Tag Extensions
### Subject (NIP-14)
```kotlin
// nip14Subject/
fun Event.subject(): String?
fun TagArrayBuilder.subject(text: String)
```
### Content Warning (NIP-36)
```kotlin
// nip36SensitiveContent/
fun Event.contentWarning(): String?
fun TagArrayBuilder.contentWarning(reason: String = "")
```
### Expiration (NIP-40)
```kotlin
// nip40Expiration/
fun Event.expiration(): Long?
fun TagArrayBuilder.expiration(unixTimestamp: Long)
```
### Alt Description (NIP-31)
```kotlin
// nip31Alts/
fun Event.alt(): String?
fun TagArrayBuilder.alt(description: String)
```
## Specialized Tags
### Zap Tags (NIP-57)
```kotlin
// nip57Zaps/tags/
class BoltTag(val bolt11: String, val preimage: String?)
class DescriptionTag(val zapRequestJson: String)
```
### Imeta Tags (NIP-92)
```kotlin
// nip92IMeta/
class IMetaTag(val url: String, val metadata: Map<String, String>)
// Usage: Image metadata
IMetaTag("https://example.com/image.jpg", mapOf(
"m" to "image/jpeg",
"dim" to "1920x1080",
"blurhash" to "..."
))
```
### Relay Tags (NIP-65)
```kotlin
// nip65RelayList/
class RelayTag(val url: String, val type: RelayType)
enum class RelayType { READ, WRITE, BOTH }
```
## Tag Query Patterns
### Finding Tags
```kotlin
// Extension functions on TagArray
fun TagArray.firstTag(name: String): Tag?
fun TagArray.allTags(name: String): List<Tag>
fun TagArray.tagValue(name: String): String?
fun TagArray.tagValues(name: String): List<String>
```
**Example**:
```kotlin
val event: TextNoteEvent = ...
val subject = event.tags.tagValue("subject")
val mentions = event.tags.allTags("p").mapNotNull { it.getOrNull(1) }
```
### Parsing Tags
```kotlin
// Pattern: Companion object with parse methods
object ETag {
fun parse(tag: Tag): ETag? {
if (tag.getOrNull(0) != "e") return null
return ETag(
eventId = tag.getOrNull(1) ?: return null,
relay = tag.getOrNull(2),
marker = tag.getOrNull(3)
)
}
}
// Usage
val eTags = event.tags.mapNotNull(ETag::parse)
```
## Event Builder Pattern
Combining TagArrayBuilder with event creation:
```kotlin
fun createTextNote(content: String, replyTo: Event?): EventTemplate {
return eventTemplate(
kind = 1,
content = content,
tags = tagArray {
replyTo?.let {
eTag(it.id, marker = "reply")
pTag(it.pubKey)
it.rootEvent()?.let { root ->
eTag(root.id, marker = "root")
}
}
}
)
}
```
## Hint System
Tags can provide "hints" - optional relay URLs for fetching referenced content:
```kotlin
// Event references
["e", eventId, "wss://relay.example.com"] // relay hint
// Pubkey references
["p", pubkey, "wss://relay.example.com"] // relay hint
// Addressable references
["a", "30023:pubkey:dtag", "wss://relay.example.com"] // relay hint
```
**Pattern**: Third parameter (index 2) is always the relay hint
## Tag Validation
```kotlin
// Common validations
fun validateETag(tag: Tag): Boolean {
return tag.getOrNull(0) == "e" && tag.getOrNull(1)?.isValidHex() == true
}
fun validatePTag(tag: Tag): Boolean {
return tag.getOrNull(0) == "p" && tag.getOrNull(1)?.isValidHex() == true
}
```
## Performance Patterns
### Tag Indexing
```kotlin
// TagArrayBuilder keeps an index by tag name
private val tagList = mutableMapOf<String, MutableList<Tag>>()
// Fast lookup by name
fun remove(tagName: String) {
tagList.remove(tagName)
}
```
### Lazy Parsing
```kotlin
// Don't parse all tags upfront
class TextNoteEvent(...) {
private val _mentions by lazy {
tags.mapNotNull(PTag::parse)
}
fun mentions() = _mentions
}
```
## Common Workflows
### Creating a Reply
```kotlin
fun replyTo(original: TextNoteEvent, content: String): EventTemplate {
return eventTemplate(
kind = 1,
content = content,
tags = tagArray {
// Reply to this event
eTag(original.id, marker = "reply")
// Copy root marker if exists, or mark original as root
original.rootEvent()?.let {
eTag(it.id, marker = "root")
} ?: eTag(original.id, marker = "root")
// Tag author
pTag(original.pubKey)
// Tag all mentioned users
original.mentions().forEach { pTag(it) }
}
)
}
```
### Creating a Reaction
```kotlin
fun createReaction(targetEvent: Event, emoji: String): EventTemplate {
return eventTemplate(
kind = 7,
content = emoji,
tags = tagArray {
eTag(targetEvent.id)
pTag(targetEvent.pubKey)
}
)
}
```
### Creating an Addressable Event
```kotlin
fun createArticle(title: String, content: String, slug: String): EventTemplate {
return eventTemplate(
kind = 30023,
content = content,
tags = tagArray {
addUnique(arrayOf("d", slug)) // Unique identifier
add(arrayOf("title", title))
add(arrayOf("published_at", "${TimeUtils.now()}"))
}
)
}
```
## Quick Reference
| Tag | NIP | Usage | Example |
|-----|-----|-------|---------|
| e | 01 | Event reference | `["e", eventId, relay, marker]` |
| p | 01 | Pubkey reference | `["p", pubkey, relay]` |
| a | 01 | Addressable event | `["a", "kind:pubkey:d"]` |
| d | 01 | Identifier | `["d", "unique-id"]` |
| subject | 14 | Subject line | `["subject", "Hello"]` |
| content-warning | 36 | Content warning | `["content-warning", "nsfw"]` |
| expiration | 40 | Expiration time | `["expiration", "1234567890"]` |
| bolt11 | 57 | Lightning invoice | `["bolt11", "lnbc..."]` |
| imeta | 92 | Media metadata | `["imeta", "url", "m", "image/jpeg"]` |
| relay | 65 | User relays | `["relay", "wss://...", "read"]` |
## Resources
- Tag builders: `quartz/src/commonMain/.../nip01Core/tags/`
- Tag extensions: Look for `TagArrayExt.kt`, `TagArrayBuilderExt.kt` in each NIP package
- Event parsing: Each event class has tag parsing methods
+58
View File
@@ -0,0 +1,58 @@
#!/bin/bash
# Find NIP implementation files by NIP number or search term
set -e
QUARTZ_PATH="${QUARTZ_PATH:-./quartz/src/commonMain/kotlin/com/vitorpamplona/quartz}"
if [ $# -eq 0 ]; then
echo "Usage: $0 <nip-number|search-term>"
echo ""
echo "Examples:"
echo " $0 01 # Find NIP-01 files"
echo " $0 44 # Find NIP-44 files"
echo " $0 encryption # Search for 'encryption' in NIP packages"
echo ""
echo "Set QUARTZ_PATH to override default quartz location"
exit 1
fi
SEARCH_TERM="$1"
# Check if it's a number (NIP number)
if [[ "$SEARCH_TERM" =~ ^[0-9]+$ ]]; then
# Pad to 2 digits
NIP_NUM=$(printf "%02d" "$SEARCH_TERM")
echo "Searching for NIP-$NIP_NUM implementation..."
echo "================================================"
echo ""
# Find directories matching nip##*
find "$QUARTZ_PATH" -type d -name "nip${NIP_NUM}*" | while read -r dir; do
echo "📁 $(basename "$dir")/"
find "$dir" -name "*.kt" -type f | while read -r file; do
rel_path="${file#$QUARTZ_PATH/}"
echo " └─ $rel_path"
done
echo ""
done
else
# Text search
echo "Searching for '$SEARCH_TERM' in NIP packages..."
echo "================================================"
echo ""
find "$QUARTZ_PATH" -type d -name "nip*" | while read -r dir; do
if grep -r -l -i "$SEARCH_TERM" "$dir" --include="*.kt" 2>/dev/null | head -1 > /dev/null; then
echo "📁 $(basename "$dir")/"
grep -r -l -i "$SEARCH_TERM" "$dir" --include="*.kt" 2>/dev/null | while read -r file; do
rel_path="${file#$QUARTZ_PATH/}"
matches=$(grep -c -i "$SEARCH_TERM" "$file" 2>/dev/null || echo "0")
echo " └─ $rel_path ($matches matches)"
done
echo ""
fi
done
fi
echo "Done."
+165
View File
@@ -0,0 +1,165 @@
# Quartz KMP Conversion Skill
When working with Quartz library conversion to Kotlin Multiplatform:
## Current Structure (Android-only)
```
quartz/
├── build.gradle
└── src/
├── main/kotlin/ # All Nostr code here
├── test/
└── androidTest/
```
## Target Structure (KMP)
```
quartz/
├── build.gradle.kts
└── src/
├── commonMain/kotlin/ # Shared protocol code
├── commonTest/kotlin/ # Shared tests
├── androidMain/kotlin/ # Android crypto, storage
├── androidTest/kotlin/
├── jvmMain/kotlin/ # Desktop crypto, storage
└── jvmTest/kotlin/
```
## Platform Abstractions Required
### 1. Cryptography (expect/actual)
```kotlin
// commonMain
expect object Secp256k1 {
fun sign(data: ByteArray, privateKey: ByteArray): ByteArray
fun verify(data: ByteArray, signature: ByteArray, pubKey: ByteArray): Boolean
fun pubKeyCreate(privateKey: ByteArray): ByteArray
}
// androidMain - uses secp256k1-kmp-jni-android
actual object Secp256k1 {
actual fun sign(data: ByteArray, privateKey: ByteArray): ByteArray {
return fr.acinq.secp256k1.Secp256k1.sign(data, privateKey)
}
// ...
}
// jvmMain - uses secp256k1-kmp-jni-jvm
actual object Secp256k1 {
actual fun sign(data: ByteArray, privateKey: ByteArray): ByteArray {
return fr.acinq.secp256k1.Secp256k1.sign(data, privateKey)
}
// ...
}
```
### 2. NIP-44 Encryption (Sodium)
```kotlin
// commonMain
expect object Nip44 {
fun encrypt(plaintext: String, sharedSecret: ByteArray): String
fun decrypt(ciphertext: String, sharedSecret: ByteArray): String
}
// androidMain - lazysodium-android
// jvmMain - lazysodium-java or libsodium-jni
```
### 3. Secure Random
```kotlin
// commonMain
expect fun secureRandomBytes(size: Int): ByteArray
// androidMain
actual fun secureRandomBytes(size: Int): ByteArray {
return SecureRandom().let { random ->
ByteArray(size).also { random.nextBytes(it) }
}
}
// jvmMain
actual fun secureRandomBytes(size: Int): ByteArray {
return java.security.SecureRandom().let { random ->
ByteArray(size).also { random.nextBytes(it) }
}
}
```
## Build Configuration
```kotlin
// quartz/build.gradle.kts
plugins {
kotlin("multiplatform")
id("com.android.library")
}
kotlin {
androidTarget {
compilations.all {
kotlinOptions.jvmTarget = "17"
}
}
jvm("desktop") {
compilations.all {
kotlinOptions.jvmTarget = "17"
}
}
sourceSets {
val commonMain by getting {
dependencies {
implementation(libs.kotlinx.coroutines.core)
implementation(libs.kotlinx.collections.immutable)
}
}
val androidMain by getting {
dependencies {
implementation(libs.secp256k1.kmp.jni.android)
implementation(libs.lazysodium.android)
}
}
val desktopMain by getting {
dependencies {
implementation(libs.secp256k1.kmp.jni.jvm)
// lazysodium-java or alternative
}
}
}
}
android {
namespace = "com.vitorpamplona.quartz"
compileSdk = 35
defaultConfig.minSdk = 26
}
```
## Migration Steps
1. **Convert build.gradle to build.gradle.kts** with KMP plugin
2. **Move pure Kotlin code** to `commonMain/`
3. **Identify platform dependencies** (crypto, JNA, Android APIs)
4. **Create expect declarations** for platform-specific APIs
5. **Implement actuals** in androidMain and jvmMain
6. **Update imports** in amethyst module
7. **Test on both platforms**
## Files to Move to commonMain
Most of Quartz can be shared:
- Event classes and parsing
- Filter definitions
- Relay message types
- NIP implementations (logic only)
- Utilities (hex encoding, bech32)
## Files Needing expect/actual
- `Secp256k1.kt` - Signature operations
- `Nip04.kt` - Legacy encryption (uses AES)
- `Nip44.kt` - Modern encryption (uses ChaCha)
- `KeyPair.kt` - Key generation
+6
View File
@@ -19,6 +19,9 @@
/.idea/kotlinNotebook.xml
/.idea/ChatHistory_schema_v3.xml
/.idea/markdown.xml
/.idea/AndroidProjectSystem.xml
/.idea/deviceManager.xml
/.idea/inspectionProfiles/
.DS_Store
/build
/captures
@@ -138,3 +141,6 @@ lint/generated/
lint/outputs/
lint/tmp/
# lint/reports/
# Local task tracking
TASKS.md
-6
View File
@@ -1,6 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="AndroidProjectSystem">
<option name="providerId" value="com.android.tools.idea.GradleProjectSystem" />
</component>
</project>
-13
View File
@@ -1,13 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="DeviceTable">
<option name="columnSorters">
<list>
<ColumnSorterState>
<option name="column" value="Name" />
<option name="order" value="ASCENDING" />
</ColumnSorterState>
</list>
</option>
</component>
</project>
-77
View File
@@ -1,77 +0,0 @@
<component name="InspectionProjectProfileManager">
<profile version="1.0">
<option name="myName" value="Project Default" />
<inspection_tool class="ComposePreviewDimensionRespectsLimit" enabled="true" level="WARNING" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="ComposePreviewMustBeTopLevelFunction" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="ComposePreviewNeedsComposableAnnotation" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="ComposePreviewNotSupportedInUnitTestFiles" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="GlancePreviewDimensionRespectsLimit" enabled="true" level="WARNING" enabled_by_default="true">
<option name="composableFile" value="true" />
</inspection_tool>
<inspection_tool class="GlancePreviewMustBeTopLevelFunction" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
</inspection_tool>
<inspection_tool class="GlancePreviewNeedsComposableAnnotation" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
</inspection_tool>
<inspection_tool class="GlancePreviewNotSupportedInUnitTestFiles" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewAnnotationInFunctionWithParameters" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewApiLevelMustBeValid" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewDeviceShouldUseNewSpec" enabled="true" level="WEAK WARNING" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewDimensionRespectsLimit" enabled="true" level="WARNING" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewFontScaleMustBeGreaterThanZero" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewMultipleParameterProviders" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewMustBeTopLevelFunction" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewNeedsComposableAnnotation" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewNotSupportedInUnitTestFiles" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewParameterProviderOnFirstParameter" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewPickerAnnotation" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
</profile>
</component>
-6
View File
@@ -1,6 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="KotlinNotebookOptionsProvider">
<option name="shouldAddProjectLibrariesToClasspath" value="true" />
</component>
</project>
+6 -2
View File
@@ -4,9 +4,13 @@
<option name="moduleKind" value="plain" />
</component>
<component name="Kotlin2JvmCompilerArguments">
<option name="jvmTarget" value="1.8" />
<option name="jvmTarget" value="21" />
</component>
<component name="KotlinCommonCompilerArguments">
<option name="apiVersion" value="2.3" />
<option name="languageVersion" value="2.3" />
</component>
<component name="KotlinJpsPluginSettings">
<option name="version" value="2.1.0" />
<option name="version" value="2.3.0" />
</component>
</project>
+148
View File
@@ -1,3 +1,151 @@
<a id="v1.05.0"></a>
# [Release v1.05.0: Bookmark Lists and WoT Scores](https://github.com/vitorpamplona/amethyst/releases/tag/v1.05.0) - 2025-01-08
#Amethyst v1.05.0: Bookmark Lists, Voice Notes, and WoT Scores
This release introduces Bookmark List management, a complete overhaul of Voice Notes/YakBaks,
and the debut of Web of Trust (WoT) scores for a safer social experience.
This version adds support for creating, managing, deleting, and viewing multiple bookmark lists,
which include both public and private members. You will find an improved "Bookmarks" menu option in
the sidebar and extra bookmark options in the context menu of each post, allowing you to add posts
directly to one or more individual lists.
The Voice Notes UI has been redesigned to allow recording directly within the new Post Screen and a
dedicated Voice Reply screen. Users can record a new voice message, preview it with waveform
visualization, re-record if needed, select a media server, and post the reply. You now have full control.
Amethyst now supports Trusted Assertions. By connecting to a WoT provider, you can see trust scores
and verified follower counts directly on user pictures. This helps filter signal from noise, identifying
reputable accounts to follow, which DMs to open, and which notifications to prioritize. To activate
this, you will need to find a provider capable of computing these scores. While providers are
currently limited and resource-constrained, we hope more will bring their own algorithms to Nostr over time.
Quartz received a significantly improved database engine capable of sub-microsecond queries using Android's
default SQLite database. The engine is optimized for mobile environments, using as little memory as
possible to avoid impacting other apps.
In the background, we have begun building Amethyst Desktop. While much work remains, the goal is a
standalone, mouse-first application that moves away from mobile-centric UI layouts.
New Features
- Trusted Assertions: Added support for trust scores displayed on user profile pictures
- WoT Followers: Displays verified follower counts in user profiles
- Bookmark Lists: Full support for custom lists by @npub1a3tx8wcrt789skl6gg7rqwj4wey0j53eesr4z6asd4h4jwrd62jq0wkq4k
- Relay Information: New UI with expanded NIP-11 feature support
- Voice Notes & Replies: Redesigned experience by @npub1e2yuky03caw4ke3zy68lg0fz3r4gkt94hx4fjmlelacyljgyk79svn3eef
- Profile Banner: New default banner by @npub1tx5ccpregnm9afq0xaj42hh93xl4qd3lfa7u74v5cdvyhwcnlanqplhd8g
- Native Links: Intercept njump, yakihonne, primal, iris.to, zap.stream, and shosho.live to open directly on Amethyst by @npub1lu4l4wh7482u8vq0u63g2yuj8yqjdq8ne06r432n92rnd22lspcq0cxa32
Improvements:
- New in-memory graph-based cache scheme; moved reports and WoT scores to this new system
- Disabled top bar reappearance to prevent feed shifting when navigating between pages
- Lenient Kotlin Serialization to prevent crashes from malformed JSON;
- Removed expired addressable events from cache
- Moves reports from the old caching system to the new Graph-based one.
- Reverted to a 500-post load limit for Profile screens to handle high-reply accounts
- Moved the QR Code screen from a Dialog to a full Route.
- Re-adds name as a tagging name to the profile edit page.
Performance:
- Faster event id checker by serializing, sha256 hashing, and ID comparison without creating any intermediary buffers.
- Faster event JSON parsers by avoiding new variables and thus garbage collection calls
- Faster tag array Deserializer
- Manages the pool state without having to loop through relays, saving some milliseconds of processing.
- Adds a cache system for WoT scores
- Improved Compose stability for video UI
BugFixes:
- Fixes JSON serialization of UTF-8 Emoji surrogates for compatibility with standard Nostr implementations
- Improves error message on zap configuration errors with detailed NWC URI by @npub1e2yuky03caw4ke3zy68lg0fz3r4gkt94hx4fjmlelacyljgyk79svn3eef
- Centers QR dialog content and reduce excessive top spacing by @npub1qqqqqqz7nhdqz3uuwmzlflxt46lyu7zkuqhcapddhgz66c4ddynswreecw
- Closes subscriptions when ending them on NostrClient instead of waiting for them to finish
- Requires a relay to be an outbox/inbox relay to be able to NOTIFY a user of a payment
- Improves the speed of parsing of invalid kinds inside an address string
- Fixes count not working for LIMIT queries in the DB
- Fixes icon bug with incorrect resource id by @npub1e2yuky03caw4ke3zy68lg0fz3r4gkt94hx4fjmlelacyljgyk79svn3eef
- Fixes missing updates to the feed when the top list is not yet available locally
- Fixes List of supported NIPs as Integers on NIP-11 by @npub1e2yuky03caw4ke3zy68lg0fz3r4gkt94hx4fjmlelacyljgyk79svn3eef
- Fixes ConcurrentExceptions on event outboxes
Desktop:
- Base Compose Multiplatform Desktop App with posts and global/following feeds by @npub12cfje6nl2nuxplcqfvhg7ljt89fmpj0n0fd24zxsukja5qm9wmtqd7y76c
Web:
- New website by @npub18ams6ewn5aj2n3wt2qawzglx9mr4nzksxhvrdc4gzrecw7n5tvjqctp424
Quartz:
- Adds support for Trust Provider lists and Contact Cards for NIP-85
- Early support for Payment targets as per [NIP-A3](https://github.com/nostr-protocol/nips/pull/2119) by @npub1w4uswmv6lu9yel005l3qgheysmr7tk9uvwluddznju3nuxalevvs2d0jr5
- Initial support for NIP 46 by @npub1w4uswmv6lu9yel005l3qgheysmr7tk9uvwluddznju3nuxalevvs2d0jr5
- Adds support for fast MurMur hash 3 64 bits
- Adds a nextLong secure random method
- Removing the generalist approach of ptag-mentions
- Removes deprecated fields in UserMetadata
- Removes compose bom from Quartz to avoid unnecessary dependencies.
- Removes datetime dependencies from Quartz
- Adds dependency on coroutines directly (instead of through compose runtime)
- Removes old secp256 target dependencies
- Adds Default scope for NostrClient and Relay Authenticator
Quartz-Event Store:
- Moves from text tags to probabilistic 64-bit MurMur Hash3 integers for performance
- Moves from range index queries to kind,pubkey queries by default.
- Adds simpler SQL queries for specific simple Nostr filters
- Expose SQL query plans, vacuum, and analyse to lib users
- Implements AND Tag queries from [NIP-91](https://github.com/nostr-protocol/nips/pull/1365)
- Implements GiftWrap deletions by p-Tag with deletions and vanish requests
- Offers several indexing strategy options to users.
- Adds several test cases that verify not only the SQL but also the indexes used
- Exposes raw queries that return columns for relays that might not need the tag array
- Forces the use of the index on Addressables and Replaceables on triggers
- Fixes duplicated events being returned from the DB
- Fixes unused Or condition in the SQL builder
- Refine the structure of the module classes for the DB
- Removes the Statement cache since statements are not thread safe
- Creating interfaces for multiple EventStores
Code Quality:
- Updates kotlin, compose, multiplatform, activity, serialization, media3, mockk, secp256, tor, androidxCamera, stdlib
- Adds a compose stability plugin to allow traces in debug
- Updates to the latest Zapstore config
- Updates quarts instructions in the ReadMe.
Updated translations:
- Czech, German, Swedish, and Portuguese by @npub1e2yuky03caw4ke3zy68lg0fz3r4gkt94hx4fjmlelacyljgyk79svn3eef
- Polish by @npub16gjyljum0ksrrm28zzvejydgxwfm7xse98zwc4hlgq8epxeuggushqwyrm
- Hungarian by @npub1ww8kjxz2akn82qptdpl7glywnchhkx3x04hez3d3rye397turrhssenvtp @npub1dnvslq0vvrs8d603suykc4harv94yglcxwna9sl2xu8grt2afm3qgfh0tp
- Hindi by @npub1ww6huwu3xye6r05n3qkjeq62wds5pq0jswhl7uc59lchc0n0ns4sdtw5e6
- Slovenian by @npub1qqqqqqz7nhdqz3uuwmzlflxt46lyu7zkuqhcapddhgz66c4ddynswreecw
- Spanish by @npub1luhyzgce7qtcs6r6v00ryjxza8av8u4dzh3avg0zks38tjktnmxspxq903
- Latvian by @npub1l60stxkwwmkts76kv02vdjppka9uy6y3paztck7paau7g64l687saaw6av
- Dutch by @npub1w4la29u3zv09r6crx5u8yxax0ffxgekzdm2egzjkjckef7xc83fs0ftxcd
- French by @npub106efcyntxc5qwl3w8krrhyt626m59ya2nk9f40px5s968u5xdwhsjsr8fz and Alexis Magzalci
- Chinese by @npub1gd8e0xfkylc7v8c5a6hkpj4gelwwcy99jt90lqjseqjj2t253s2s6ch58h
<a id="v1.04.2"></a>
# [Release v1.04.2: Fix for Google Play](https://github.com/vitorpamplona/amethyst/releases/tag/v1.04.2) - 2025-11-15
Quick release for Google.
<a id="v1.04.1"></a>
# [Release v1.04.1: Bugfixes](https://github.com/vitorpamplona/amethyst/releases/tag/v1.04.1) - 2025-11-15
#Amethyst v1.04.1: Bug fixes
- Fixes crashing when starting
- Fixes hashtag unfollowing for mixed case tags
- Fixes release Id for the zap the devs button
- Fixes quartz version for a release to Maven
- Fixes disappearing of the Zap the Devs Manual Payment screen
- Fixes back button of the Zap the Devs Manual Payment screen staying behind the status bar
Translations:
- Polish by @npub16gjyljum0ksrrm28zzvejydgxwfm7xse98zwc4hlgq8epxeuggushqwyrm
- Hungarian by @npub1dnvslq0vvrs8d603suykc4harv94yglcxwna9sl2xu8grt2afm3qgfh0tp
Download: http://amethyst.social
<a id="v1.04.0"></a>
# [Release v1.04.0: List Management](https://github.com/vitorpamplona/amethyst/releases/tag/v1.04.0) - 2025-11-15
+59 -16
View File
@@ -153,9 +153,11 @@ Information shared on Nostr can be re-broadcasted to other servers and should be
# Development Overview
This repository is split between Amethyst and Quartz:
- Amethyst is a native Android app made with Kotlin and Jetpack Compose.
- Quartz is our own Nostr-commons library to host classes that are of interest to other Nostr Clients.
This repository is split between Amethyst, Quartz, Commons, and DesktopApp:
- **Amethyst** - Native Android app with Kotlin and Jetpack Compose
- **Quartz** - Nostr-commons KMP library for protocol classes shared across platforms
- **Commons** - Kotlin Multiplatform module with shared UI components (icons, robohash, blurhash, composables)
- **DesktopApp** - Compose Multiplatform Desktop application reusing commons and quartz
The app architecture consists of the UI, which uses the usual State/ViewModel/Composition, the service layer that connects with Nostr relays,
and the model/repository layer, which keeps all Nostr objects in memory, in a full OO graph.
@@ -187,11 +189,17 @@ git clone https://github.com/vitorpamplona/amethyst.git
Use an Android Studio build action to install and run the app on your device or a simulator.
## Building
Build the app:
Build the Android app:
```bash
./gradlew assembleDebug
```
Build and run the Desktop app (requires Java 21+):
```bash
./gradlew :desktopApp:run
```
## Testing
```bash
./gradlew test
@@ -239,21 +247,32 @@ openssl base64 < <my-release-key.keystore> | tr -d '\n' | tee some_signing_key.j
### Installing
Add Maven Central and Google Maven to your repositories:
```gradle
repositories {
mavenCentral()
google()
}
```
Add the following line to your `commonMain` dependencies:
```gradle
implementation('com.vitorpamplona.quartz:quartz:<Amethyst Version>')
implementation('com.vitorpamplona.quartz:quartz:1:05.0')
```
Variations to each platform are also available:
```gradle
implementation('com.vitorpamplona.quartz:quartz-android:<Amethyst Version>')
implementation('com.vitorpamplona.quartz:quartz-jvm:<Amethyst Version>')
implementation('com.vitorpamplona.quartz:quartz-iosarm64:<Amethyst Version>')
implementation('com.vitorpamplona.quartz:quartz-iossimulatorarm64:<Amethyst Version>')
implementation('com.vitorpamplona.quartz:quartz-android:1:05.0')
implementation('com.vitorpamplona.quartz:quartz-jvm:1:05.0')
implementation('com.vitorpamplona.quartz:quartz-iosarm64:1:05.0')
implementation('com.vitorpamplona.quartz:quartz-iossimulatorarm64:1:05.0')
```
Check versions on [MavenCentral](https://central.sonatype.com/search?q=com.vitorpamplona.quartz)
### How to use
Manage logged in users with the `KeyPair` class
@@ -281,8 +300,8 @@ val amberSigner = NostrSignerExternal(
)
```
Create a single NostrClient for the entire application and control which relays it will access by
registering subscriptions and sending events. The pool will automatically changed based on filters +
Create a single `NostrClient` for the entire application and control which relays it will access by
registering subscriptions and sending events. The pool will automatically change based on filters +
outbox events.
You will need a coroutine scope to process events and if you are using OKHttp, we offer a basic
@@ -299,9 +318,11 @@ val client = NostrClient(socketBuilder, appScope)
If you want to auth, given a logged-in `signer`:
```kt
val authCoordinator = RelayAuthenticator(client, appScope) { challenge, relay ->
val authedEvent = RelayAuthEvent.create(relay.url, challenge, signer)
client.sendIfExists(authedEvent, relay.url)
val authCoordinator = RelayAuthenticator(client, appScope) { authTemplate ->
listOf(
// for each signed-in user, return an event
signer.sign(authTemplate)
)
}
```
@@ -309,8 +330,8 @@ To make a request subscription simply do:
```kt
val metadataSub = client.req(
relays = listOf("wss://nos.lol", "wss://nostr.mom"),
filters = Filter(
relay = "wss://nos.lol",
filter = Filter(
kinds = listOf(MetadataEvent.KIND),
authors = listOf(signer.pubkey)
)
@@ -356,6 +377,28 @@ When your app goes to the background, you can use NostrClient's `connect` and `d
methods to stop all communication to relays. Add the `connect` to your `onResume` and `disconnect`
to `onPause` methods.
### Feature Parity Table
| Feature Category | Feature / Component | Android / JVM Support | iOS Support | Notes |
| :--- | :--- | :---: | :---: | :--- |
| **Cryptography** | Secp256k1 (Schnorr, Keys) | ✅ Full | ❌ No | Core Nostr signing/verification is missing on iOS. |
| | LibSodium (ChaCha20, Poly1305) | ✅ Full | ❌ No | AEAD and stream ciphers are unimplemented. |
| | AES Encryption (CBC & GCM) | ✅ Full | ❌ No | `AESCBC` and `AESGCM` are stubs on iOS. |
| | Hashing (SHA-256, etc.) | ✅ Full | ❌ No | `DigestInstance` is unimplemented. |
| | MAC (HmacSHA256, etc.) | ✅ Full | ❌ No | `MacInstance` is unimplemented. |
| **Data & Serialization** | JSON Mapping (Optimized) | ✅ Full | ❌ No | `OptimizedJsonMapper` is a stub; cannot parse/serialize Events. |
| | GZip Compression | ✅ Full | ❌ No | `GZip` implementation is missing. |
| | BitSet | ✅ Full | ❌ No | `BitSet` utility is unimplemented. |
| | LargeCache | ✅ Full | ❌ No | `LargeCache` methods (get, keys, size, etc.) are stubs. |
| **NIP Support** | NIP-96 (File Storage Info) | ✅ Full | ❌ No | `ServerInfoParser` is unimplemented. |
| | NIP-46 (Remote Signer) | ✅ Full | ⚠️ Partial | Some methods in `NostrSignerRemote` are unimplemented in `commonMain`. |
| | NIP-03 (OTS / Timestamps) | ✅ Full | ❌ No | `BitcoinExplorer` and `RemoteCalendar` have stubs in `commonMain`. |
| **Utilities** | URL Encoding / Decoding | ✅ Full | ❌ No | `UrlEncoder` and `URLs.ios.kt` are unimplemented. |
| | Unicode Normalization | ✅ Full | ❌ No | `UnicodeNormalizer` is a stub. |
| | Platform Logging | ✅ Full | ✅ Full | iOS uses `NSLog`, Android uses standard Log. |
| | Current Time | ✅ Full | ✅ Full | Implemented using `NSDate` on iOS. |
## Contributing
Issues can be logged on: [https://gitworkshop.dev/repo/amethyst](https://gitworkshop.dev/repo/amethyst)
+4 -3
View File
@@ -6,6 +6,7 @@ plugins {
alias(libs.plugins.googleServices)
alias(libs.plugins.jetbrainsComposeCompiler)
alias(libs.plugins.serialization)
alias(libs.plugins.stability.analyzer)
}
def getCurrentBranch() {
@@ -44,9 +45,9 @@ android {
applicationId = "com.vitorpamplona.amethyst"
minSdk = libs.versions.android.minSdk.get().toInteger()
targetSdk = libs.versions.android.targetSdk.get().toInteger()
versionCode = 429
versionName = generateVersionName("1.04.0")
buildConfigField "String", "RELEASE_NOTES_ID", "\"3a03c75d85aaf6b181d3b232d064c4d4feea5c73f0bea2bd91ed61b8da7cd6a6\""
versionCode = 431
versionName = generateVersionName("1.05.0")
buildConfigField "String", "RELEASE_NOTES_ID", "\"b457a20195ffcf501389fcb708f0ef73f4ee263e3bba63f1b893a896129e4c79\""
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables {
@@ -110,7 +110,7 @@ class ImageUploadTesting {
.upload(
inputStream = inputStream,
hash = initialHash,
length = paylod.size,
length = paylod.size.toLong(),
baseFileName = "filename.png",
contentType = "image/png",
alt = null,
+36
View File
@@ -133,6 +133,42 @@
<data android:host="njump.me" />
</intent-filter>
<intent-filter android:label="njump.to">
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="https" />
<data android:host="njump.to" />
</intent-filter>
<intent-filter android:label="iris.to">
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="https" />
<data android:host="iris.to" />
</intent-filter>
<intent-filter android:label="zap.stream">
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="https" />
<data android:host="zap.stream" />
<data android:pathPrefix="/p/" />
<data android:pathPrefix="/" />
</intent-filter>
<intent-filter android:label="shosho.live">
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="https" />
<data android:host="shosho.live" />
<data android:pathPrefix="/profile/" />
<data android:pathPrefix="/live/" />
</intent-filter>
<intent-filter android:label="Primal">
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
@@ -200,7 +200,7 @@ class AppModules(
val relayStats = RelayStats(client)
// Logs debug messages when needed
val detailedLogger = if (isDebug) RelayLogger(client, true, false) else null
val detailedLogger = if (isDebug) RelayLogger(client, false, false) else null
val relayReqStats = if (isDebug) RelayReqStats(client) else null
val logger = if (isDebug) RelaySpeedLogger(client) else null
@@ -58,17 +58,6 @@ fun debugState(context: Context) {
Log.d(STATE_DUMP_TAG, "Memory Class $memClass MB (largeHeap $isLargeHeap)")
}
Log.d(
STATE_DUMP_TAG,
"Connected Relays: " +
Amethyst.instance.client
.relayStatusFlow()
.value.connected.size + "/" +
Amethyst.instance.client
.relayStatusFlow()
.value.available.size,
)
Log.d(
STATE_DUMP_TAG,
"Indexed Relays: " +
@@ -33,6 +33,7 @@ import com.vitorpamplona.amethyst.service.checkNotInMainThread
import com.vitorpamplona.amethyst.ui.actions.mediaServers.DEFAULT_MEDIA_SERVERS
import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerName
import com.vitorpamplona.quartz.experimental.ephemChat.list.EphemeralChatListEvent
import com.vitorpamplona.quartz.experimental.trustedAssertions.list.TrustProviderListEvent
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.JsonMapper
@@ -77,9 +78,9 @@ private const val DEBUG_PREFERENCES_NAME = "debug_prefs"
@Serializable
data class AccountInfo(
val npub: String,
val hasPrivKey: Boolean,
val loggedInWithExternalSigner: Boolean,
val isTransient: Boolean,
val hasPrivKey: Boolean = false,
val loggedInWithExternalSigner: Boolean = false,
val isTransient: Boolean = false,
)
private object PrefKeys {
@@ -110,6 +111,7 @@ private object PrefKeys {
const val LATEST_HASHTAG_LIST = "latestHashtagList"
const val LATEST_GEOHASH_LIST = "latestGeohashList"
const val LATEST_EPHEMERAL_LIST = "latestEphemeralChatList"
const val LATEST_TRUST_PROVIDER_LIST = "latestTrustProviderList"
const val HIDE_DELETE_REQUEST_DIALOG = "hide_delete_request_dialog"
const val HIDE_BLOCK_ALERT_DIALOG = "hide_block_alert_dialog"
const val HIDE_NIP_17_WARNING_DIALOG = "hide_nip24_warning_dialog" // delete later
@@ -246,8 +248,8 @@ object LocalPreferences {
withContext(Dispatchers.IO) {
val prefsDir = File(prefsDirPath)
prefsDir.list()?.forEach {
if (it.contains(npub)) {
File(prefsDir, it).delete()
if (it.contains(npub) && !File(prefsDir, it).delete()) {
Log.w("LocalPreferences", "Failed to delete preference file: $it")
}
}
}
@@ -354,6 +356,7 @@ object LocalPreferences {
putOrRemove(PrefKeys.LATEST_HASHTAG_LIST, settings.backupHashtagList)
putOrRemove(PrefKeys.LATEST_GEOHASH_LIST, settings.backupGeohashList)
putOrRemove(PrefKeys.LATEST_EPHEMERAL_LIST, settings.backupEphemeralChatList)
putOrRemove(PrefKeys.LATEST_TRUST_PROVIDER_LIST, settings.backupTrustProviderList)
putBoolean(PrefKeys.HIDE_DELETE_REQUEST_DIALOG, settings.hideDeleteRequestDialog)
putBoolean(PrefKeys.HIDE_NIP_17_WARNING_DIALOG, settings.hideNIP17WarningDialog)
@@ -482,6 +485,7 @@ object LocalPreferences {
val latestHashtagList = parseEventOrNull<HashtagListEvent>(PrefKeys.LATEST_HASHTAG_LIST)
val latestGeohashList = parseEventOrNull<GeohashListEvent>(PrefKeys.LATEST_GEOHASH_LIST)
val latestEphemeralList = parseEventOrNull<EphemeralChatListEvent>(PrefKeys.LATEST_EPHEMERAL_LIST)
val latestTrustProviderList = parseEventOrNull<TrustProviderListEvent>(PrefKeys.LATEST_TRUST_PROVIDER_LIST)
val hideDeleteRequestDialog = getBoolean(PrefKeys.HIDE_DELETE_REQUEST_DIALOG, false)
val hideBlockAlertDialog = getBoolean(PrefKeys.HIDE_BLOCK_ALERT_DIALOG, false)
@@ -525,6 +529,7 @@ object LocalPreferences {
backupHashtagList = latestHashtagList,
backupGeohashList = latestGeohashList,
backupEphemeralChatList = latestEphemeralList,
backupTrustProviderList = latestTrustProviderList,
lastReadPerRoute = MutableStateFlow(lastReadPerRoute),
hasDonatedInVersion = MutableStateFlow(hasDonatedInVersion),
pendingAttestations = MutableStateFlow(pendingAttestations),
@@ -23,6 +23,9 @@ package com.vitorpamplona.amethyst.model
import androidx.compose.runtime.Stable
import com.vitorpamplona.amethyst.BuildConfig
import com.vitorpamplona.amethyst.LocalPreferences
import com.vitorpamplona.amethyst.commons.model.IAccount
import com.vitorpamplona.amethyst.commons.model.nip18Reposts.RepostAction
import com.vitorpamplona.amethyst.commons.model.nip25Reactions.ReactionAction
import com.vitorpamplona.amethyst.commons.richtext.RichTextParser
import com.vitorpamplona.amethyst.logTime
import com.vitorpamplona.amethyst.model.edits.PrivateStorageRelayListDecryptionCache
@@ -43,8 +46,6 @@ import com.vitorpamplona.amethyst.model.nip02FollowLists.Kind3FollowListState
import com.vitorpamplona.amethyst.model.nip03Timestamp.OtsState
import com.vitorpamplona.amethyst.model.nip17Dms.DmInboxRelayState
import com.vitorpamplona.amethyst.model.nip17Dms.DmRelayListState
import com.vitorpamplona.amethyst.model.nip18Reposts.RepostAction
import com.vitorpamplona.amethyst.model.nip25Reactions.ReactionAction
import com.vitorpamplona.amethyst.model.nip28PublicChats.PublicChatChannel
import com.vitorpamplona.amethyst.model.nip28PublicChats.PublicChatListDecryptionCache
import com.vitorpamplona.amethyst.model.nip28PublicChats.PublicChatListState
@@ -64,6 +65,7 @@ import com.vitorpamplona.amethyst.model.nip51Lists.hashtagLists.HashtagListDecry
import com.vitorpamplona.amethyst.model.nip51Lists.hashtagLists.HashtagListState
import com.vitorpamplona.amethyst.model.nip51Lists.indexerRelays.IndexerRelayListDecryptionCache
import com.vitorpamplona.amethyst.model.nip51Lists.indexerRelays.IndexerRelayListState
import com.vitorpamplona.amethyst.model.nip51Lists.labeledBookmarkLists.LabeledBookmarkListsState
import com.vitorpamplona.amethyst.model.nip51Lists.muteList.MuteListDecryptionCache
import com.vitorpamplona.amethyst.model.nip51Lists.muteList.MuteListState
import com.vitorpamplona.amethyst.model.nip51Lists.peopleList.FollowListsState
@@ -92,6 +94,8 @@ import com.vitorpamplona.amethyst.model.topNavFeeds.FeedDecryptionCaches
import com.vitorpamplona.amethyst.model.topNavFeeds.FeedTopNavFilterState
import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavFilter
import com.vitorpamplona.amethyst.model.topNavFeeds.OutboxLoaderState
import com.vitorpamplona.amethyst.model.trustedAssertions.TrustProviderListDecryptionCache
import com.vitorpamplona.amethyst.model.trustedAssertions.TrustProviderListState
import com.vitorpamplona.amethyst.service.location.LocationState
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.nwc.NWCPaymentFilterAssembler
import com.vitorpamplona.amethyst.service.uploads.FileHeader
@@ -199,8 +203,10 @@ import com.vitorpamplona.quartz.nip94FileMetadata.mimeType
import com.vitorpamplona.quartz.nip94FileMetadata.originalHash
import com.vitorpamplona.quartz.nip94FileMetadata.tags.DimensionTag
import com.vitorpamplona.quartz.nip98HttpAuth.HTTPAuthorizationEvent
import com.vitorpamplona.quartz.nipA0VoiceMessages.BaseVoiceEvent
import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceEvent
import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceReplyEvent
import com.vitorpamplona.quartz.utils.DualCase
import com.vitorpamplona.quartz.utils.Log
import com.vitorpamplona.quartz.utils.containsAny
import kotlinx.coroutines.CoroutineScope
@@ -228,14 +234,21 @@ class Account(
val cache: LocalCache,
val client: INostrClient,
val scope: CoroutineScope,
) {
) : IAccount {
private var userProfileCache: User? = null
fun userProfile(): User = userProfileCache ?: cache.getOrCreateUser(signer.pubKey).also { userProfileCache = it }
override fun userProfile(): User = userProfileCache ?: cache.getOrCreateUser(signer.pubKey).also { userProfileCache = it }
// IAccount interface properties
override val pubKey: String get() = signer.pubKey
override val showSensitiveContent: Boolean? get() = hiddenUsers.flow.value.showSensitiveContent
override val hiddenWordsCase: List<DualCase> get() = hiddenUsers.flow.value.hiddenWordsCase
override val hiddenUsersHashCodes: Set<Int> get() = hiddenUsers.flow.value.hiddenUsersHashCodes
override val spammersHashCodes: Set<Int> get() = hiddenUsers.flow.value.spammersHashCodes
val userMetadata = UserMetadataState(signer, cache, scope, settings)
val nip47SignerState = NwcSignerState(signer, nwcFilterAssembler, cache, scope, settings)
override val nip47SignerState = NwcSignerState(signer, nwcFilterAssembler, cache, scope, settings)
val nip65RelayList = Nip65RelayListState(signer, cache, scope, settings)
val localRelayList = LocalRelayListState(signer, cache, scope, settings)
@@ -283,6 +296,9 @@ class Account(
val muteListDecryptionCache = MuteListDecryptionCache(signer)
val muteList = MuteListState(signer, cache, muteListDecryptionCache, scope, settings)
val trustProviderListDecryptionCache = TrustProviderListDecryptionCache(signer)
val trustProviderList = TrustProviderListState(signer, cache, trustProviderListDecryptionCache, scope, settings)
val peopleListDecryptionCache = PeopleListDecryptionCache(signer)
val blockPeopleList = BlockPeopleListState(signer, cache, peopleListDecryptionCache, scope)
val peopleLists = PeopleListsState(signer, cache, peopleListDecryptionCache, scope)
@@ -290,6 +306,7 @@ class Account(
val hiddenUsers = HiddenUsersState(muteList.flow, blockPeopleList.flow, scope, settings)
val labeledBookmarkLists = LabeledBookmarkListsState(signer, cache, scope)
val bookmarkState = BookmarkListState(signer, cache, scope)
val emoji = EmojiPackState(signer, cache, scope)
@@ -327,7 +344,7 @@ class Account(
val allFollows = MergedFollowListsState(kind3FollowList, peopleLists, followLists, hashtagList, geohashList, communityList, scope)
val privateDMDecryptionCache = PrivateDMCache(signer)
val privateZapsDecryptionCache = PrivateZapCache(signer)
override val privateZapsDecryptionCache = PrivateZapCache(signer)
val draftsDecryptionCache = DraftEventCache(signer)
val chatroomList = cache.getOrCreateChatroomList(signer.pubKey)
@@ -410,7 +427,7 @@ class Account(
val liveNotificationFollowListsPerRelay = OutboxLoaderState(liveNotificationFollowLists, cache, scope).flow
fun isWriteable(): Boolean = settings.isWriteable()
override fun isWriteable(): Boolean = settings.isWriteable()
suspend fun updateWarnReports(warnReports: Boolean): Boolean {
if (settings.updateWarnReports(warnReports)) {
@@ -594,7 +611,7 @@ class Account(
url: String,
method: String,
body: ByteArray? = null,
): HTTPAuthorizationEvent? = signer.sign(HTTPAuthorizationEvent.build(url, method, body))
): HTTPAuthorizationEvent = signer.sign(HTTPAuthorizationEvent.build(url, method, body))
suspend fun createBlossomUploadAuth(
hash: HexKey,
@@ -704,7 +721,7 @@ class Account(
fun computeRelayListToBroadcast(event: Event): Set<NormalizedRelayUrl> {
if (event is MetadataEvent || event is AdvertisedRelayListEvent) {
// everywhere
return followPlusAllMineWithIndex.flow.value + client.relayStatusFlow().value.available
return followPlusAllMineWithIndex.flow.value + client.availableRelaysFlow().value
}
if (event is GiftWrapEvent) {
val receiver = event.recipientPubKey()
@@ -904,7 +921,7 @@ class Account(
}
fun sendLiterallyEverywhere(event: Event) {
client.send(event, followPlusAllMineWithIndex.flow.value + client.relayStatusFlow().value.available)
client.send(event, followPlusAllMineWithIndex.flow.value + client.availableRelaysFlow().value)
cache.justConsumeMyOwnEvent(event)
}
@@ -995,7 +1012,7 @@ class Account(
hash: String,
duration: Int,
waveform: List<Float>,
replyTo: EventHintBundle<VoiceEvent>,
replyTo: EventHintBundle<BaseVoiceEvent>,
) {
signAndComputeBroadcast(VoiceReplyEvent.build(url, mimeType, hash, duration, waveform, replyTo))
}
@@ -1546,6 +1563,15 @@ class Account(
}
}
suspend fun removeBookmark(note: Note) {
if (!isWriteable() || note.isDraft()) return
val event = bookmarkState.removeBookmark(note)
if (event != null) {
sendMyPublicAndPrivateOutbox(event)
}
}
suspend fun createAuthEvent(
relay: NormalizedRelayUrl,
challenge: String,
@@ -1648,15 +1674,21 @@ class Account(
}
if (!settings.syncedSettings.security.warnAboutPostsWithReports) {
return !isHidden(user) &&
// if user hasn't hided this author
user.reportsBy(userProfile()).isEmpty() // if user has not reported this post
if (isHidden(user)) return false
val reports = user.reportsOrNull() ?: return true
return reports.reportsBy(userProfile()).isEmpty() // if user has not reported this post
}
return !isHidden(user) &&
// if user hasn't hided this author
user.reportsBy(userProfile()).isEmpty() &&
if (isHidden(user)) return false
val reports = user.reportsOrNull() ?: return true
// if user hasn't hided this author
return reports.reportsBy(userProfile()).isEmpty() &&
// if user has not reported this post
user.countReportAuthorsBy(followingKeySet()) < 5
reports.countReportAuthorsBy(followingKeySet()) < 5
}
private fun isAcceptableDirect(note: Note): Boolean {
@@ -1709,7 +1741,7 @@ class Account(
return (
note.reportsBy(kind3FollowList.flow.value.authorsPlusMe) +
(note.author?.reportsBy(kind3FollowList.flow.value.authorsPlusMe) ?: emptyList()) +
(note.author?.reportsOrNull()?.reportsBy(kind3FollowList.flow.value.authorsPlusMe) ?: emptyList()) +
innerReports
).toSet()
}
@@ -1777,6 +1809,7 @@ class Account(
newNotesPreProcessor.runNew(newNotes)
peopleLists.newNotes(newNotes)
followLists.newNotes(newNotes)
labeledBookmarkLists.newNotes(newNotes)
}
}
}
@@ -1787,6 +1820,7 @@ class Account(
newNotesPreProcessor.runDeleted(deletedNotes)
peopleLists.deletedNotes(deletedNotes)
followLists.deletedNotes(deletedNotes)
labeledBookmarkLists.deletedNotes(deletedNotes)
}
}
}
@@ -25,6 +25,7 @@ import com.vitorpamplona.amethyst.ui.actions.mediaServers.DEFAULT_MEDIA_SERVERS
import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerName
import com.vitorpamplona.amethyst.ui.screen.FeedDefinition
import com.vitorpamplona.quartz.experimental.ephemChat.list.EphemeralChatListEvent
import com.vitorpamplona.quartz.experimental.trustedAssertions.list.TrustProviderListEvent
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
@@ -138,6 +139,7 @@ class AccountSettings(
var backupHashtagList: HashtagListEvent? = null,
var backupGeohashList: GeohashListEvent? = null,
var backupEphemeralChatList: EphemeralChatListEvent? = null,
var backupTrustProviderList: TrustProviderListEvent? = null,
val lastReadPerRoute: MutableStateFlow<Map<String, MutableStateFlow<Long>>> = MutableStateFlow(mapOf()),
var hasDonatedInVersion: MutableStateFlow<Set<String>> = MutableStateFlow(setOf<String>()),
val pendingAttestations: MutableStateFlow<Map<HexKey, String>> = MutableStateFlow<Map<HexKey, String>>(mapOf()),
@@ -437,6 +439,16 @@ class AccountSettings(
}
}
fun updateTrustProviderListTo(trustProviderList: TrustProviderListEvent?) {
if (trustProviderList == null || trustProviderList.tags.isEmpty()) return
// Events might be different objects, we have to compare their ids.
if (backupTrustProviderList?.id != trustProviderList.id) {
backupTrustProviderList = trustProviderList
saveAccountSettings()
}
}
fun updateMuteList(newMuteList: MuteListEvent?) {
if (newMuteList == null || newMuteList.tags.isEmpty()) return
@@ -95,7 +95,7 @@ class AntiSpamFilter {
if (spammer.shouldHide() && relay != null) {
Amethyst.instance.relayStats
.get(relay)
.newSpam("$link1 $link2")
.newSpam(link1, link2)
}
flowSpam.tryEmit(AntiSpamState(this))
@@ -123,7 +123,7 @@ class AntiSpamFilter {
if (spammer.shouldHide() && relay != null) {
Amethyst.instance.relayStats
.get(relay)
.newSpam("$link1 $link2")
.newSpam(link1, link2)
}
flowSpam.tryEmit(AntiSpamState(this))
@@ -22,7 +22,6 @@ package com.vitorpamplona.amethyst.model
import androidx.compose.runtime.Stable
import com.vitorpamplona.amethyst.ui.dal.DefaultFeedOrder
import com.vitorpamplona.amethyst.ui.dal.ListChange
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.utils.cache.LargeCache
@@ -0,0 +1,39 @@
/**
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.model
sealed class ListChange<out T> {
data class Addition<T>(
val item: T,
) : ListChange<T>()
data class Deletion<T>(
val item: T,
) : ListChange<T>()
data class SetAddition<T>(
val item: Set<T>,
) : ListChange<T>()
data class SetDeletion<T>(
val item: Set<T>,
) : ListChange<T>()
}
@@ -31,9 +31,9 @@ import com.vitorpamplona.amethyst.model.nip53LiveActivities.LiveActivitiesChanne
import com.vitorpamplona.amethyst.model.observables.LatestByKindAndAuthor
import com.vitorpamplona.amethyst.model.observables.LatestByKindWithETag
import com.vitorpamplona.amethyst.model.privateChats.ChatroomList
import com.vitorpamplona.amethyst.service.BundledInsert
import com.vitorpamplona.amethyst.service.checkNotInMainThread
import com.vitorpamplona.amethyst.ui.note.dateFormatter
import com.vitorpamplona.ammolite.relays.BundledInsert
import com.vitorpamplona.quartz.experimental.audio.header.AudioHeaderEvent
import com.vitorpamplona.quartz.experimental.audio.track.AudioTrackEvent
import com.vitorpamplona.quartz.experimental.edits.TextNoteModificationEvent
@@ -50,6 +50,7 @@ import com.vitorpamplona.quartz.experimental.nns.NNSEvent
import com.vitorpamplona.quartz.experimental.profileGallery.ProfileGalleryEntryEvent
import com.vitorpamplona.quartz.experimental.publicMessages.PublicMessageEvent
import com.vitorpamplona.quartz.experimental.relationshipStatus.ContactCardEvent
import com.vitorpamplona.quartz.experimental.trustedAssertions.list.TrustProviderListEvent
import com.vitorpamplona.quartz.experimental.zapPolls.PollNoteEvent
import com.vitorpamplona.quartz.nip01Core.core.Address
import com.vitorpamplona.quartz.nip01Core.core.AddressableEvent
@@ -134,6 +135,7 @@ import com.vitorpamplona.quartz.nip51Lists.bookmarkList.BookmarkListEvent
import com.vitorpamplona.quartz.nip51Lists.followList.FollowListEvent
import com.vitorpamplona.quartz.nip51Lists.geohashList.GeohashListEvent
import com.vitorpamplona.quartz.nip51Lists.hashtagList.HashtagListEvent
import com.vitorpamplona.quartz.nip51Lists.labeledBookmarkList.LabeledBookmarkListEvent
import com.vitorpamplona.quartz.nip51Lists.muteList.MuteListEvent
import com.vitorpamplona.quartz.nip51Lists.peopleList.PeopleListEvent
import com.vitorpamplona.quartz.nip51Lists.relayLists.BlockedRelayListEvent
@@ -796,9 +798,6 @@ object LocalCache : ILocalCache {
is ReactionEvent ->
event.originalPost().mapNotNull { checkGetOrCreateNote(it) } +
event.taggedAddresses().map { getOrCreateAddressableNote(it) }
is ReportEvent ->
event.reportedPost().mapNotNull { checkGetOrCreateNote(it.eventId) } +
event.reportedAddresses().map { getOrCreateAddressableNote(it.address) }
is ChannelMessageEvent ->
event
.tagsWithoutCitations()
@@ -862,6 +861,12 @@ object LocalCache : ILocalCache {
return false
}
fun consume(
event: LabeledBookmarkListEvent,
relay: NormalizedRelayUrl?,
wasVerified: Boolean,
) = consumeBaseReplaceable(event, relay, wasVerified)
fun consume(
event: MuteListEvent,
relay: NormalizedRelayUrl?,
@@ -964,6 +969,12 @@ object LocalCache : ILocalCache {
wasVerified: Boolean,
) = consumeBaseReplaceable(event, relay, wasVerified)
private fun consume(
event: TrustProviderListEvent,
relay: NormalizedRelayUrl?,
wasVerified: Boolean,
) = consumeBaseReplaceable(event, relay, wasVerified)
private fun consume(
event: ProxyRelayListEvent,
relay: NormalizedRelayUrl?,
@@ -1082,11 +1093,25 @@ object LocalCache : ILocalCache {
return false
}
fun Event.toNote() = getOrCreateNote(id)
fun AddressableEvent.toAddressableNote() = getOrCreateAddressableNote(address())
fun consume(
event: ContactCardEvent,
relay: NormalizedRelayUrl?,
wasVerified: Boolean,
) = consumeBaseReplaceable(event, relay, wasVerified)
): Boolean {
val note = event.toAddressableNote()
val new = consumeBaseReplaceable(event, relay, wasVerified)
if (new) {
val about = checkGetOrCreateUser(event.aboutUser()) ?: return new
about.cards().addCard(note)
}
return new
}
fun consume(
event: OtsEvent,
@@ -1318,14 +1343,25 @@ object LocalCache : ILocalCache {
private fun deleteNote(deleteNote: Note) {
val deletedEvent = deleteNote.event
val mentions =
deleteNote.event
?.tags
?.filter { it.firstOrNull() == "p" }
?.mapNotNull { it.getOrNull(1) }
?.mapNotNull { checkGetOrCreateUser(it) }
if (deletedEvent is ReportEvent) {
deletedEvent.reportedAuthor().forEach {
getUserIfExists(it.pubkey)?.reportsOrNull()?.removeReport(deleteNote)
}
}
mentions?.forEach { user -> user.removeReport(deleteNote) }
if (deleteNote is AddressableNote && deletedEvent is ContactCardEvent) {
getUserIfExists(deletedEvent.aboutUser())?.cardsOrNull()?.removeCard(deleteNote)
}
if (deletedEvent is TorrentCommentEvent) {
deletedEvent.torrentIds()?.let {
getNoteIfExists(it)?.removeReply(deleteNote)
}
}
if (deletedEvent is WrappedEvent) {
deleteWraps(deletedEvent)
}
// Counts the replies
deleteNote.replyTo?.forEach { masterNote ->
@@ -1336,16 +1372,8 @@ object LocalCache : ILocalCache {
getAnyChannel(deleteNote)?.removeNote(deleteNote)
(deletedEvent as? TorrentCommentEvent)?.torrentIds()?.let {
getNoteIfExists(it)?.removeReply(deleteNote)
}
notes.remove(deleteNote.idHex)
if (deletedEvent is WrappedEvent) {
deleteWraps(deletedEvent)
}
deleteNote.clearFlow()
refreshDeletedNoteObservers(deleteNote)
@@ -1497,42 +1525,23 @@ object LocalCache : ILocalCache {
wasVerified: Boolean,
): Boolean {
val note = getOrCreateNote(event.id)
val author = getOrCreateUser(event.pubKey)
if (relay != null) {
author.addRelayBeingUsed(relay, event.createdAt)
note.addRelay(relay)
}
val new = consumeRegularEvent(event, relay, wasVerified)
// Already processed this event.
if (note.event != null) return false
if (new) {
val authorsReported = event.reportedAuthor().mapNotNull { checkGetOrCreateUser(it.pubkey) }
val eventsReported =
event.reportedPost().mapNotNull { checkGetOrCreateNote(it.eventId) } +
event.reportedAddresses().map { getOrCreateAddressableNote(it.address) }
if (wasVerified || justVerify(event)) {
val mentions = event.reportedAuthor().mapNotNull { checkGetOrCreateUser(it.pubkey) }
val repliesTo = computeReplyTo(event)
note.loadEvent(event, author, repliesTo)
// Log.d("RP", "New Report ${event.content} by ${note.author?.toBestDisplayName()}
// ${formattedDateTime(event.createdAt)}")
// Adds notifications to users.
if (repliesTo.isEmpty()) {
mentions.forEach { it.addReport(note) }
if (eventsReported.isEmpty()) {
authorsReported.forEach { author -> author.reports().addReport(note) }
} else {
repliesTo.forEach { it.addReport(note) }
mentions.forEach {
// doesn't add to reports, but triggers recounts
it.flowSet?.reports?.invalidateData()
}
eventsReported.forEach { it.addReport(note) }
}
refreshNewNoteObservers(note)
return true
}
return false
return new
}
fun consume(
@@ -2456,10 +2465,20 @@ object LocalCache : ILocalCache {
}
}
if (noteEvent is ReportEvent) {
noteEvent.reportedAuthor().mapNotNull {
val author = getUserIfExists(it.pubkey)
author?.removeReport(note)
noteEvent.reportedAuthor().forEach {
getUserIfExists(it.pubkey)?.reportsOrNull()?.removeReport(note)
}
noteEvent.reportedPost().forEach {
getNoteIfExists(it.eventId)?.removeReport(note)
}
noteEvent.reportedAddresses().forEach {
getAddressableNoteIfExists(it.address)?.removeReport(note)
}
}
if (note is AddressableNote && noteEvent is ContactCardEvent) {
getUserIfExists(noteEvent.aboutUser())?.cardsOrNull()?.removeCard(note)
}
note.clearFlow()
@@ -2477,19 +2496,25 @@ object LocalCache : ILocalCache {
checkNotInMainThread()
val now = TimeUtils.now()
val toBeRemoved = notes.filter { _, it -> it.event?.isExpirationBefore(now) == true }
val versionsToBeRemoved = notes.filter { _, it -> it.event?.isExpirationBefore(now) == true }
val addressesToBeRemoved = addressables.filter { _, it -> it.event?.isExpirationBefore(now) == true }
val childrenToBeRemoved = mutableListOf<Note>()
toBeRemoved.forEach {
versionsToBeRemoved.forEach {
removeFromCache(it)
childrenToBeRemoved.addAll(it.removeAllChildNotes())
}
addressesToBeRemoved.forEach {
removeFromCache(it)
childrenToBeRemoved.addAll(it.removeAllChildNotes())
}
removeFromCache(childrenToBeRemoved)
if (toBeRemoved.size > 1) {
println("PRUNE: ${toBeRemoved.size} thread replies removed.")
if (versionsToBeRemoved.size > 1 || addressesToBeRemoved.size > 1) {
println("PRUNE: ${versionsToBeRemoved.size} events and ${addressesToBeRemoved.size} expired.")
}
}
@@ -2811,6 +2836,7 @@ object LocalCache : ILocalCache {
is InteractiveStoryPrologueEvent -> consume(event, relay, wasVerified)
is InteractiveStorySceneEvent -> consume(event, relay, wasVerified)
is InteractiveStoryReadingStateEvent -> consume(event, relay, wasVerified)
is LabeledBookmarkListEvent -> consume(event, relay, wasVerified)
is LiveActivitiesEvent -> consume(event, relay, wasVerified)
is LiveActivitiesChatMessageEvent -> consume(event, relay, wasVerified)
is LnZapEvent -> consume(event, relay, wasVerified)
@@ -2848,6 +2874,7 @@ object LocalCache : ILocalCache {
is TorrentEvent -> consume(event, relay, wasVerified)
is TorrentCommentEvent -> consume(event, relay, wasVerified)
is TrustedRelayListEvent -> consume(event, relay, wasVerified)
is TrustProviderListEvent -> consume(event, relay, wasVerified)
is VideoHorizontalEvent -> consume(event, relay, wasVerified)
is VideoNormalEvent -> consume(event, relay, wasVerified)
is VideoVerticalEvent -> consume(event, relay, wasVerified)
@@ -2862,7 +2889,7 @@ object LocalCache : ILocalCache {
}
} catch (e: Exception) {
if (e is CancellationException) throw e
Log.w("LocalCache", "Cannot consume ${event.kind}", e)
Log.w("LocalCache", "Cannot consume ${event.toJson()} from ${relay?.url}", e)
false
}
File diff suppressed because it is too large Load Diff
@@ -20,6 +20,7 @@
*/
package com.vitorpamplona.amethyst.model
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.combine
@@ -36,23 +37,24 @@ class UiSettingsFlow(
val featureSet: MutableStateFlow<FeatureSetType> = MutableStateFlow(FeatureSetType.SIMPLIFIED),
val gallerySet: MutableStateFlow<ProfileGalleryType> = MutableStateFlow(ProfileGalleryType.CLASSIC),
) {
val listOfFlows: List<Flow<Any?>> =
listOf<Flow<Any?>>(
theme,
preferredLanguage,
automaticallyShowImages,
automaticallyStartPlayback,
automaticallyShowUrlPreview,
automaticallyHideNavigationBars,
automaticallyShowProfilePictures,
dontShowPushNotificationSelector,
dontAskForNotificationPermissions,
featureSet,
gallerySet,
)
// emits at every change in any of the propertyes.
val propertyWatchFlow =
combine(
listOf(
theme,
preferredLanguage,
automaticallyShowImages,
automaticallyStartPlayback,
automaticallyShowUrlPreview,
automaticallyHideNavigationBars,
automaticallyShowProfilePictures,
dontShowPushNotificationSelector,
dontAskForNotificationPermissions,
featureSet,
gallerySet,
),
) { flows ->
val propertyWatchFlow: Flow<UiSettings> =
combine<Any?, UiSettings>(listOfFlows) { flows: Array<Any?> ->
UiSettings(
flows[0] as ThemeType,
flows[1] as String?,
@@ -20,362 +20,10 @@
*/
package com.vitorpamplona.amethyst.model
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.Stable
import com.vitorpamplona.amethyst.ui.note.toShortDisplay
import com.vitorpamplona.quartz.lightning.Lud06
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.toImmutableListOfLists
import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
import com.vitorpamplona.quartz.nip01Core.metadata.UserMetadata
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.tags.geohash.isTaggedGeoHash
import com.vitorpamplona.quartz.nip01Core.tags.hashtags.isTaggedHash
import com.vitorpamplona.quartz.nip01Core.tags.people.PTag
import com.vitorpamplona.quartz.nip01Core.tags.people.isTaggedUser
import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent
import com.vitorpamplona.quartz.nip17Dm.settings.ChatMessageRelayListEvent
import com.vitorpamplona.quartz.nip19Bech32.entities.NProfile
import com.vitorpamplona.quartz.nip19Bech32.toNpub
import com.vitorpamplona.quartz.nip56Reports.ReportEvent
import com.vitorpamplona.quartz.nip56Reports.ReportType
import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent
import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent
import com.vitorpamplona.quartz.utils.DualCase
import com.vitorpamplona.quartz.utils.Hex
import com.vitorpamplona.quartz.utils.containsAny
import kotlinx.coroutines.flow.MutableStateFlow
import java.math.BigDecimal
@Stable
class User(
val pubkeyHex: String,
val nip65RelayListNote: Note,
val dmRelayListNote: Note,
) {
var info: UserMetadata? = null
var latestMetadata: MetadataEvent? = null
var latestMetadataRelay: NormalizedRelayUrl? = null
var latestContactList: ContactListEvent? = null
var reports = mapOf<User, Set<Note>>()
private set
var zaps = mapOf<Note, Note?>()
private set
var relaysBeingUsed = mapOf<NormalizedRelayUrl, RelayInfo>()
private set
fun pubkey() = Hex.decode(pubkeyHex)
fun pubkeyNpub() = pubkey().toNpub()
fun pubkeyDisplayHex() = pubkeyNpub().toShortDisplay()
fun dmInboxRelayList() = dmRelayListNote.event as? ChatMessageRelayListEvent
fun authorRelayList() = nip65RelayListNote.event as? AdvertisedRelayListEvent
fun toNProfile() = NProfile.create(pubkeyHex, relayHints())
fun outboxRelays() = authorRelayList()?.writeRelaysNorm()
fun relayHints() = authorRelayList()?.writeRelaysNorm()?.take(3) ?: listOfNotNull(latestMetadataRelay)
fun inboxRelays() = authorRelayList()?.readRelaysNorm()
fun dmInboxRelays() = dmInboxRelayList()?.relays()?.ifEmpty { null } ?: inboxRelays()
fun bestRelayHint() = authorRelayList()?.writeRelaysNorm()?.firstOrNull() ?: latestMetadataRelay
fun toPTag() = PTag(pubkeyHex, bestRelayHint())
fun toNostrUri() = "nostr:${toNProfile()}"
fun toBestShortFirstName(): String {
val fullName = toBestDisplayName()
val names = fullName.split(' ')
val firstName =
if (names[0].length <= 3) {
// too short. Remove Dr.
"${names[0]} ${names.getOrNull(1) ?: ""}"
} else {
names[0]
}
return firstName
}
fun toBestDisplayName(): String = info?.bestName() ?: pubkeyDisplayHex()
fun nip05(): String? = info?.nip05
fun profilePicture(): String? = info?.picture
fun updateContactList(event: ContactListEvent) {
if (event.id == latestContactList?.id) return
val oldContactListEvent = latestContactList
latestContactList = event
// Update following of the current user
flowSet?.follows?.invalidateData()
// Update Followers of the past user list
// Update Followers of the new contact list
(oldContactListEvent)?.unverifiedFollowKeySet()?.forEach {
LocalCache
.getUserIfExists(it)
?.flowSet
?.followers
?.invalidateData()
}
(latestContactList)?.unverifiedFollowKeySet()?.forEach {
LocalCache
.getUserIfExists(it)
?.flowSet
?.followers
?.invalidateData()
}
}
fun addReport(note: Note) {
val author = note.author ?: return
val reportsBy = reports[author]
if (reportsBy == null) {
reports = reports + Pair(author, setOf(note))
flowSet?.reports?.invalidateData()
} else if (!reportsBy.contains(note)) {
reports = reports + Pair(author, reportsBy + note)
flowSet?.reports?.invalidateData()
}
}
fun removeReport(deleteNote: Note) {
val author = deleteNote.author ?: return
if (reports[author]?.contains(deleteNote) == true) {
reports[author]?.let {
reports = reports + Pair(author, it.minus(deleteNote))
flowSet?.reports?.invalidateData()
}
}
}
fun addZap(
zapRequest: Note,
zap: Note?,
) {
if (zaps[zapRequest] == null) {
zaps = zaps + Pair(zapRequest, zap)
flowSet?.zaps?.invalidateData()
}
}
fun removeZap(zapRequestOrZapEvent: Note) {
if (zaps.containsKey(zapRequestOrZapEvent)) {
zaps = zaps.minus(zapRequestOrZapEvent)
flowSet?.zaps?.invalidateData()
} else if (zaps.containsValue(zapRequestOrZapEvent)) {
zaps = zaps.filter { it.value != zapRequestOrZapEvent }
flowSet?.zaps?.invalidateData()
}
}
fun zappedAmount(): BigDecimal {
var amount = BigDecimal.ZERO
zaps.forEach {
val itemValue = (it.value?.event as? LnZapEvent)?.amount
if (itemValue != null) {
amount += itemValue
}
}
return amount
}
fun reportsBy(user: User): Set<Note> = reports[user] ?: emptySet()
fun countReportAuthorsBy(users: Set<HexKey>): Int = reports.count { it.key.pubkeyHex in users }
fun reportsBy(users: Set<HexKey>): List<Note> =
reports
.mapNotNull {
if (it.key.pubkeyHex in users) {
it.value
} else {
null
}
}.flatten()
fun addRelayBeingUsed(
relay: NormalizedRelayUrl,
eventTime: Long,
) {
val here = relaysBeingUsed[relay]
if (here == null) {
relaysBeingUsed = relaysBeingUsed + Pair(relay, RelayInfo(relay, eventTime, 1))
} else {
if (eventTime > here.lastEvent) {
here.lastEvent = eventTime
}
here.counter++
}
flowSet?.usedRelays?.invalidateData()
}
fun updateUserInfo(
newUserInfo: UserMetadata,
latestMetadata: MetadataEvent,
) {
info = newUserInfo
info?.tags = latestMetadata.tags.toImmutableListOfLists()
info?.cleanBlankNames()
if (newUserInfo.lud16.isNullOrBlank()) {
info?.lud06?.let {
if (it.lowercase().startsWith("lnurl")) {
info?.lud16 = Lud06().toLud16(it)
}
}
}
flowSet?.metadata?.invalidateData()
}
fun isFollowing(user: User): Boolean = latestContactList?.isTaggedUser(user.pubkeyHex) ?: false
fun isFollowingHashtag(tag: String) = latestContactList?.isTaggedHash(tag) ?: false
fun isFollowingGeohash(geoTag: String) = latestContactList?.isTaggedGeoHash(geoTag) ?: false
fun transientFollowCount(): Int? = latestContactList?.unverifiedFollowKeySet()?.size
suspend fun transientFollowerCount(): Int = LocalCache.users.count { _, it -> it.latestContactList?.isTaggedUser(pubkeyHex) ?: false }
fun hasReport(
loggedIn: User,
type: ReportType,
): Boolean =
reports[loggedIn]?.firstOrNull {
(it.event as? ReportEvent)?.reportedAuthor()?.any { it.type == type } ?: false
} != null
fun containsAny(hiddenWordsCase: List<DualCase>): Boolean {
if (hiddenWordsCase.isEmpty()) return false
if (toBestDisplayName().containsAny(hiddenWordsCase)) {
return true
}
if (profilePicture()?.containsAny(hiddenWordsCase) == true) {
return true
}
if (info?.banner?.containsAny(hiddenWordsCase) == true) {
return true
}
if (info?.about?.containsAny(hiddenWordsCase) == true) {
return true
}
if (info?.lud06?.containsAny(hiddenWordsCase) == true) {
return true
}
if (info?.lud16?.containsAny(hiddenWordsCase) == true) {
return true
}
if (info?.nip05?.containsAny(hiddenWordsCase) == true) {
return true
}
return false
}
fun anyNameStartsWith(username: String): Boolean = info?.anyNameStartsWith(username) ?: false
var flowSet: UserFlowSet? = null
@Synchronized
fun createOrDestroyFlowSync(create: Boolean) {
if (create) {
if (flowSet == null) {
flowSet = UserFlowSet(this)
}
} else {
if (flowSet != null && flowSet?.isInUse() == false) {
flowSet = null
}
}
}
fun flow(): UserFlowSet {
if (flowSet == null) {
createOrDestroyFlowSync(true)
}
return flowSet!!
}
fun clearFlow() {
if (flowSet != null && flowSet?.isInUse() == false) {
createOrDestroyFlowSync(false)
}
}
}
@Stable
class UserFlowSet(
u: User,
) {
// Observers line up here.
val metadata = UserBundledRefresherFlow(u)
val follows = UserBundledRefresherFlow(u)
val followers = UserBundledRefresherFlow(u)
val reports = UserBundledRefresherFlow(u)
val usedRelays = UserBundledRefresherFlow(u)
val zaps = UserBundledRefresherFlow(u)
val statuses = UserBundledRefresherFlow(u)
fun isInUse(): Boolean =
metadata.hasObservers() ||
follows.hasObservers() ||
followers.hasObservers() ||
reports.hasObservers() ||
usedRelays.hasObservers() ||
zaps.hasObservers() ||
statuses.hasObservers()
}
@Immutable
data class RelayInfo(
val url: NormalizedRelayUrl,
var lastEvent: Long,
var counter: Long,
)
@Stable
class UserBundledRefresherFlow(
val user: User,
) {
val stateFlow = MutableStateFlow(UserState(user))
fun invalidateData() {
stateFlow.tryEmit(UserState(user))
}
fun hasObservers() = stateFlow.subscriptionCount.value > 0
}
@Immutable
class UserState(
val user: User,
)
// Re-export from commons for backwards compatibility
typealias UserDependencies = com.vitorpamplona.amethyst.commons.model.UserDependencies
typealias User = com.vitorpamplona.amethyst.commons.model.User
typealias UserFlowSet = com.vitorpamplona.amethyst.commons.model.UserFlowSet
typealias RelayInfo = com.vitorpamplona.amethyst.commons.model.RelayInfo
typealias UserBundledRefresherFlow = com.vitorpamplona.amethyst.commons.model.UserBundledRefresherFlow
typealias UserState = com.vitorpamplona.amethyst.commons.model.UserState
@@ -50,6 +50,7 @@ class UserMetadataState(
suspend fun sendNewUserMetadata(
name: String? = null,
displayName: String? = null,
picture: String? = null,
banner: String? = null,
website: String? = null,
@@ -69,7 +70,7 @@ class UserMetadataState(
MetadataEvent.updateFromPast(
latest = latest,
name = name,
displayName = name,
displayName = displayName,
picture = picture,
banner = banner,
website = website,
@@ -85,7 +86,7 @@ class UserMetadataState(
} else {
MetadataEvent.createNew(
name = name,
displayName = name,
displayName = displayName,
picture = picture,
banner = banner,
website = website,
@@ -1,103 +0,0 @@
/**
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.model.nip25Reactions
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nip17Dm.NIP17Factory
import com.vitorpamplona.quartz.nip17Dm.base.NIP17Group
import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent
import com.vitorpamplona.quartz.nip30CustomEmoji.EmojiUrlTag
class ReactionAction {
companion object {
suspend fun reactTo(
note: Note,
reaction: String,
by: User,
signer: NostrSigner,
onPublic: (ReactionEvent) -> Unit,
onPrivate: suspend (NIP17Factory.Result) -> Unit,
) {
if (!signer.isWriteable()) return
if (note.hasReacted(by, reaction)) {
// has already liked this note
return
}
val noteEvent = note.event
if (noteEvent is NIP17Group) {
val users = noteEvent.groupMembers().toList()
if (reaction.startsWith(":")) {
val emojiUrl = EmojiUrlTag.decode(reaction)
if (emojiUrl != null) {
note.toEventHint<Event>()?.let {
onPrivate(
NIP17Factory().createReactionWithinGroup(
emojiUrl = emojiUrl,
originalNote = it,
to = users,
signer = signer,
),
)
}
return
}
}
note.toEventHint<Event>()?.let {
onPrivate(
NIP17Factory().createReactionWithinGroup(
content = reaction,
originalNote = it,
to = users,
signer = signer,
),
)
}
return
} else {
if (reaction.startsWith(":")) {
val emojiUrl = EmojiUrlTag.decode(reaction)
if (emojiUrl != null) {
note.event?.let {
val template = ReactionEvent.build(emojiUrl, EventHintBundle(it, note.relayHintUrl()))
onPublic(signer.sign(template))
}
return
}
}
note.toEventHint<Event>()?.let {
onPublic(signer.sign(ReactionEvent.build(reaction, it)))
}
}
}
}
}
@@ -20,6 +20,7 @@
*/
package com.vitorpamplona.amethyst.model.nip47WalletConnect
import com.vitorpamplona.amethyst.commons.model.INwcSignerState
import com.vitorpamplona.amethyst.model.AccountSettings
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
@@ -66,7 +67,7 @@ class NwcSignerState(
val cache: LocalCache,
val scope: CoroutineScope,
val settings: AccountSettings,
) {
) : INwcSignerState {
/**
* Derives a NIP-47 signer from the zap payment request in settings.
* If there's no valid configuration, it defaults to the main signer.
@@ -112,7 +113,7 @@ class NwcSignerState(
fun hasWalletConnectSetup(): Boolean = settings.zapPaymentRequest.value != null
fun isNIP47Author(pubkeyHex: String?): Boolean = nip47Signer.value.pubKey == pubkeyHex
override fun isNIP47Author(pubkeyHex: String?): Boolean = nip47Signer.value.pubKey == pubkeyHex
/**
* Decrypts a NIP-47 payment request using the current signer.
@@ -120,7 +121,7 @@ class NwcSignerState(
* @param nwcRequest the NIP-47 payment request event to decrypt
* @return the decrypted request or null if not set up or decryption fails
*/
suspend fun decryptRequest(nwcRequest: LnZapPaymentRequestEvent): Request? {
override suspend fun decryptRequest(nwcRequest: LnZapPaymentRequestEvent): Request? {
if (!hasWalletConnectSetup()) return null
return zapPaymentRequestDecryptionCache.value.decryptRequest(nwcRequest)
}
@@ -131,7 +132,7 @@ class NwcSignerState(
* @param nwsResponse the NIP-47 payment response event to decrypt
* @return the decrypted response or null if not set up or decryption fails
*/
suspend fun decryptResponse(nwsResponse: LnZapPaymentResponseEvent): Response? {
override suspend fun decryptResponse(nwsResponse: LnZapPaymentResponseEvent): Response? {
if (!hasWalletConnectSetup()) return null
return zapPaymentResponseDecryptionCache.value.decryptResponse(nwsResponse)
}
@@ -20,6 +20,7 @@
*/
package com.vitorpamplona.amethyst.model.nip51Lists
import androidx.compose.runtime.Stable
import com.vitorpamplona.amethyst.model.AddressableNote
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
@@ -41,6 +42,7 @@ import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.flow.stateIn
@Stable
class BookmarkListState(
val signer: NostrSigner,
val cache: LocalCache,
@@ -274,4 +276,26 @@ class BookmarkListState(
null
}
}
suspend fun removeBookmark(note: Note): BookmarkListEvent? {
val bookmarkList = getBookmarkList()
return if (bookmarkList != null) {
if (note is AddressableNote) {
BookmarkListEvent.remove(
earlierVersion = bookmarkList,
bookmarkIdTag = AddressBookmark(note.address, note.relayHintUrl()),
signer = signer,
)
} else {
BookmarkListEvent.remove(
earlierVersion = bookmarkList,
bookmarkIdTag = EventBookmark(note.idHex, note.relayHintUrl()),
signer = signer,
)
}
} else {
null
}
}
}
@@ -20,7 +20,7 @@
*/
package com.vitorpamplona.amethyst.model.nip51Lists
import androidx.compose.runtime.Immutable
import com.vitorpamplona.amethyst.commons.model.LiveHiddenUsers
import com.vitorpamplona.amethyst.model.AccountSettings
import com.vitorpamplona.amethyst.service.checkNotInMainThread
import com.vitorpamplona.quartz.nip01Core.core.HexKey
@@ -47,33 +47,25 @@ class HiddenUsersState(
) {
var transientHiddenUsers: MutableStateFlow<Set<String>> = MutableStateFlow(setOf())
@Immutable
class LiveHiddenUsers(
val hiddenUsers: Set<String>,
val spammers: Set<String>,
val hiddenWords: Set<String>,
val showSensitiveContent: Boolean?,
) {
// speeds up isHidden calculations
val hiddenUsersHashCodes = hiddenUsers.mapTo(HashSet()) { it.hashCode() }
val spammersHashCodes = spammers.mapTo(HashSet()) { it.hashCode() }
val hiddenWordsCase = hiddenWords.map { DualCase(it.lowercase(), it.uppercase()) }
fun isUserHidden(userHex: HexKey) = hiddenUsers.contains(userHex) || spammers.contains(userHex)
}
suspend fun assembleLiveHiddenUsers(
blockList: List<MuteTag>,
muteList: List<MuteTag>,
transientHiddenUsers: Set<String>,
showSensitiveContent: Boolean?,
): LiveHiddenUsers =
LiveHiddenUsers(
hiddenUsers = blockList.mapNotNullTo(mutableSetOf()) { if (it is UserTag) it.pubKey else null } + muteList.mapNotNull { if (it is UserTag) it.pubKey else null },
hiddenWords = blockList.mapNotNullTo(mutableSetOf()) { if (it is WordTag) it.word else null } + muteList.mapNotNull { if (it is WordTag) it.word else null },
spammers = transientHiddenUsers,
): LiveHiddenUsers {
val hiddenUsers = blockList.mapNotNullTo(mutableSetOf()) { if (it is UserTag) it.pubKey else null } + muteList.mapNotNull { if (it is UserTag) it.pubKey else null }
val hiddenWords = blockList.mapNotNullTo(mutableSetOf()) { if (it is WordTag) it.word else null } + muteList.mapNotNull { if (it is WordTag) it.word else null }
return LiveHiddenUsers(
showSensitiveContent = showSensitiveContent,
hiddenWordsCase = hiddenWords.map { DualCase(it.lowercase(), it.uppercase()) },
hiddenUsersHashCodes = hiddenUsers.mapTo(HashSet()) { it.hashCode() },
spammersHashCodes = transientHiddenUsers.mapTo(HashSet()) { it.hashCode() },
hiddenUsers = hiddenUsers,
spammers = transientHiddenUsers,
hiddenWords = hiddenWords,
)
}
val flow: StateFlow<LiveHiddenUsers> =
combineTransform(
@@ -97,7 +89,15 @@ class HiddenUsersState(
.stateIn(
scope,
SharingStarted.Eagerly,
LiveHiddenUsers(emptySet(), emptySet(), emptySet(), null),
LiveHiddenUsers(
showSensitiveContent = null,
hiddenWordsCase = emptyList(),
hiddenUsersHashCodes = emptySet(),
spammersHashCodes = emptySet(),
hiddenUsers = emptySet(),
spammers = emptySet(),
hiddenWords = emptySet(),
),
)
fun resetTransientUsers() {
@@ -89,9 +89,9 @@ class HashtagListState(
val hashtagList = getHashtagList()
return if (hashtagList == null) {
HashtagListEvent.create(hashtag, true, signer)
HashtagListEvent.create(hashtag.lowercase(), true, signer)
} else {
HashtagListEvent.add(hashtagList, hashtag, true, signer)
HashtagListEvent.add(hashtagList, hashtag.lowercase(), true, signer)
}
}
@@ -0,0 +1,42 @@
/**
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.model.nip51Lists.labeledBookmarkLists
import androidx.compose.runtime.Stable
import com.vitorpamplona.quartz.nip51Lists.bookmarkList.tags.AddressBookmark
import com.vitorpamplona.quartz.nip51Lists.bookmarkList.tags.BookmarkIdTag
import com.vitorpamplona.quartz.nip51Lists.bookmarkList.tags.EventBookmark
@Stable
data class LabeledBookmarkList(
val identifier: String,
val title: String,
val description: String?,
val image: String?,
val privateBookmarks: Set<BookmarkIdTag> = emptySet(),
val publicBookmarks: Set<BookmarkIdTag> = emptySet(),
) {
val privatePostBookmarks = privateBookmarks.filter { it is EventBookmark }.map { bookmarkIdTag -> bookmarkIdTag as EventBookmark }
val publicPostBookmarks = publicBookmarks.filter { it is EventBookmark }.map { bookmarkIdTag -> bookmarkIdTag as EventBookmark }
val privateArticleBookmarks = privateBookmarks.filter { it is AddressBookmark }.map { bookmarkIdTag -> bookmarkIdTag as AddressBookmark }
val publicArticleBookmarks = publicBookmarks.filter { it is AddressBookmark }.map { bookmarkIdTag -> bookmarkIdTag as AddressBookmark }
}
@@ -0,0 +1,301 @@
/**
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.model.nip51Lists.labeledBookmarkLists
import com.vitorpamplona.amethyst.commons.model.anyNotNullEvent
import com.vitorpamplona.amethyst.commons.model.eventIdSet
import com.vitorpamplona.amethyst.commons.model.events
import com.vitorpamplona.amethyst.commons.model.updateFlow
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.AddressableNote
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.filter
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nip01Core.signers.update
import com.vitorpamplona.quartz.nip09Deletions.DeletionEvent
import com.vitorpamplona.quartz.nip51Lists.bookmarkList.tags.BookmarkIdTag
import com.vitorpamplona.quartz.nip51Lists.labeledBookmarkList.LabeledBookmarkListEvent
import com.vitorpamplona.quartz.nip51Lists.labeledBookmarkList.description
import com.vitorpamplona.quartz.nip51Lists.labeledBookmarkList.image
import com.vitorpamplona.quartz.nip51Lists.labeledBookmarkList.name
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.emitAll
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.transformLatest
import kotlinx.coroutines.flow.update
class LabeledBookmarkListsState(
val signer: NostrSigner,
val cache: LocalCache,
val scope: CoroutineScope,
) {
val user = cache.getOrCreateUser(signer.pubKey)
fun existingLabeledBookmarkNotes() = cache.addressables.filter(LabeledBookmarkListEvent.KIND, user.pubkeyHex)
val labeledBookmarkListVersions = MutableStateFlow(0)
val labeledBookmarkListNotes =
labeledBookmarkListVersions
.map { existingLabeledBookmarkNotes() }
.onStart { emit(existingLabeledBookmarkNotes()) }
.flowOn(Dispatchers.IO)
.stateIn(scope, SharingStarted.Eagerly, emptyList())
val labeledBookmarkListEventIds =
labeledBookmarkListNotes
.map { it.eventIdSet() }
.onStart { emit(labeledBookmarkListNotes.value.eventIdSet()) }
.flowOn(Dispatchers.IO)
.stateIn(scope, SharingStarted.Eagerly, emptySet())
@OptIn(ExperimentalCoroutinesApi::class)
val latestBookmarkLists: StateFlow<List<LabeledBookmarkListEvent>> =
labeledBookmarkListNotes
.transformLatest { emitAll(it.updateFlow<LabeledBookmarkListEvent>()) }
.onStart { emit(labeledBookmarkListNotes.value.events()) }
.flowOn(Dispatchers.IO)
.stateIn(scope, SharingStarted.Eagerly, emptyList())
suspend fun LabeledBookmarkListEvent.toLabeledBookmarkList() =
LabeledBookmarkList(
identifier = dTag(),
title = nameOrTitle() ?: dTag(),
description = description(),
image = image(),
privateBookmarks = privateBookmarks(signer)?.toSet() ?: emptySet(),
publicBookmarks = publicBookmarks().toSet(),
)
suspend fun List<LabeledBookmarkListEvent>.toLabeledBookmarkListsFeed() = map { it.toLabeledBookmarkList() }.sortedBy { it.title }
val listFeedFlow =
latestBookmarkLists
.map { it.toLabeledBookmarkListsFeed() }
.onStart { emit(latestBookmarkLists.value.toLabeledBookmarkListsFeed()) }
.flowOn(Dispatchers.IO)
.stateIn(scope, SharingStarted.Eagerly, emptyList())
fun List<LabeledBookmarkList>.getList(bookmarkListId: String) =
this.firstOrNull {
it.identifier == bookmarkListId
}
fun getBookmarkList(dTag: String) = listFeedFlow.value.getList(bookmarkListId = dTag)
fun DeletionEvent.hasAnyDeletedBookmarkLists() = deleteAddressesWithKind(LabeledBookmarkListEvent.KIND) || deletesAnyEventIn(labeledBookmarkListEventIds.value)
fun hasItemInNoteList(notes: Set<Note>): Boolean =
notes.anyNotNullEvent { event ->
if (event.pubKey == signer.pubKey) {
event is LabeledBookmarkListEvent || (event is DeletionEvent && event.hasAnyDeletedBookmarkLists())
} else {
false
}
}
fun newNotes(newNotes: Set<Note>) {
if (hasItemInNoteList(newNotes)) {
forceRefresh()
}
}
fun deletedNotes(deletedNotes: Set<Note>) {
if (hasItemInNoteList(deletedNotes)) {
forceRefresh()
}
}
fun forceRefresh() {
labeledBookmarkListVersions.update { it + 1 }
}
fun getLabeledBookmarkListNote(bookmarkIdentifier: String): AddressableNote? = existingLabeledBookmarkNotes().find { it.dTag() == bookmarkIdentifier }
fun getLabeledBookmarkListEvent(bookmarkIdentifier: String): LabeledBookmarkListEvent = getLabeledBookmarkListNote(bookmarkIdentifier)?.event as LabeledBookmarkListEvent
fun getLabeledBookmarkListFlow(bookmarkIdentifier: String) =
listFeedFlow
.map { it.getList(bookmarkIdentifier) }
.onStart {
emit(
listFeedFlow.value.getList(bookmarkIdentifier),
)
}.flowOn(Dispatchers.IO)
suspend fun addLabeledBookmarkList(
listName: String,
listDescription: String? = null,
listImage: String? = null,
firstBookmark: BookmarkIdTag? = null,
isBookmarkPrivate: Boolean = false,
account: Account,
) {
val newList =
LabeledBookmarkListEvent.create(
name = listName,
description = listDescription,
image = listImage,
publicBookmarks = if (!isBookmarkPrivate && firstBookmark != null) listOf(firstBookmark) else emptyList(),
privateBookmarks = if (isBookmarkPrivate && firstBookmark != null) listOf(firstBookmark) else emptyList(),
signer = account.signer,
)
account.sendMyPublicAndPrivateOutbox(newList)
}
suspend fun updateMetadata(
listName: String?,
listDescription: String?,
listImage: String?,
bookmarkList: LabeledBookmarkList,
account: Account,
) {
val listEvent = getLabeledBookmarkListEvent(bookmarkList.identifier)
val template =
listEvent.update {
if (listName != null) name(listName)
if (listDescription != null) description(listDescription)
if (listImage != null) image(listImage)
}
val newList = signer.sign(template)
account.sendMyPublicAndPrivateOutbox(newList)
}
suspend fun renameBookmarkList(
newName: String,
bookmarkList: LabeledBookmarkList,
account: Account,
) {
val listEvent = getLabeledBookmarkListEvent(bookmarkList.identifier)
val renamedList =
LabeledBookmarkListEvent.modifyName(
earlierVersion = listEvent,
newName = newName,
signer = account.signer,
)
account.sendMyPublicAndPrivateOutbox(renamedList)
}
suspend fun modifyListDescription(
newDescription: String?,
bookmarkList: LabeledBookmarkList,
account: Account,
) {
val listEvent = getLabeledBookmarkListEvent(bookmarkList.identifier)
val modifiedList =
LabeledBookmarkListEvent.modifyDescription(
earlierVersion = listEvent,
newDescription = newDescription,
signer = account.signer,
)
account.sendMyPublicAndPrivateOutbox(modifiedList)
}
suspend fun cloneBookmarkList(
currentBookmarkList: LabeledBookmarkList,
customCloneName: String?,
customCloneDescription: String?,
account: Account,
) {
val clonedList =
LabeledBookmarkListEvent.create(
name = customCloneName ?: currentBookmarkList.title,
description = customCloneDescription ?: currentBookmarkList.description,
publicBookmarks = currentBookmarkList.publicBookmarks.toList(),
privateBookmarks = currentBookmarkList.privateBookmarks.toList(),
signer = account.signer,
)
account.sendMyPublicAndPrivateOutbox(clonedList)
}
suspend fun deleteBookmarkList(
bookmarkListIdentifier: String,
account: Account,
) {
val listEvent = getLabeledBookmarkListEvent(bookmarkListIdentifier)
val deletionEventTemplate = DeletionEvent.build(listOf(listEvent))
val deletionEvent = account.signer.sign(deletionEventTemplate)
account.sendMyPublicAndPrivateOutbox(deletionEvent)
}
suspend fun addBookmarkToList(
bookmark: BookmarkIdTag,
bookmarkListIdentifier: String,
isBookmarkPrivate: Boolean,
account: Account,
) {
val currentBookmarkList = getLabeledBookmarkListEvent(bookmarkListIdentifier)
val updatedList =
LabeledBookmarkListEvent.addBookmark(
earlierVersion = currentBookmarkList,
bookmarkIdTag = bookmark,
isPrivate = isBookmarkPrivate,
signer = account.signer,
)
account.sendMyPublicAndPrivateOutbox(updatedList)
}
suspend fun moveBookmarkInList(
bookmark: BookmarkIdTag,
bookmarkListIdentifier: String,
isBookmarkCurrentlyPrivate: Boolean,
account: Account,
) {
val bookmarkList = getLabeledBookmarkListEvent(bookmarkListIdentifier)
val updatedList =
LabeledBookmarkListEvent.moveBookmark(
earlierVersion = bookmarkList,
bookmarkIdTag = bookmark,
isCurrentlyPrivate = isBookmarkCurrentlyPrivate,
signer = account.signer,
)
account.sendMyPublicAndPrivateOutbox(updatedList)
}
suspend fun removeBookmarkFromList(
bookmark: BookmarkIdTag,
bookmarkListIdentifier: String,
isBookmarkPrivate: Boolean,
account: Account,
) {
val currentBookmarkList = getLabeledBookmarkListEvent(bookmarkListIdentifier)
val updatedList =
LabeledBookmarkListEvent.removeBookmark(
earlierVersion = currentBookmarkList,
bookmarkIdTag = bookmark,
isPrivate = isBookmarkPrivate,
signer = account.signer,
)
account.sendMyPublicAndPrivateOutbox(updatedList)
}
}
@@ -20,16 +20,16 @@
*/
package com.vitorpamplona.amethyst.model.nip51Lists.peopleList
import com.vitorpamplona.amethyst.commons.model.anyNotNullEvent
import com.vitorpamplona.amethyst.commons.model.eventIdSet
import com.vitorpamplona.amethyst.commons.model.events
import com.vitorpamplona.amethyst.commons.model.updateFlow
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.AddressableNote
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.model.anyNotNullEvent
import com.vitorpamplona.amethyst.model.eventIdSet
import com.vitorpamplona.amethyst.model.events
import com.vitorpamplona.amethyst.model.filter
import com.vitorpamplona.amethyst.model.updateFlow
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nip01Core.signers.update
@@ -43,9 +43,6 @@ import com.vitorpamplona.quartz.nip51Lists.followList.personFirst
import com.vitorpamplona.quartz.nip51Lists.followList.removePerson
import com.vitorpamplona.quartz.nip51Lists.followList.title
import com.vitorpamplona.quartz.nip51Lists.muteList.tags.UserTag
import com.vitorpamplona.quartz.nip51Lists.peopleList.description
import com.vitorpamplona.quartz.nip51Lists.peopleList.image
import com.vitorpamplona.quartz.nip51Lists.peopleList.name
import com.vitorpamplona.quartz.utils.flattenToSet
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
@@ -83,14 +80,14 @@ class FollowListsState(
.map { existingPeopleListNotes() }
.onStart { emit(existingPeopleListNotes()) }
.flowOn(Dispatchers.IO)
.stateIn(scope, SharingStarted.Companion.Eagerly, emptyList())
.stateIn(scope, SharingStarted.Eagerly, emptyList())
val followListsEventIds =
followListNotes
.map { it.eventIdSet() }
.onStart { emit(followListNotes.value.eventIdSet()) }
.flowOn(Dispatchers.IO)
.stateIn(scope, SharingStarted.Companion.Eagerly, emptySet())
.stateIn(scope, SharingStarted.Eagerly, emptySet())
@OptIn(ExperimentalCoroutinesApi::class)
val latestLists: StateFlow<List<FollowListEvent>> =
@@ -107,7 +104,7 @@ class FollowListsState(
.map { it.mapToUserIdSet() }
.onStart { emit(latestLists.value.mapToUserIdSet()) }
.flowOn(Dispatchers.IO)
.stateIn(scope, SharingStarted.Companion.Eagerly, emptySet())
.stateIn(scope, SharingStarted.Eagerly, emptySet())
fun FollowListEvent.toUI() =
PeopleList(
@@ -126,7 +123,7 @@ class FollowListsState(
.map { it.toUI() }
.onStart { emit(latestLists.value.toUI()) }
.flowOn(Dispatchers.IO)
.stateIn(scope, SharingStarted.Companion.Eagerly, emptyList())
.stateIn(scope, SharingStarted.Eagerly, emptyList())
fun List<PeopleList>.select(dTag: String) =
this.firstOrNull {
@@ -142,7 +139,7 @@ class FollowListsState(
fun isUserInFollowSets(user: User): Boolean = allPeopleListProfiles.value.contains(user.pubkeyHex)
fun DeletionEvent.hasDeletedAnyFollowList() = deleteAddressesWithKind(FollowListEvent.Companion.KIND) || deletesAnyEventIn(followListsEventIds.value)
fun DeletionEvent.hasDeletedAnyFollowList() = deleteAddressesWithKind(FollowListEvent.KIND) || deletesAnyEventIn(followListsEventIds.value)
fun hasItemInNoteList(notes: Set<Note>): Boolean =
notes.anyNotNullEvent { event ->
@@ -20,21 +20,20 @@
*/
package com.vitorpamplona.amethyst.model.nip51Lists.peopleList
import com.vitorpamplona.amethyst.commons.model.anyNotNullEvent
import com.vitorpamplona.amethyst.commons.model.eventIdSet
import com.vitorpamplona.amethyst.commons.model.events
import com.vitorpamplona.amethyst.commons.model.updateFlow
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.AddressableNote
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.model.anyNotNullEvent
import com.vitorpamplona.amethyst.model.eventIdSet
import com.vitorpamplona.amethyst.model.events
import com.vitorpamplona.amethyst.model.filter
import com.vitorpamplona.amethyst.model.updateFlow
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nip01Core.signers.update
import com.vitorpamplona.quartz.nip09Deletions.DeletionEvent
import com.vitorpamplona.quartz.nip51Lists.followList.description
import com.vitorpamplona.quartz.nip51Lists.muteList.tags.UserTag
import com.vitorpamplona.quartz.nip51Lists.peopleList.PeopleListEvent
import com.vitorpamplona.quartz.nip51Lists.peopleList.description
@@ -34,7 +34,7 @@ class ReportAction {
by: User,
signer: NostrSigner,
): ReportEvent? {
if (user.hasReport(by, type)) {
if (user.reports().hasReport(by, type)) {
// has already reported this note
return null
}
@@ -21,11 +21,11 @@
package com.vitorpamplona.amethyst.model.privateChats
import androidx.compose.runtime.Stable
import com.vitorpamplona.amethyst.model.ListChange
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.NotesGatherer
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.ui.dal.DefaultFeedOrder
import com.vitorpamplona.amethyst.ui.dal.ListChange
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent
import com.vitorpamplona.quartz.nip14Subject.subject
@@ -45,7 +45,6 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.emitAll
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.flow.stateIn
@@ -43,6 +43,7 @@ import com.vitorpamplona.quartz.nip51Lists.peopleList.PeopleListEvent
import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent
import com.vitorpamplona.quartz.nip72ModCommunities.follow.CommunityListEvent
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.FlowCollector
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combineTransform
@@ -220,11 +221,11 @@ class NoteFeedFlow(
}
@OptIn(ExperimentalCoroutinesApi::class)
override fun flow() =
override fun flow(): Flow<IFeedTopNavFilter> =
combineTransform(metadataFlow, outboxRelays, proxyRelays) { noteState, outboxRelays, proxyRelays ->
val noteEvent = noteState?.note?.event
if (noteEvent == null) {
AuthorsByOutboxTopNavFilter(emptySet(), blockedRelays)
emit(AuthorsByOutboxTopNavFilter(emptySet(), blockedRelays))
} else {
if (proxyRelays.isEmpty()) {
processByOutbox(noteEvent, outboxRelays)
@@ -0,0 +1,41 @@
/**
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.model.trustedAssertions
import com.vitorpamplona.quartz.experimental.trustedAssertions.list.TrustProviderListEvent
import com.vitorpamplona.quartz.experimental.trustedAssertions.list.serviceProviderSet
import com.vitorpamplona.quartz.experimental.trustedAssertions.list.serviceProviders
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nip51Lists.PrivateTagArrayEventCache
class TrustProviderListDecryptionCache(
val signer: NostrSigner,
) {
val cachedPrivateLists = PrivateTagArrayEventCache<TrustProviderListEvent>(signer)
fun cachedServiceProviders(event: TrustProviderListEvent) = cachedPrivateLists.mergeTagListPrecached(event).serviceProviders()
fun cachedServiceProviderSet(event: TrustProviderListEvent) = cachedPrivateLists.mergeTagListPrecached(event).serviceProviderSet()
suspend fun serviceProviders(event: TrustProviderListEvent) = cachedPrivateLists.mergeTagList(event).serviceProviders()
suspend fun serviceProviderSet(event: TrustProviderListEvent) = cachedPrivateLists.mergeTagList(event).serviceProviderSet()
}
@@ -0,0 +1,135 @@
/**
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.model.trustedAssertions
import com.vitorpamplona.amethyst.model.AccountSettings
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.NoteState
import com.vitorpamplona.quartz.experimental.trustedAssertions.list.TrustProviderListEvent
import com.vitorpamplona.quartz.experimental.trustedAssertions.list.tags.ProviderTypes
import com.vitorpamplona.quartz.experimental.trustedAssertions.list.tags.ServiceProviderTag
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.utils.Log
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.transformLatest
import kotlinx.coroutines.launch
import com.vitorpamplona.amethyst.commons.model.trustedAssertions.TrustProviderListState as ITrustProviderListState
class TrustProviderListState(
val signer: NostrSigner,
val cache: LocalCache,
val decryptionCache: TrustProviderListDecryptionCache,
val scope: CoroutineScope,
val settings: AccountSettings,
) : ITrustProviderListState {
// Creates a long-term reference for this note so that the GC doesn't collect the note it self
val trustProviderListNote = cache.getOrCreateAddressableNote(getTrustProviderListAddress())
fun getTrustProviderListAddress() = TrustProviderListEvent.createAddress(signer.pubKey)
fun getTrustProviderListFlow(): StateFlow<NoteState> = trustProviderListNote.flow().metadata.stateFlow
fun getTrustProviderList(): TrustProviderListEvent? = trustProviderListNote.event as? TrustProviderListEvent
suspend fun trustProviderListWithBackup(note: Note): Set<ServiceProviderTag> {
val event = note.event as? TrustProviderListEvent ?: settings.backupTrustProviderList
return event?.let { decryptionCache.serviceProviderSet(it) } ?: emptySet()
}
@OptIn(ExperimentalCoroutinesApi::class)
val liveTrustProviderList: StateFlow<Set<ServiceProviderTag>> =
getTrustProviderListFlow()
.transformLatest { noteState ->
emit(trustProviderListWithBackup(noteState.note))
}.onStart {
emit(trustProviderListWithBackup(trustProviderListNote))
}.flowOn(Dispatchers.IO)
.stateIn(
scope,
SharingStarted.Eagerly,
emptySet(),
)
@OptIn(ExperimentalCoroutinesApi::class)
override val liveUserRankProvider: StateFlow<ServiceProviderTag?> =
liveTrustProviderList
.map {
it.firstOrNull { it.service == ProviderTypes.rank }
}.onStart {
emit(
liveTrustProviderList.value.firstOrNull {
it.service == ProviderTypes.rank
},
)
}.flowOn(Dispatchers.IO)
.stateIn(
scope,
SharingStarted.Eagerly,
null,
)
@OptIn(ExperimentalCoroutinesApi::class)
override val liveUserFollowerCount: StateFlow<ServiceProviderTag?> =
liveTrustProviderList
.map { tagList ->
tagList.firstOrNull { it.service == ProviderTypes.followerCount }
}.onStart {
emit(
liveTrustProviderList.value.firstOrNull { it.service == ProviderTypes.followerCount },
)
}.flowOn(Dispatchers.IO)
.stateIn(
scope,
SharingStarted.Eagerly,
null,
)
init {
settings.backupTrustProviderList?.let { event ->
Log.d("AccountRegisterObservers", "Loading saved ephemeral chat list")
@OptIn(DelicateCoroutinesApi::class)
GlobalScope.launch(Dispatchers.IO) {
LocalCache.justConsumeMyOwnEvent(event)
}
}
scope.launch(Dispatchers.IO) {
Log.d("AccountRegisterObservers", "TrustProviderList Collector Start")
getTrustProviderListFlow().collect { noteState ->
Log.d("AccountRegisterObservers", "TrustProviderList List for ${signer.pubKey}")
(noteState.note.event as? TrustProviderListEvent)?.let {
settings.updateTrustProviderListTo(it)
}
}
}
}
}
@@ -18,10 +18,8 @@
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.ammolite.relays
package com.vitorpamplona.amethyst.service
import androidx.compose.runtime.Stable
import com.vitorpamplona.ammolite.service.checkNotInMainThread
import com.vitorpamplona.quartz.utils.Log
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineExceptionHandler
@@ -37,7 +35,6 @@ import java.util.concurrent.LinkedBlockingQueue
import java.util.concurrent.atomic.AtomicBoolean
/** This class is designed to have a waiting time between two calls of invalidate */
@Stable
class BundledUpdate(
val delay: Long,
val dispatcher: CoroutineDispatcher = Dispatchers.IO,
@@ -63,7 +60,6 @@ class BundledUpdate(
}
/** This class is designed to have a waiting time between two calls of invalidate */
@Stable
class BasicBundledUpdate(
val delay: Long,
val dispatcher: CoroutineDispatcher = Dispatchers.IO,
@@ -101,7 +97,6 @@ class BasicBundledUpdate(
}
/** This class is designed to have a waiting time between two calls of invalidate */
@Stable
class BundledInsert<T>(
val delay: Long,
val dispatcher: CoroutineDispatcher = Dispatchers.IO,
@@ -127,7 +122,6 @@ class BundledInsert<T>(
}
/** This class is designed to have a waiting time between two calls of invalidate */
@Stable
class BasicBundledInsert<T>(
val delay: Long,
val dispatcher: CoroutineDispatcher = Dispatchers.IO,
@@ -140,8 +134,6 @@ class BasicBundledInsert<T>(
newObject: T,
onUpdate: suspend (Set<T>) -> Unit,
) {
checkNotInMainThread()
queue.put(newObject)
if (onlyOneInBlock.getAndSet(true)) {
@@ -28,15 +28,13 @@ import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.coroutines.executeAsync
class Nip05NostrAddressVerifier {
object Nip05NostrAddressVerifier {
suspend fun fetchNip05Json(
nip05: String,
okHttpClient: (String) -> OkHttpClient,
onSuccess: suspend (String) -> Unit,
onError: (String) -> Unit,
) = withContext(Dispatchers.IO) {
checkNotInMainThread()
val url = Nip05().assembleUrl(nip05)
if (url == null) {
@@ -73,15 +71,10 @@ class Nip05NostrAddressVerifier {
onSuccess: suspend (String) -> Unit,
onError: (String) -> Unit,
) {
// check fails on tests
checkNotInMainThread()
fetchNip05Json(
nip05,
okHttpClient,
onSuccess = {
checkNotInMainThread()
Nip05().parseHexKeyFor(nip05, it.lowercase()).fold(
onSuccess = { hexKey ->
if (hexKey == null) {
@@ -31,6 +31,7 @@ import coil3.fetch.ImageFetchResult
import coil3.key.Keyer
import coil3.request.Options
import com.vitorpamplona.amethyst.commons.base64Image.Base64Image
import com.vitorpamplona.amethyst.commons.base64Image.toBitmap
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
import com.vitorpamplona.quartz.utils.sha256.sha256
@@ -42,7 +43,7 @@ class Base64Fetcher(
override suspend fun fetch(): FetchResult? =
runCatching {
ImageFetchResult(
image = Base64Image.Companion.toBitmap(data.toString()).asImage(true),
image = Base64Image.toBitmap(data.toString()).asImage(true),
isSampled = false,
dataSource = DataSource.MEMORY,
)
@@ -30,6 +30,7 @@ import coil3.fetch.ImageFetchResult
import coil3.key.Keyer
import coil3.request.Options
import com.vitorpamplona.amethyst.commons.blurhash.BlurHashDecoder
import com.vitorpamplona.amethyst.commons.blurhash.toAndroidBitmap
data class BlurhashWrapper(
val blurhash: String,
@@ -43,10 +44,10 @@ class BlurHashFetcher(
override suspend fun fetch(): FetchResult? {
val hash = data.blurhash
val bitmap = BlurHashDecoder.decodeKeepAspectRatio(hash, 25) ?: return null
val platformImage = BlurHashDecoder.decodeKeepAspectRatio(hash, 25) ?: return null
return ImageFetchResult(
image = bitmap.asImage(true),
image = platformImage.toAndroidBitmap().asImage(true),
isSampled = false,
dataSource = DataSource.MEMORY,
)
@@ -21,10 +21,11 @@
package com.vitorpamplona.amethyst.service.logging
import android.os.Build
import android.os.Looper
import android.os.StrictMode
import android.os.StrictMode.ThreadPolicy
import android.os.StrictMode.VmPolicy
import com.skydoves.compose.stability.runtime.ComposeStabilityAnalyzer
import com.vitorpamplona.amethyst.BuildConfig
class Logging {
companion object {
@@ -57,8 +58,11 @@ class Logging {
}.penaltyLog()
.build(),
)
Looper.getMainLooper().setMessageLogging(LogMonitor())
ChoreographerHelper.start()
// Looper.getMainLooper().setMessageLogging(LogMonitor())
// ChoreographerHelper.start()
// Enable recomposition tracking ONLY in debug builds
ComposeStabilityAnalyzer.setEnabled(BuildConfig.DEBUG)
}
}
}
@@ -64,7 +64,7 @@ fun GetVideoController(
if (controllerId.needsController()) {
// If there is a connection, don't wait.
if (!onlyOnePreparing.getAndSet(true)) {
scope.launch {
scope.launch(Dispatchers.IO) {
Log.d("PlaybackService", "Preparing Video ${controllerId.id} ${mediaItem.src.videoUri}")
PlaybackServiceClient.prepareController(
mediaControllerState = controllerId,
@@ -172,7 +172,7 @@ fun GetVideoController(
// if the controller is not null, just continue playing what the controller was playing
if (controllerId.needsController()) {
if (!onlyOnePreparing.getAndSet(true)) {
scope.launch(Dispatchers.Main) {
scope.launch(Dispatchers.IO) {
Log.d("PlaybackService", "Preparing Video from Resume ${controllerId.id} ${mediaItem.src.videoUri} ")
PlaybackServiceClient.prepareController(
mediaControllerState = controllerId,
@@ -197,8 +197,6 @@ class MediaSessionPool(
controller: MediaSession.ControllerInfo,
mediaItems: List<MediaItem>,
): ListenableFuture<List<MediaItem>> {
mediaSession.player.setMediaItems(mediaItems)
// set up return call when clicking on the Notification bar
mediaItems.firstOrNull()?.mediaMetadata?.extras?.getString("callbackUri")?.let {
mediaSession.setSessionActivity(
@@ -41,10 +41,10 @@ class WssDataStreamCollector : WebSocketListener() {
reason: String,
) {
super.onClosing(webSocket, code, reason)
wssData.removeAll(wssData)
wssData.clear()
}
fun canStream(): Boolean = wssData.size > 0
fun canStream(): Boolean = wssData.isNotEmpty()
fun getNextStream(): ByteString = wssData.pollFirst()
}
@@ -20,11 +20,11 @@
*/
package com.vitorpamplona.amethyst.service.previews
import com.vitorpamplona.amethyst.commons.preview.HtmlCharsetParser
import com.vitorpamplona.amethyst.commons.preview.MetaTag
import com.vitorpamplona.amethyst.commons.preview.MetaTagsParser
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import okhttp3.MediaType
import okio.BufferedSource
import okio.ByteString.Companion.decodeHex
import okio.Options
@@ -32,10 +32,6 @@ import java.nio.charset.Charset
class HtmlParser {
companion object {
val ATTRIBUTE_VALUE_CHARSET = "charset"
val ATTRIBUTE_VALUE_HTTP_EQUIV = "http-equiv"
val CONTENT = "content"
// taken from okhttp
private val UNICODE_BOMS =
Options.of(
@@ -50,25 +46,30 @@ class HtmlParser {
// UTF-32LE
"ffff0000".decodeHex(),
)
private val RE_CONTENT_TYPE_CHARSET = Regex("""charset=([^;]+)""")
}
suspend fun parseHtml(
source: BufferedSource,
type: MediaType,
type: Charset?,
): Sequence<MetaTag> =
parseHtml(
source.readByteArray(),
type ?: source.readBomAsCharset(),
)
suspend fun parseHtml(
bodyBytes: ByteArray,
type: Charset?,
): Sequence<MetaTag> =
withContext(Dispatchers.IO) {
// sniff charset from Content-Type header or BOM
val sniffedCharset = type.charset() ?: source.readBomAsCharset()
if (sniffedCharset != null) {
val content = source.readByteArray().toString(sniffedCharset)
if (type != null) {
val content = bodyBytes.toString(type)
return@withContext MetaTagsParser.parse(content)
}
// if sniffing was failed, detect charset from content
val bodyBytes = source.readByteArray()
val charset = detectCharset(bodyBytes)
val charset = HtmlCharsetParser.detectCharset(bodyBytes)
val content = bodyBytes.toString(charset)
return@withContext MetaTagsParser.parse(content)
}
@@ -83,29 +84,4 @@ class HtmlParser {
-1 -> null
else -> throw AssertionError()
}
private fun detectCharset(bodyBytes: ByteArray): Charset {
// try to detect charset from meta tags parsed from first 1024 bytes of body
val firstPart = String(bodyBytes, 0, 1024, Charset.forName("utf-8"))
val metaTags = MetaTagsParser.parse(firstPart)
metaTags.forEach { meta ->
val charsetAttr = meta.attr(ATTRIBUTE_VALUE_CHARSET)
if (charsetAttr.isNotEmpty()) {
runCatching { Charset.forName(charsetAttr) }.getOrNull()?.let {
return it
}
}
if (meta.attr(ATTRIBUTE_VALUE_HTTP_EQUIV).lowercase() == "content-type") {
RE_CONTENT_TYPE_CHARSET
.find(meta.attr(CONTENT))
?.let {
runCatching { Charset.forName(it.groupValues[1]) }.getOrNull()
}?.let {
return it
}
}
}
// defaults to UTF-8
return Charset.forName("utf-8")
}
}
@@ -20,6 +20,8 @@
*/
package com.vitorpamplona.amethyst.service.previews
import com.vitorpamplona.amethyst.commons.preview.OpenGraphParser
import com.vitorpamplona.amethyst.commons.preview.UrlInfoItem
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
@@ -62,7 +64,7 @@ class UrlPreview {
response.headers["Content-Type"]?.toMediaType()
?: throw IllegalArgumentException("Website returned unknown mimetype: ${response.headers["Content-Type"]}")
if (mimeType.type == "text" && mimeType.subtype == "html") {
val metaTags = HtmlParser().parseHtml(response.body.source(), mimeType)
val metaTags = HtmlParser().parseHtml(response.body.source(), mimeType.charset())
val data = OpenGraphParser().extractUrlInfo(metaTags)
UrlInfoItem(url, data.title, data.description, data.image, mimeType.toString())
} else if (mimeType.type == "image") {
@@ -37,7 +37,9 @@ class AuthCoordinator(
scope: CoroutineScope,
) {
private val authWithAccounts = ListWithUniqueSetCache<ScreenAuthAccount, Account> { it.account }
private val tempAccount = NostrSignerSync()
private val tempAccount by lazy {
NostrSignerSync()
}
val receiver =
RelayAuthenticator(

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